+8
-6
.air/appview.toml
+8
-6
.air/appview.toml
···
1
-
[build]
2
-
cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go"
3
-
bin = ";set -o allexport && source .env && set +o allexport; .bin/app"
4
root = "."
5
6
-
exclude_regex = [".*_templ.go"]
7
-
include_ext = ["go", "templ", "html", "css"]
8
-
exclude_dir = ["target", "atrium", "nix"]
+11
.air/knot.toml
+11
.air/knot.toml
···
···
1
+
root = "."
2
+
tmp_dir = "out"
3
+
4
+
[build]
5
+
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go'
6
+
bin = "out/knot.out"
7
+
args_bin = ["server"]
8
+
9
+
include_ext = ["go"]
10
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
11
+
stop_on_error = true
-7
.air/knotserver.toml
-7
.air/knotserver.toml
+10
.air/spindle.toml
+10
.air/spindle.toml
+13
.editorconfig
+13
.editorconfig
+3
-1
api/tangled/actorprofile.go
+3
-1
api/tangled/actorprofile.go
···
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
}
···
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
33
}
+196
-2
api/tangled/cbor_gen.go
+196
-2
api/tangled/cbor_gen.go
···
26
}
27
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 7
30
31
if t.Description == nil {
32
fieldCount--
···
41
}
42
43
if t.PinnedRepositories == nil {
44
fieldCount--
45
}
46
···
186
return err
187
}
188
if _, err := cw.WriteString(string(*t.Location)); err != nil {
189
return err
190
}
191
}
···
430
}
431
432
t.Location = (*string)(&sval)
433
}
434
}
435
// t.Description (string) (string)
···
5806
}
5807
5808
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5810
5811
if t.Description == nil {
5812
fieldCount--
···
5821
}
5822
5823
if t.Spindle == nil {
5824
fieldCount--
5825
}
5826
···
5961
}
5962
}
5963
5964
// t.Spindle (string) (string)
5965
if t.Spindle != nil {
5966
···
5993
}
5994
}
5995
5996
// t.CreatedAt (string) (string)
5997
if len("createdAt") > 1000000 {
5998
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
t.Source = (*string)(&sval)
6186
}
6187
}
6188
// t.Spindle (string) (string)
6189
case "spindle":
6190
···
6204
}
6205
6206
t.Spindle = (*string)(&sval)
6207
}
6208
}
6209
// t.CreatedAt (string) (string)
···
26
}
27
28
cw := cbg.NewCborWriter(w)
29
+
fieldCount := 8
30
31
if t.Description == nil {
32
fieldCount--
···
41
}
42
43
if t.PinnedRepositories == nil {
44
+
fieldCount--
45
+
}
46
+
47
+
if t.Pronouns == nil {
48
fieldCount--
49
}
50
···
190
return err
191
}
192
if _, err := cw.WriteString(string(*t.Location)); err != nil {
193
+
return err
194
+
}
195
+
}
196
+
}
197
+
198
+
// t.Pronouns (string) (string)
199
+
if t.Pronouns != nil {
200
+
201
+
if len("pronouns") > 1000000 {
202
+
return xerrors.Errorf("Value in field \"pronouns\" was too long")
203
+
}
204
+
205
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil {
206
+
return err
207
+
}
208
+
if _, err := cw.WriteString(string("pronouns")); err != nil {
209
+
return err
210
+
}
211
+
212
+
if t.Pronouns == nil {
213
+
if _, err := cw.Write(cbg.CborNull); err != nil {
214
+
return err
215
+
}
216
+
} else {
217
+
if len(*t.Pronouns) > 1000000 {
218
+
return xerrors.Errorf("Value in field t.Pronouns was too long")
219
+
}
220
+
221
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil {
222
+
return err
223
+
}
224
+
if _, err := cw.WriteString(string(*t.Pronouns)); err != nil {
225
return err
226
}
227
}
···
466
}
467
468
t.Location = (*string)(&sval)
469
+
}
470
+
}
471
+
// t.Pronouns (string) (string)
472
+
case "pronouns":
473
+
474
+
{
475
+
b, err := cr.ReadByte()
476
+
if err != nil {
477
+
return err
478
+
}
479
+
if b != cbg.CborNull[0] {
480
+
if err := cr.UnreadByte(); err != nil {
481
+
return err
482
+
}
483
+
484
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
485
+
if err != nil {
486
+
return err
487
+
}
488
+
489
+
t.Pronouns = (*string)(&sval)
490
}
491
}
492
// t.Description (string) (string)
···
5863
}
5864
5865
cw := cbg.NewCborWriter(w)
5866
+
fieldCount := 10
5867
5868
if t.Description == nil {
5869
fieldCount--
···
5878
}
5879
5880
if t.Spindle == nil {
5881
+
fieldCount--
5882
+
}
5883
+
5884
+
if t.Topics == nil {
5885
+
fieldCount--
5886
+
}
5887
+
5888
+
if t.Website == nil {
5889
fieldCount--
5890
}
5891
···
6026
}
6027
}
6028
6029
+
// t.Topics ([]string) (slice)
6030
+
if t.Topics != nil {
6031
+
6032
+
if len("topics") > 1000000 {
6033
+
return xerrors.Errorf("Value in field \"topics\" was too long")
6034
+
}
6035
+
6036
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil {
6037
+
return err
6038
+
}
6039
+
if _, err := cw.WriteString(string("topics")); err != nil {
6040
+
return err
6041
+
}
6042
+
6043
+
if len(t.Topics) > 8192 {
6044
+
return xerrors.Errorf("Slice value in field t.Topics was too long")
6045
+
}
6046
+
6047
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil {
6048
+
return err
6049
+
}
6050
+
for _, v := range t.Topics {
6051
+
if len(v) > 1000000 {
6052
+
return xerrors.Errorf("Value in field v was too long")
6053
+
}
6054
+
6055
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
6056
+
return err
6057
+
}
6058
+
if _, err := cw.WriteString(string(v)); err != nil {
6059
+
return err
6060
+
}
6061
+
6062
+
}
6063
+
}
6064
+
6065
// t.Spindle (string) (string)
6066
if t.Spindle != nil {
6067
···
6094
}
6095
}
6096
6097
+
// t.Website (string) (string)
6098
+
if t.Website != nil {
6099
+
6100
+
if len("website") > 1000000 {
6101
+
return xerrors.Errorf("Value in field \"website\" was too long")
6102
+
}
6103
+
6104
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil {
6105
+
return err
6106
+
}
6107
+
if _, err := cw.WriteString(string("website")); err != nil {
6108
+
return err
6109
+
}
6110
+
6111
+
if t.Website == nil {
6112
+
if _, err := cw.Write(cbg.CborNull); err != nil {
6113
+
return err
6114
+
}
6115
+
} else {
6116
+
if len(*t.Website) > 1000000 {
6117
+
return xerrors.Errorf("Value in field t.Website was too long")
6118
+
}
6119
+
6120
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil {
6121
+
return err
6122
+
}
6123
+
if _, err := cw.WriteString(string(*t.Website)); err != nil {
6124
+
return err
6125
+
}
6126
+
}
6127
+
}
6128
+
6129
// t.CreatedAt (string) (string)
6130
if len("createdAt") > 1000000 {
6131
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6318
t.Source = (*string)(&sval)
6319
}
6320
}
6321
+
// t.Topics ([]string) (slice)
6322
+
case "topics":
6323
+
6324
+
maj, extra, err = cr.ReadHeader()
6325
+
if err != nil {
6326
+
return err
6327
+
}
6328
+
6329
+
if extra > 8192 {
6330
+
return fmt.Errorf("t.Topics: array too large (%d)", extra)
6331
+
}
6332
+
6333
+
if maj != cbg.MajArray {
6334
+
return fmt.Errorf("expected cbor array")
6335
+
}
6336
+
6337
+
if extra > 0 {
6338
+
t.Topics = make([]string, extra)
6339
+
}
6340
+
6341
+
for i := 0; i < int(extra); i++ {
6342
+
{
6343
+
var maj byte
6344
+
var extra uint64
6345
+
var err error
6346
+
_ = maj
6347
+
_ = extra
6348
+
_ = err
6349
+
6350
+
{
6351
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6352
+
if err != nil {
6353
+
return err
6354
+
}
6355
+
6356
+
t.Topics[i] = string(sval)
6357
+
}
6358
+
6359
+
}
6360
+
}
6361
// t.Spindle (string) (string)
6362
case "spindle":
6363
···
6377
}
6378
6379
t.Spindle = (*string)(&sval)
6380
+
}
6381
+
}
6382
+
// t.Website (string) (string)
6383
+
case "website":
6384
+
6385
+
{
6386
+
b, err := cr.ReadByte()
6387
+
if err != nil {
6388
+
return err
6389
+
}
6390
+
if b != cbg.CborNull[0] {
6391
+
if err := cr.UnreadByte(); err != nil {
6392
+
return err
6393
+
}
6394
+
6395
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6396
+
if err != nil {
6397
+
return err
6398
+
}
6399
+
6400
+
t.Website = (*string)(&sval)
6401
}
6402
}
6403
// t.CreatedAt (string) (string)
+13
-1
api/tangled/repoblob.go
+13
-1
api/tangled/repoblob.go
···
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
type RepoBlob_Output struct {
32
// content: File content (base64 encoded for binary files)
33
-
Content string `json:"content" cborgen:"content"`
34
// encoding: Content encoding
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
// isBinary: Whether the file is binary
···
44
Ref string `json:"ref" cborgen:"ref"`
45
// size: File size in bytes
46
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
}
48
49
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
54
Name string `json:"name" cborgen:"name"`
55
// when: Author timestamp
56
When string `json:"when" cborgen:"when"`
57
}
58
59
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
···
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
type RepoBlob_Output struct {
32
// content: File content (base64 encoded for binary files)
33
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
34
// encoding: Content encoding
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
// isBinary: Whether the file is binary
···
44
Ref string `json:"ref" cborgen:"ref"`
45
// size: File size in bytes
46
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
+
// submodule: Submodule information if path is a submodule
48
+
Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"`
49
}
50
51
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
56
Name string `json:"name" cborgen:"name"`
57
// when: Author timestamp
58
When string `json:"when" cborgen:"when"`
59
+
}
60
+
61
+
// RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema.
62
+
type RepoBlob_Submodule struct {
63
+
// branch: Branch to track in the submodule
64
+
Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"`
65
+
// name: Submodule name
66
+
Name string `json:"name" cborgen:"name"`
67
+
// url: Submodule repository URL
68
+
Url string `json:"url" cborgen:"url"`
69
}
70
71
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
-4
api/tangled/repotree.go
···
47
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
49
type RepoTree_TreeEntry struct {
50
-
// is_file: Whether this entry is a file
51
-
Is_file bool `json:"is_file" cborgen:"is_file"`
52
-
// is_subtree: Whether this entry is a directory/subtree
53
-
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
54
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
55
// mode: File mode
56
Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
+4
api/tangled/tangledrepo.go
···
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
// spindle: CI runner to send jobs to and receive results from
32
Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"`
33
+
// topics: Topics related to the repo
34
+
Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"`
35
+
// website: Any URI related to the repo
36
+
Website *string `json:"website,omitempty" cborgen:"website,omitempty"`
37
}
+11
appview/config/config.go
+11
appview/config/config.go
···
30
ClientKid string `env:"CLIENT_KID"`
31
}
32
33
type JetstreamConfig struct {
34
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
35
}
···
80
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
81
}
82
83
func (cfg RedisConfig) ToURL() string {
84
u := &url.URL{
85
Scheme: "redis",
···
105
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
106
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
107
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
108
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
109
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
110
}
111
112
func LoadConfig(ctx context.Context) (*Config, error) {
···
30
ClientKid string `env:"CLIENT_KID"`
31
}
32
33
+
type PlcConfig struct {
34
+
PLCURL string `env:"URL, default=https://plc.directory"`
35
+
}
36
+
37
type JetstreamConfig struct {
38
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
39
}
···
84
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
85
}
86
87
+
type LabelConfig struct {
88
+
DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=,
89
+
GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"`
90
+
}
91
+
92
func (cfg RedisConfig) ToURL() string {
93
u := &url.URL{
94
Scheme: "redis",
···
114
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
115
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
116
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
118
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
119
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
121
}
122
123
func LoadConfig(ctx context.Context) (*Config, error) {
+61
-2
appview/db/db.go
+61
-2
appview/db/db.go
···
569
-- indexes for better performance
570
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
571
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
572
-
create index if not exists idx_stars_created on stars(created);
573
-
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
574
`)
575
if err != nil {
576
return nil, err
···
1102
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
_, err := tx.Exec(`
1104
alter table pull_submissions add column combined text;
1105
`)
1106
return err
1107
})
···
569
-- indexes for better performance
570
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
571
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
572
`)
573
if err != nil {
574
return nil, err
···
1100
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1101
_, err := tx.Exec(`
1102
alter table pull_submissions add column combined text;
1103
+
`)
1104
+
return err
1105
+
})
1106
+
1107
+
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1108
+
_, err := tx.Exec(`
1109
+
alter table profile add column pronouns text;
1110
+
`)
1111
+
return err
1112
+
})
1113
+
1114
+
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1115
+
_, err := tx.Exec(`
1116
+
alter table repos add column website text;
1117
+
alter table repos add column topics text;
1118
+
`)
1119
+
return err
1120
+
})
1121
+
1122
+
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1123
+
_, err := tx.Exec(`
1124
+
alter table notification_preferences add column user_mentioned integer not null default 1;
1125
+
`)
1126
+
return err
1127
+
})
1128
+
1129
+
// remove the foreign key constraints from stars.
1130
+
runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1131
+
_, err := tx.Exec(`
1132
+
create table stars_new (
1133
+
id integer primary key autoincrement,
1134
+
did text not null,
1135
+
rkey text not null,
1136
+
1137
+
subject_at text not null,
1138
+
1139
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1140
+
unique(did, rkey),
1141
+
unique(did, subject_at)
1142
+
);
1143
+
1144
+
insert into stars_new (
1145
+
id,
1146
+
did,
1147
+
rkey,
1148
+
subject_at,
1149
+
created
1150
+
)
1151
+
select
1152
+
id,
1153
+
starred_by_did,
1154
+
rkey,
1155
+
repo_at,
1156
+
created
1157
+
from stars;
1158
+
1159
+
drop table stars;
1160
+
alter table stars_new rename to stars;
1161
+
1162
+
create index if not exists idx_stars_created on stars(created);
1163
+
create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
1164
`)
1165
return err
1166
})
+15
-5
appview/db/notifications.go
+15
-5
appview/db/notifications.go
···
134
select
135
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
136
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
137
-
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
138
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,
139
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
140
from notifications n
···
163
var issue models.Issue
164
var pull models.Pull
165
var rId, iId, pId sql.NullInt64
166
-
var rDid, rName, rDescription sql.NullString
167
var iDid sql.NullString
168
var iIssueId sql.NullInt64
169
var iTitle sql.NullString
···
176
err := rows.Scan(
177
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
178
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
179
-
&rId, &rDid, &rName, &rDescription,
180
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
181
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
182
)
···
203
}
204
if rDescription.Valid {
205
repo.Description = rDescription.String
206
}
207
nwe.Repo = &repo
208
}
···
394
pull_created,
395
pull_commented,
396
followed,
397
pull_merged,
398
issue_closed,
399
email_notifications
···
419
&prefs.PullCreated,
420
&prefs.PullCommented,
421
&prefs.Followed,
422
&prefs.PullMerged,
423
&prefs.IssueClosed,
424
&prefs.EmailNotifications,
···
440
query := `
441
INSERT OR REPLACE INTO notification_preferences
442
(user_did, repo_starred, issue_created, issue_commented, pull_created,
443
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
444
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
445
`
446
447
result, err := d.DB.ExecContext(ctx, query,
···
452
prefs.PullCreated,
453
prefs.PullCommented,
454
prefs.Followed,
455
prefs.PullMerged,
456
prefs.IssueClosed,
457
prefs.EmailNotifications,
···
134
select
135
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
136
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
137
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics,
138
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,
139
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
140
from notifications n
···
163
var issue models.Issue
164
var pull models.Pull
165
var rId, iId, pId sql.NullInt64
166
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
167
var iDid sql.NullString
168
var iIssueId sql.NullInt64
169
var iTitle sql.NullString
···
176
err := rows.Scan(
177
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
178
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
179
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
180
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
181
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
182
)
···
203
}
204
if rDescription.Valid {
205
repo.Description = rDescription.String
206
+
}
207
+
if rWebsite.Valid {
208
+
repo.Website = rWebsite.String
209
+
}
210
+
if rTopicStr.Valid {
211
+
repo.Topics = strings.Fields(rTopicStr.String)
212
}
213
nwe.Repo = &repo
214
}
···
400
pull_created,
401
pull_commented,
402
followed,
403
+
user_mentioned,
404
pull_merged,
405
issue_closed,
406
email_notifications
···
426
&prefs.PullCreated,
427
&prefs.PullCommented,
428
&prefs.Followed,
429
+
&prefs.UserMentioned,
430
&prefs.PullMerged,
431
&prefs.IssueClosed,
432
&prefs.EmailNotifications,
···
448
query := `
449
INSERT OR REPLACE INTO notification_preferences
450
(user_did, repo_starred, issue_created, issue_commented, pull_created,
451
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
+
email_notifications)
453
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
454
`
455
456
result, err := d.DB.ExecContext(ctx, query,
···
461
prefs.PullCreated,
462
prefs.PullCommented,
463
prefs.Followed,
464
+
prefs.UserMentioned,
465
prefs.PullMerged,
466
prefs.IssueClosed,
467
prefs.EmailNotifications,
+4
-2
appview/db/pipeline.go
+4
-2
appview/db/pipeline.go
···
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 {
···
205
join
206
triggers t ON p.trigger_id = t.id
207
%s
208
-
`, whereClause)
209
210
rows, err := e.Query(query, args...)
211
if err != nil {
···
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, limit int, filters ...filter) ([]models.Pipeline, error) {
172
var conditions []string
173
var args []any
174
for _, filter := range filters {
···
205
join
206
triggers t ON p.trigger_id = t.id
207
%s
208
+
order by p.created desc
209
+
limit %d
210
+
`, whereClause, limit)
211
212
rows, err := e.Query(query, args...)
213
if err != nil {
+26
-6
appview/db/profile.go
+26
-6
appview/db/profile.go
···
129
did,
130
description,
131
include_bluesky,
132
-
location
133
)
134
-
values (?, ?, ?, ?)`,
135
profile.Did,
136
profile.Description,
137
includeBskyValue,
138
profile.Location,
139
)
140
141
if err != nil {
···
216
did,
217
description,
218
include_bluesky,
219
-
location
220
from
221
profile
222
%s`,
···
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)
236
if err != nil {
237
return nil, err
238
}
239
240
if includeBluesky != 0 {
241
profile.IncludeBluesky = true
242
}
243
244
profileMap[profile.Did] = &profile
···
302
303
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
var profile models.Profile
305
profile.Did = did
306
307
includeBluesky := 0
308
err := e.QueryRow(
309
-
`select description, include_bluesky, location from profile where did = ?`,
310
did,
311
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
312
if err == sql.ErrNoRows {
313
profile := models.Profile{}
314
profile.Did = did
···
321
322
if includeBluesky != 0 {
323
profile.IncludeBluesky = true
324
}
325
326
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
412
// ensure description is not too long
413
if len(profile.Location) > 40 {
414
return fmt.Errorf("Entered location is too long.")
415
}
416
417
// ensure links are in order
···
129
did,
130
description,
131
include_bluesky,
132
+
location,
133
+
pronouns
134
)
135
+
values (?, ?, ?, ?, ?)`,
136
profile.Did,
137
profile.Description,
138
includeBskyValue,
139
profile.Location,
140
+
profile.Pronouns,
141
)
142
143
if err != nil {
···
218
did,
219
description,
220
include_bluesky,
221
+
location,
222
+
pronouns
223
from
224
profile
225
%s`,
···
234
for rows.Next() {
235
var profile models.Profile
236
var includeBluesky int
237
+
var pronouns sql.Null[string]
238
239
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
240
if err != nil {
241
return nil, err
242
}
243
244
if includeBluesky != 0 {
245
profile.IncludeBluesky = true
246
+
}
247
+
248
+
if pronouns.Valid {
249
+
profile.Pronouns = pronouns.V
250
}
251
252
profileMap[profile.Did] = &profile
···
310
311
func GetProfile(e Execer, did string) (*models.Profile, error) {
312
var profile models.Profile
313
+
var pronouns sql.Null[string]
314
+
315
profile.Did = did
316
317
includeBluesky := 0
318
+
319
err := e.QueryRow(
320
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
321
did,
322
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
323
if err == sql.ErrNoRows {
324
profile := models.Profile{}
325
profile.Did = did
···
332
333
if includeBluesky != 0 {
334
profile.IncludeBluesky = true
335
+
}
336
+
337
+
if pronouns.Valid {
338
+
profile.Pronouns = pronouns.V
339
}
340
341
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
427
// ensure description is not too long
428
if len(profile.Location) > 40 {
429
return fmt.Errorf("Entered location is too long.")
430
+
}
431
+
432
+
// ensure pronouns are not too long
433
+
if len(profile.Pronouns) > 40 {
434
+
return fmt.Errorf("Entered pronouns are too long.")
435
}
436
437
// ensure links are in order
+4
-4
appview/db/pulls.go
+4
-4
appview/db/pulls.go
···
92
_, err = tx.Exec(`
93
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
values (?, ?, ?, ?, ?)
95
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
101
if err != nil {
102
return "", err
103
}
104
-
return pull.PullAt(), err
105
}
106
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
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 {
···
92
_, err = tx.Exec(`
93
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
values (?, ?, ?, ?, ?)
95
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
101
if err != nil {
102
return "", err
103
}
104
+
return pull.AtUri(), err
105
}
106
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
+
pulls[pull.AtUri()] = &pull
218
}
219
220
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
+
pullAts = append(pullAts, p.AtUri())
223
}
224
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
+53
-15
appview/db/repos.go
+53
-15
appview/db/repos.go
···
70
rkey,
71
created,
72
description,
73
source,
74
spindle
75
from
···
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,
···
99
&repo.Rkey,
100
&createdAt,
101
&description,
102
&source,
103
&spindle,
104
)
···
111
}
112
if description.Valid {
113
repo.Description = description.String
114
}
115
if source.Valid {
116
repo.Source = source.String
···
198
199
starCountQuery := fmt.Sprintf(
200
`select
201
-
repo_at, count(1)
202
from stars
203
-
where repo_at in (%s)
204
-
group by repo_at`,
205
inClause,
206
)
207
rows, err = e.Query(starCountQuery, args...)
···
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)
···
368
369
if nullableDescription.Valid {
370
repo.Description = nullableDescription.String
371
-
} else {
372
-
repo.Description = ""
373
}
374
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)
···
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 = ?)
···
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
}
···
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
}
486
487
if nullableDescription.Valid {
488
repo.Description = nullableDescription.String
489
}
490
491
if nullableSource.Valid {
···
70
rkey,
71
created,
72
description,
73
+
website,
74
+
topics,
75
source,
76
spindle
77
from
···
91
for rows.Next() {
92
var repo models.Repo
93
var createdAt string
94
+
var description, website, topicStr, source, spindle sql.NullString
95
96
err := rows.Scan(
97
&repo.Id,
···
101
&repo.Rkey,
102
&createdAt,
103
&description,
104
+
&website,
105
+
&topicStr,
106
&source,
107
&spindle,
108
)
···
115
}
116
if description.Valid {
117
repo.Description = description.String
118
+
}
119
+
if website.Valid {
120
+
repo.Website = website.String
121
+
}
122
+
if topicStr.Valid {
123
+
repo.Topics = strings.Fields(topicStr.String)
124
}
125
if source.Valid {
126
repo.Source = source.String
···
208
209
starCountQuery := fmt.Sprintf(
210
`select
211
+
subject_at, count(1)
212
from stars
213
+
where subject_at in (%s)
214
+
group by subject_at`,
215
inClause,
216
)
217
rows, err = e.Query(starCountQuery, args...)
···
366
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
367
var repo models.Repo
368
var nullableDescription sql.NullString
369
+
var nullableWebsite sql.NullString
370
+
var nullableTopicStr sql.NullString
371
372
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
373
374
var createdAt string
375
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
376
return nil, err
377
}
378
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
380
381
if nullableDescription.Valid {
382
repo.Description = nullableDescription.String
383
+
}
384
+
if nullableWebsite.Valid {
385
+
repo.Website = nullableWebsite.String
386
+
}
387
+
if nullableTopicStr.Valid {
388
+
repo.Topics = strings.Fields(nullableTopicStr.String)
389
}
390
391
return &repo, nil
392
}
393
394
+
func PutRepo(tx *sql.Tx, repo models.Repo) error {
395
+
_, err := tx.Exec(
396
+
`update repos
397
+
set knot = ?, description = ?, website = ?, topics = ?
398
+
where did = ? and rkey = ?
399
+
`,
400
+
repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey,
401
+
)
402
+
return err
403
+
}
404
+
405
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
406
_, err := tx.Exec(
407
`insert into repos
408
+
(did, name, knot, rkey, at_uri, description, website, topics, source)
409
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
410
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source,
411
)
412
if err != nil {
413
return fmt.Errorf("failed to insert repo: %w", err)
···
443
var repos []models.Repo
444
445
rows, err := e.Query(
446
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
447
from repos r
448
left join collaborators c on r.at_uri = c.repo_at
449
where (r.did = ? or c.subject_did = ?)
···
461
var repo models.Repo
462
var createdAt string
463
var nullableDescription sql.NullString
464
+
var nullableWebsite sql.NullString
465
var nullableSource sql.NullString
466
467
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
468
if err != nil {
469
return nil, err
470
}
···
498
var repo models.Repo
499
var createdAt string
500
var nullableDescription sql.NullString
501
+
var nullableWebsite sql.NullString
502
+
var nullableTopicStr sql.NullString
503
var nullableSource sql.NullString
504
505
row := e.QueryRow(
506
+
`select id, did, name, knot, rkey, description, website, topics, created, source
507
from repos
508
where did = ? and name = ? and source is not null and source != ''`,
509
did, name,
510
)
511
512
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
513
if err != nil {
514
return nil, err
515
}
516
517
if nullableDescription.Valid {
518
repo.Description = nullableDescription.String
519
+
}
520
+
521
+
if nullableWebsite.Valid {
522
+
repo.Website = nullableWebsite.String
523
+
}
524
+
525
+
if nullableTopicStr.Valid {
526
+
repo.Topics = strings.Fields(nullableTopicStr.String)
527
}
528
529
if nullableSource.Valid {
+39
-99
appview/db/star.go
+39
-99
appview/db/star.go
···
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,
20
-
star.StarredByDid,
21
star.RepoAt.String(),
22
star.Rkey,
23
)
···
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 {
39
return nil, err
40
}
···
51
}
52
53
// Remove a star
54
-
func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error {
55
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt)
56
return err
57
}
58
59
// Remove a star
60
-
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
61
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
62
return err
63
}
64
65
-
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
66
stars := 0
67
err := e.QueryRow(
68
-
`select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
69
if err != nil {
70
return 0, err
71
}
···
89
}
90
91
query := fmt.Sprintf(`
92
-
SELECT repo_at
93
FROM stars
94
-
WHERE starred_by_did = ? AND repo_at IN (%s)
95
`, strings.Join(placeholders, ","))
96
97
rows, err := e.Query(query, args...)
···
118
return result, nil
119
}
120
121
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
122
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
123
if err != nil {
124
return false
125
}
126
-
return statuses[repoAt.String()]
127
}
128
129
// GetStarStatuses returns a map of repo URIs to star status for a given user
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 {
···
149
}
150
151
repoQuery := fmt.Sprintf(
152
-
`select starred_by_did, repo_at, created, rkey
153
from stars
154
%s
155
order by created desc
···
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 {
171
return nil, err
172
}
···
197
return nil, err
198
}
199
200
for _, r := range repos {
201
if stars, ok := starMap[string(r.RepoAt())]; ok {
202
-
for i := range stars {
203
-
stars[i].Repo = &r
204
}
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
}
···
220
return 0
221
})
222
223
-
return stars, nil
224
}
225
226
func CountStars(e Execer, filters ...filter) (int64, error) {
···
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
255
-
s.starred_by_did,
256
-
s.repo_at,
257
-
s.rkey,
258
-
s.created,
259
-
r.did,
260
-
r.name,
261
-
r.knot,
262
-
r.rkey,
263
-
r.created
264
-
from stars s
265
-
join repos r on s.repo_at = r.at_uri
266
-
`)
267
-
268
-
if err != nil {
269
-
return nil, err
270
-
}
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(
279
-
&star.StarredByDid,
280
-
&star.RepoAt,
281
-
&star.Rkey,
282
-
&starCreatedAt,
283
-
&repo.Did,
284
-
&repo.Name,
285
-
&repo.Knot,
286
-
&repo.Rkey,
287
-
&repoCreatedAt,
288
-
); err != nil {
289
-
return nil, err
290
-
}
291
-
292
-
star.Created, err = time.Parse(time.RFC3339, starCreatedAt)
293
-
if err != nil {
294
-
star.Created = time.Now()
295
-
}
296
-
repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt)
297
-
if err != nil {
298
-
repo.Created = time.Now()
299
-
}
300
-
star.Repo = &repo
301
-
302
-
stars = append(stars, star)
303
-
}
304
-
305
-
if err := rows.Err(); err != nil {
306
-
return nil, err
307
-
}
308
-
309
-
return stars, nil
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 (
317
-
select distinct repo_at
318
from stars
319
where created >= datetime('now', '-7 days')
320
),
321
repo_star_counts as (
322
select
323
-
s.repo_at,
324
count(*) as stars_gained_last_week
325
from stars s
326
-
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
327
where s.created >= datetime('now', '-7 days')
328
-
group by s.repo_at
329
)
330
-
select rsc.repo_at
331
from repo_star_counts rsc
332
order by rsc.stars_gained_last_week desc
333
limit 8
···
14
)
15
16
func AddStar(e Execer, star *models.Star) error {
17
+
query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)`
18
_, err := e.Exec(
19
query,
20
+
star.Did,
21
star.RepoAt.String(),
22
star.Rkey,
23
)
···
25
}
26
27
// Get a star record
28
+
func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) {
29
query := `
30
+
select did, subject_at, created, rkey
31
from stars
32
+
where did = ? and subject_at = ?`
33
+
row := e.QueryRow(query, did, subjectAt)
34
35
var star models.Star
36
var created string
37
+
err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
38
if err != nil {
39
return nil, err
40
}
···
51
}
52
53
// Remove a star
54
+
func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error {
55
+
_, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt)
56
return err
57
}
58
59
// Remove a star
60
+
func DeleteStarByRkey(e Execer, did string, rkey string) error {
61
+
_, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey)
62
return err
63
}
64
65
+
func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) {
66
stars := 0
67
err := e.QueryRow(
68
+
`select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars)
69
if err != nil {
70
return 0, err
71
}
···
89
}
90
91
query := fmt.Sprintf(`
92
+
SELECT subject_at
93
FROM stars
94
+
WHERE did = ? AND subject_at IN (%s)
95
`, strings.Join(placeholders, ","))
96
97
rows, err := e.Query(query, args...)
···
118
return result, nil
119
}
120
121
+
func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool {
122
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt})
123
if err != nil {
124
return false
125
}
126
+
return statuses[subjectAt.String()]
127
}
128
129
// GetStarStatuses returns a map of repo URIs to star status for a given user
130
+
func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) {
131
+
return getStarStatuses(e, userDid, subjectAts)
132
}
133
+
134
+
// GetRepoStars return a list of stars each holding target repository.
135
+
// If there isn't known repo with starred at-uri, those stars will be ignored.
136
+
func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) {
137
var conditions []string
138
var args []any
139
for _, filter := range filters {
···
152
}
153
154
repoQuery := fmt.Sprintf(
155
+
`select did, subject_at, created, rkey
156
from stars
157
%s
158
order by created desc
···
169
for rows.Next() {
170
var star models.Star
171
var created string
172
+
err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
173
if err != nil {
174
return nil, err
175
}
···
200
return nil, err
201
}
202
203
+
var repoStars []models.RepoStar
204
for _, r := range repos {
205
if stars, ok := starMap[string(r.RepoAt())]; ok {
206
+
for _, star := range stars {
207
+
repoStars = append(repoStars, models.RepoStar{
208
+
Star: star,
209
+
Repo: &r,
210
+
})
211
}
212
}
213
}
214
215
+
slices.SortFunc(repoStars, func(a, b models.RepoStar) int {
216
if a.Created.After(b.Created) {
217
return -1
218
}
···
222
return 0
223
})
224
225
+
return repoStars, nil
226
}
227
228
func CountStars(e Execer, filters ...filter) (int64, error) {
···
249
return count, nil
250
}
251
252
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
253
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
254
// first, get the top repo URIs by star count from the last week
255
query := `
256
with recent_starred_repos as (
257
+
select distinct subject_at
258
from stars
259
where created >= datetime('now', '-7 days')
260
),
261
repo_star_counts as (
262
select
263
+
s.subject_at,
264
count(*) as stars_gained_last_week
265
from stars s
266
+
join recent_starred_repos rsr on s.subject_at = rsr.subject_at
267
where s.created >= datetime('now', '-7 days')
268
+
group by s.subject_at
269
)
270
+
select rsc.subject_at
271
from repo_star_counts rsc
272
order by rsc.stars_gained_last_week desc
273
limit 8
+3
-13
appview/db/timeline.go
+3
-13
appview/db/timeline.go
···
146
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
filters := make([]filter, 0)
148
if userIsFollowing != nil {
149
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
}
151
152
-
stars, err := GetStars(e, limit, filters...)
153
if err != nil {
154
return nil, err
155
}
156
157
-
// filter star records without a repo
158
-
n := 0
159
-
for _, s := range stars {
160
-
if s.Repo != nil {
161
-
stars[n] = s
162
-
n++
163
-
}
164
-
}
165
-
stars = stars[:n]
166
-
167
var repos []models.Repo
168
for _, s := range stars {
169
repos = append(repos, *s.Repo)
···
179
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
180
181
events = append(events, models.TimelineEvent{
182
-
Star: &s,
183
EventAt: s.Created,
184
IsStarred: isStarred,
185
StarCount: starCount,
···
146
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
filters := make([]filter, 0)
148
if userIsFollowing != nil {
149
+
filters = append(filters, FilterIn("did", userIsFollowing))
150
}
151
152
+
stars, err := GetRepoStars(e, limit, filters...)
153
if err != nil {
154
return nil, err
155
}
156
157
var repos []models.Repo
158
for _, s := range stars {
159
repos = append(repos, *s.Repo)
···
169
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
170
171
events = append(events, models.TimelineEvent{
172
+
RepoStar: &s,
173
EventAt: s.Created,
174
IsStarred: isStarred,
175
StarCount: starCount,
+3
-1
appview/indexer/issues/indexer.go
+3
-1
appview/indexer/issues/indexer.go
+4
-3
appview/indexer/notifier.go
+4
-3
appview/indexer/notifier.go
···
3
import (
4
"context"
5
6
"tangled.org/core/appview/models"
7
"tangled.org/core/appview/notify"
8
"tangled.org/core/log"
···
10
11
var _ notify.Notifier = &Indexer{}
12
13
-
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) {
14
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
15
l.Debug("indexing new issue")
16
err := ix.Issues.Index(ctx, *issue)
···
19
}
20
}
21
22
-
func (ix *Indexer) NewIssueState(ctx context.Context, issue *models.Issue) {
23
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
24
l.Debug("updating an issue")
25
err := ix.Issues.Index(ctx, *issue)
···
46
}
47
}
48
49
-
func (ix *Indexer) NewPullState(ctx context.Context, pull *models.Pull) {
50
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
51
l.Debug("updating a pr")
52
err := ix.Pulls.Index(ctx, pull)
···
3
import (
4
"context"
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
"tangled.org/core/appview/models"
8
"tangled.org/core/appview/notify"
9
"tangled.org/core/log"
···
11
12
var _ notify.Notifier = &Indexer{}
13
14
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
15
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
16
l.Debug("indexing new issue")
17
err := ix.Issues.Index(ctx, *issue)
···
20
}
21
}
22
23
+
func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
24
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
25
l.Debug("updating an issue")
26
err := ix.Issues.Index(ctx, *issue)
···
47
}
48
}
49
50
+
func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
51
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
52
l.Debug("updating a pr")
53
err := ix.Pulls.Index(ctx, pull)
+3
-1
appview/indexer/pulls/indexer.go
+3
-1
appview/indexer/pulls/indexer.go
+9
-3
appview/ingester.go
+9
-3
appview/ingester.go
···
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)
···
291
292
includeBluesky := record.Bluesky
293
294
location := ""
295
if record.Location != nil {
296
location = *record.Location
···
325
Links: links,
326
Stats: stats,
327
PinnedRepos: pinned,
328
}
329
330
ddb, ok := i.Db.Execer.(*db.DB)
···
121
return err
122
}
123
err = db.AddStar(i.Db, &models.Star{
124
+
Did: did,
125
+
RepoAt: subjectUri,
126
+
Rkey: e.Commit.RKey,
127
})
128
case jmodels.CommitOperationDelete:
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
···
291
292
includeBluesky := record.Bluesky
293
294
+
pronouns := ""
295
+
if record.Pronouns != nil {
296
+
pronouns = *record.Pronouns
297
+
}
298
+
299
location := ""
300
if record.Location != nil {
301
location = *record.Location
···
330
Links: links,
331
Stats: stats,
332
PinnedRepos: pinned,
333
+
Pronouns: pronouns,
334
}
335
336
ddb, ok := i.Db.Execer.(*db.DB)
+59
-20
appview/issues/issues.go
+59
-20
appview/issues/issues.go
···
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
"tangled.org/core/appview/pagination"
28
"tangled.org/core/appview/reporesolver"
29
"tangled.org/core/appview/validator"
···
309
issue.Open = false
310
311
// notify about the issue closure
312
-
rp.notifier.NewIssueState(r.Context(), issue)
313
314
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
315
return
···
359
issue.Open = true
360
361
// notify about the issue reopen
362
-
rp.notifier.NewIssueState(r.Context(), issue)
363
364
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
365
return
···
453
454
// notify about the new comment
455
comment.Id = commentId
456
-
rp.notifier.NewIssueComment(r.Context(), &comment)
457
458
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
459
}
···
793
return
794
}
795
796
keyword := params.Get("q")
797
798
-
var ids []int64
799
searchOpts := models.IssueSearchOptions{
800
Keyword: keyword,
801
RepoAt: f.RepoAt().String(),
···
808
l.Error("failed to search for issues", "err", err)
809
return
810
}
811
-
ids = res.Hits
812
-
l.Debug("searched issues with indexer", "count", len(ids))
813
} else {
814
-
ids, err = db.GetIssueIDs(rp.db, searchOpts)
815
if err != nil {
816
-
l.Error("failed to search for issues", "err", err)
817
return
818
}
819
-
l.Debug("indexed all issues from the db", "count", len(ids))
820
-
}
821
-
822
-
issues, err := db.GetIssues(
823
-
rp.db,
824
-
db.FilterIn("id", ids),
825
-
)
826
-
if err != nil {
827
-
l.Error("failed to get issues", "err", err)
828
-
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
829
-
return
830
}
831
832
labelDefs, err := db.GetLabelDefinitions(
···
849
LoggedInUser: rp.oauth.GetUser(r),
850
RepoInfo: f.RepoInfo(user),
851
Issues: issues,
852
LabelDefs: defs,
853
FilteringByOpen: isOpen,
854
FilterQuery: keyword,
···
948
949
// everything is successful, do not rollback the atproto record
950
atUri = ""
951
-
rp.notifier.NewIssue(r.Context(), issue)
952
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
953
return
954
}
···
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/markup"
28
"tangled.org/core/appview/pagination"
29
"tangled.org/core/appview/reporesolver"
30
"tangled.org/core/appview/validator"
···
310
issue.Open = false
311
312
// notify about the issue closure
313
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
314
315
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
316
return
···
360
issue.Open = true
361
362
// notify about the issue reopen
363
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
364
365
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
366
return
···
454
455
// notify about the new comment
456
comment.Id = commentId
457
+
458
+
rawMentions := markup.FindUserMentions(comment.Body)
459
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
460
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
461
+
var mentions []syntax.DID
462
+
for _, ident := range idents {
463
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
464
+
mentions = append(mentions, ident.DID)
465
+
}
466
+
}
467
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
468
469
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
470
}
···
804
return
805
}
806
807
+
totalIssues := 0
808
+
if isOpen {
809
+
totalIssues = f.RepoStats.IssueCount.Open
810
+
} else {
811
+
totalIssues = f.RepoStats.IssueCount.Closed
812
+
}
813
+
814
keyword := params.Get("q")
815
816
+
var issues []models.Issue
817
searchOpts := models.IssueSearchOptions{
818
Keyword: keyword,
819
RepoAt: f.RepoAt().String(),
···
826
l.Error("failed to search for issues", "err", err)
827
return
828
}
829
+
l.Debug("searched issues with indexer", "count", len(res.Hits))
830
+
totalIssues = int(res.Total)
831
+
832
+
issues, err = db.GetIssues(
833
+
rp.db,
834
+
db.FilterIn("id", res.Hits),
835
+
)
836
+
if err != nil {
837
+
l.Error("failed to get issues", "err", err)
838
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
839
+
return
840
+
}
841
+
842
} else {
843
+
openInt := 0
844
+
if isOpen {
845
+
openInt = 1
846
+
}
847
+
issues, err = db.GetIssuesPaginated(
848
+
rp.db,
849
+
page,
850
+
db.FilterEq("repo_at", f.RepoAt()),
851
+
db.FilterEq("open", openInt),
852
+
)
853
if err != nil {
854
+
l.Error("failed to get issues", "err", err)
855
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
856
return
857
}
858
}
859
860
labelDefs, err := db.GetLabelDefinitions(
···
877
LoggedInUser: rp.oauth.GetUser(r),
878
RepoInfo: f.RepoInfo(user),
879
Issues: issues,
880
+
IssueCount: totalIssues,
881
LabelDefs: defs,
882
FilteringByOpen: isOpen,
883
FilterQuery: keyword,
···
977
978
// everything is successful, do not rollback the atproto record
979
atUri = ""
980
+
981
+
rawMentions := markup.FindUserMentions(issue.Body)
982
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
983
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
984
+
var mentions []syntax.DID
985
+
for _, ident := range idents {
986
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
987
+
mentions = append(mentions, ident.DID)
988
+
}
989
+
}
990
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
991
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
992
return
993
}
+5
-5
appview/issues/opengraph.go
+5
-5
appview/issues/opengraph.go
···
143
var statusBgColor color.RGBA
144
145
if issue.Open {
146
-
statusIcon = "static/icons/circle-dot.svg"
147
statusText = "open"
148
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
} else {
150
-
statusIcon = "static/icons/circle-dot.svg"
151
statusText = "closed"
152
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
}
···
155
badgeIconSize := 36
156
157
// Draw icon with status color (no background)
158
-
err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
if err != nil {
160
log.Printf("failed to draw status icon: %v", err)
161
}
···
172
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
174
// Draw comment count
175
-
err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
if err != nil {
177
log.Printf("failed to draw comment icon: %v", err)
178
}
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
}
···
143
var statusBgColor color.RGBA
144
145
if issue.Open {
146
+
statusIcon = "circle-dot"
147
statusText = "open"
148
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
} else {
150
+
statusIcon = "ban"
151
statusText = "closed"
152
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
}
···
155
badgeIconSize := 36
156
157
// Draw icon with status color (no background)
158
+
err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
if err != nil {
160
log.Printf("failed to draw status icon: %v", err)
161
}
···
172
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
174
// Draw comment count
175
+
err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
if err != nil {
177
log.Printf("failed to draw comment icon: %v", err)
178
}
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
}
+9
appview/knots/knots.go
+9
appview/knots/knots.go
···
6
"log/slog"
7
"net/http"
8
"slices"
9
"time"
10
11
"github.com/go-chi/chi/v5"
···
145
}
146
147
domain := r.FormValue("domain")
148
if domain == "" {
149
k.Pages.Notice(w, noticeId, "Incomplete form.")
150
return
···
526
}
527
528
member := r.FormValue("member")
529
if member == "" {
530
l.Error("empty member")
531
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
626
}
627
628
member := r.FormValue("member")
629
if member == "" {
630
l.Error("empty member")
631
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
6
"log/slog"
7
"net/http"
8
"slices"
9
+
"strings"
10
"time"
11
12
"github.com/go-chi/chi/v5"
···
146
}
147
148
domain := r.FormValue("domain")
149
+
// Strip protocol, trailing slashes, and whitespace
150
+
// Rkey cannot contain slashes
151
+
domain = strings.TrimSpace(domain)
152
+
domain = strings.TrimPrefix(domain, "https://")
153
+
domain = strings.TrimPrefix(domain, "http://")
154
+
domain = strings.TrimSuffix(domain, "/")
155
if domain == "" {
156
k.Pages.Notice(w, noticeId, "Incomplete form.")
157
return
···
533
}
534
535
member := r.FormValue("member")
536
+
member = strings.TrimPrefix(member, "@")
537
if member == "" {
538
l.Error("empty member")
539
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
634
}
635
636
member := r.FormValue("member")
637
+
member = strings.TrimPrefix(member, "@")
638
if member == "" {
639
l.Error("empty member")
640
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+4
-2
appview/middleware/middleware.go
+4
-2
appview/middleware/middleware.go
···
180
return func(next http.Handler) http.Handler {
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
didOrHandle := chi.URLParam(req, "user")
183
if slices.Contains(excluded, didOrHandle) {
184
next.ServeHTTP(w, req)
185
return
186
}
187
-
188
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
if err != nil {
···
206
return func(next http.Handler) http.Handler {
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
repoName := chi.URLParam(req, "repo")
209
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
if !ok {
211
log.Println("malformed middleware")
···
180
return func(next http.Handler) http.Handler {
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
didOrHandle := chi.URLParam(req, "user")
183
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
184
+
185
if slices.Contains(excluded, didOrHandle) {
186
next.ServeHTTP(w, req)
187
return
188
}
189
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
if err != nil {
···
206
return func(next http.Handler) http.Handler {
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
212
if !ok {
213
log.Println("malformed middleware")
+25
-43
appview/models/label.go
+25
-43
appview/models/label.go
···
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
···
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)
···
526
}
527
528
labelDef, err := LabelDefinitionFromRecord(
529
-
parsedUri.Authority().String(),
530
-
parsedUri.RecordKey().String(),
531
labelRecord,
532
)
533
if err != nil {
···
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/idresolver"
18
)
19
···
460
return result
461
}
462
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
+
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
466
467
+
for _, dl := range aturis {
468
+
atUri, err := syntax.ParseATURI(dl)
469
+
if err != nil {
470
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err)
471
+
}
472
+
if atUri.Collection() != tangled.LabelDefinitionNSID {
473
+
return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri)
474
+
}
475
476
+
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
477
+
if err != nil {
478
+
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
479
+
}
480
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
483
+
}
484
485
record, err := atproto.RepoGetRecord(
486
+
ctx,
487
+
&xrpcc,
488
"",
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
492
)
493
if err != nil {
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
508
}
509
510
labelDef, err := LabelDefinitionFromRecord(
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
513
labelRecord,
514
)
515
if err != nil {
+7
appview/models/notifications.go
+7
appview/models/notifications.go
···
20
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
NotificationTypePullReopen NotificationType = "pull_reopen"
23
)
24
25
type Notification struct {
···
63
return "git-pull-request-create"
64
case NotificationTypeFollowed:
65
return "user-plus"
66
default:
67
return ""
68
}
···
84
PullCreated bool
85
PullCommented bool
86
Followed bool
87
PullMerged bool
88
IssueClosed bool
89
EmailNotifications bool
···
113
return prefs.PullCreated // same pref for now
114
case NotificationTypeFollowed:
115
return prefs.Followed
116
default:
117
return false
118
}
···
127
PullCreated: true,
128
PullCommented: true,
129
Followed: true,
130
PullMerged: true,
131
IssueClosed: true,
132
EmailNotifications: false,
···
20
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
24
)
25
26
type Notification struct {
···
64
return "git-pull-request-create"
65
case NotificationTypeFollowed:
66
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
69
default:
70
return ""
71
}
···
87
PullCreated bool
88
PullCommented bool
89
Followed bool
90
+
UserMentioned bool
91
PullMerged bool
92
IssueClosed bool
93
EmailNotifications bool
···
117
return prefs.PullCreated // same pref for now
118
case NotificationTypeFollowed:
119
return prefs.Followed
120
+
case NotificationTypeUserMentioned:
121
+
return prefs.UserMentioned
122
default:
123
return false
124
}
···
133
PullCreated: true,
134
PullCommented: true,
135
Followed: true,
136
+
UserMentioned: true,
137
PullMerged: true,
138
IssueClosed: true,
139
EmailNotifications: false,
+1
appview/models/profile.go
+1
appview/models/profile.go
+1
-1
appview/models/pull.go
+1
-1
appview/models/pull.go
+61
-1
appview/models/repo.go
+61
-1
appview/models/repo.go
···
2
3
import (
4
"fmt"
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
···
17
Rkey string
18
Created time.Time
19
Description string
20
Spindle string
21
Labels []string
22
···
28
}
29
30
func (r *Repo) AsRecord() tangled.Repo {
31
-
var source, spindle, description *string
32
33
if r.Source != "" {
34
source = &r.Source
···
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,
···
60
func (r Repo) DidSlashRepo() string {
61
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
return p
63
}
64
65
type RepoStats struct {
···
91
Repo *Repo
92
Issues []Issue
93
}
···
2
3
import (
4
"fmt"
5
+
"strings"
6
"time"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
18
Rkey string
19
Created time.Time
20
Description string
21
+
Website string
22
+
Topics []string
23
Spindle string
24
Labels []string
25
···
31
}
32
33
func (r *Repo) AsRecord() tangled.Repo {
34
+
var source, spindle, description, website *string
35
36
if r.Source != "" {
37
source = &r.Source
···
45
description = &r.Description
46
}
47
48
+
if r.Website != "" {
49
+
website = &r.Website
50
+
}
51
+
52
return tangled.Repo{
53
Knot: r.Knot,
54
Name: r.Name,
55
Description: description,
56
+
Website: website,
57
+
Topics: r.Topics,
58
CreatedAt: r.Created.Format(time.RFC3339),
59
Source: source,
60
Spindle: spindle,
···
69
func (r Repo) DidSlashRepo() string {
70
p, _ := securejoin.SecureJoin(r.Did, r.Name)
71
return p
72
+
}
73
+
74
+
func (r Repo) TopicStr() string {
75
+
return strings.Join(r.Topics, " ")
76
}
77
78
type RepoStats struct {
···
104
Repo *Repo
105
Issues []Issue
106
}
107
+
108
+
type BlobContentType int
109
+
110
+
const (
111
+
BlobContentTypeCode BlobContentType = iota
112
+
BlobContentTypeMarkup
113
+
BlobContentTypeImage
114
+
BlobContentTypeSvg
115
+
BlobContentTypeVideo
116
+
BlobContentTypeSubmodule
117
+
)
118
+
119
+
func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode }
120
+
func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup }
121
+
func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage }
122
+
func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg }
123
+
func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo }
124
+
func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule }
125
+
126
+
type BlobView struct {
127
+
HasTextView bool // can show as code/text
128
+
HasRenderedView bool // can show rendered (markup/image/video/submodule)
129
+
HasRawView bool // can download raw (everything except submodule)
130
+
131
+
// current display mode
132
+
ShowingRendered bool // currently in rendered mode
133
+
ShowingText bool // currently in text/code mode
134
+
135
+
// content type flags
136
+
ContentType BlobContentType
137
+
138
+
// Content data
139
+
Contents string
140
+
ContentSrc string // URL for media files
141
+
Lines int
142
+
SizeHint uint64
143
+
}
144
+
145
+
// if both views are available, then show a toggle between them
146
+
func (b BlobView) ShowToggle() bool {
147
+
return b.HasTextView && b.HasRenderedView
148
+
}
149
+
150
+
func (b BlobView) IsUnsupported() bool {
151
+
// no view available, only raw
152
+
return !(b.HasRenderedView || b.HasTextView)
153
+
}
+14
-5
appview/models/star.go
+14
-5
appview/models/star.go
···
7
)
8
9
type Star struct {
10
+
Did string
11
+
RepoAt syntax.ATURI
12
+
Created time.Time
13
+
Rkey string
14
+
}
15
16
+
// RepoStar is used for reverse mapping to repos
17
+
type RepoStar struct {
18
+
Star
19
Repo *Repo
20
}
21
+
22
+
// StringStar is used for reverse mapping to strings
23
+
type StringStar struct {
24
+
Star
25
+
String *String
26
+
}
+1
-1
appview/models/string.go
+1
-1
appview/models/string.go
+1
-1
appview/models/timeline.go
+1
-1
appview/models/timeline.go
+55
-17
appview/notify/db/db.go
+55
-17
appview/notify/db/db.go
···
7
"slices"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/db"
11
"tangled.org/core/appview/models"
12
"tangled.org/core/appview/notify"
13
"tangled.org/core/idresolver"
14
)
15
16
type databaseNotifier struct {
···
32
}
33
34
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
35
var err error
36
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
37
if err != nil {
···
39
return
40
}
41
42
-
actorDid := syntax.DID(star.StarredByDid)
43
recipients := []syntax.DID{syntax.DID(repo.Did)}
44
eventType := models.NotificationTypeRepoStarred
45
entityType := "repo"
···
64
// no-op
65
}
66
67
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
68
69
// build the recipients list
70
// - owner of the repo
···
81
}
82
83
actorDid := syntax.DID(issue.Did)
84
-
eventType := models.NotificationTypeIssueCreated
85
entityType := "issue"
86
entityId := issue.AtUri().String()
87
repoId := &issue.Repo.Id
···
91
n.notifyEvent(
92
actorDid,
93
recipients,
94
-
eventType,
95
entityType,
96
entityId,
97
repoId,
···
100
)
101
}
102
103
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
104
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
105
if err != nil {
106
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
132
}
133
134
actorDid := syntax.DID(comment.Did)
135
-
eventType := models.NotificationTypeIssueCommented
136
entityType := "issue"
137
entityId := issue.AtUri().String()
138
repoId := &issue.Repo.Id
···
142
n.notifyEvent(
143
actorDid,
144
recipients,
145
-
eventType,
146
entityType,
147
entityId,
148
repoId,
···
203
actorDid := syntax.DID(pull.OwnerDid)
204
eventType := models.NotificationTypePullCreated
205
entityType := "pull"
206
-
entityId := pull.PullAt().String()
207
repoId := &repo.Id
208
var issueId *int64
209
p := int64(pull.ID)
···
221
)
222
}
223
224
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
225
pull, err := db.GetPull(n.db,
226
syntax.ATURI(comment.RepoAt),
227
comment.PullId,
···
249
actorDid := syntax.DID(comment.OwnerDid)
250
eventType := models.NotificationTypePullCommented
251
entityType := "pull"
252
-
entityId := pull.PullAt().String()
253
repoId := &repo.Id
254
var issueId *int64
255
p := int64(pull.ID)
···
265
issueId,
266
pullId,
267
)
268
}
269
270
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
283
// no-op
284
}
285
286
-
func (n *databaseNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
287
// build up the recipients list:
288
// - repo owner
289
// - repo collaborators
···
302
recipients = append(recipients, syntax.DID(p))
303
}
304
305
-
actorDid := syntax.DID(issue.Repo.Did)
306
entityType := "pull"
307
entityId := issue.AtUri().String()
308
repoId := &issue.Repo.Id
···
317
}
318
319
n.notifyEvent(
320
-
actorDid,
321
recipients,
322
eventType,
323
entityType,
···
328
)
329
}
330
331
-
func (n *databaseNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
332
// Get repo details
333
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
334
if err != nil {
···
353
recipients = append(recipients, syntax.DID(p))
354
}
355
356
-
actorDid := syntax.DID(repo.Did)
357
entityType := "pull"
358
-
entityId := pull.PullAt().String()
359
repoId := &repo.Id
360
var issueId *int64
361
var eventType models.NotificationType
···
374
pullId := &p
375
376
n.notifyEvent(
377
-
actorDid,
378
recipients,
379
eventType,
380
entityType,
···
395
issueId *int64,
396
pullId *int64,
397
) {
398
recipientSet := make(map[syntax.DID]struct{})
399
for _, did := range recipients {
400
// everybody except actor themselves
···
7
"slices"
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/notify"
14
"tangled.org/core/idresolver"
15
+
)
16
+
17
+
const (
18
+
maxMentions = 5
19
)
20
21
type databaseNotifier struct {
···
37
}
38
39
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
40
+
if star.RepoAt.Collection().String() != tangled.RepoNSID {
41
+
// skip string stars for now
42
+
return
43
+
}
44
var err error
45
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
46
if err != nil {
···
48
return
49
}
50
51
+
actorDid := syntax.DID(star.Did)
52
recipients := []syntax.DID{syntax.DID(repo.Did)}
53
eventType := models.NotificationTypeRepoStarred
54
entityType := "repo"
···
73
// no-op
74
}
75
76
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
77
78
// build the recipients list
79
// - owner of the repo
···
90
}
91
92
actorDid := syntax.DID(issue.Did)
93
entityType := "issue"
94
entityId := issue.AtUri().String()
95
repoId := &issue.Repo.Id
···
99
n.notifyEvent(
100
actorDid,
101
recipients,
102
+
models.NotificationTypeIssueCreated,
103
+
entityType,
104
+
entityId,
105
+
repoId,
106
+
issueId,
107
+
pullId,
108
+
)
109
+
n.notifyEvent(
110
+
actorDid,
111
+
mentions,
112
+
models.NotificationTypeUserMentioned,
113
entityType,
114
entityId,
115
repoId,
···
118
)
119
}
120
121
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
122
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
123
if err != nil {
124
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
150
}
151
152
actorDid := syntax.DID(comment.Did)
153
entityType := "issue"
154
entityId := issue.AtUri().String()
155
repoId := &issue.Repo.Id
···
159
n.notifyEvent(
160
actorDid,
161
recipients,
162
+
models.NotificationTypeIssueCommented,
163
+
entityType,
164
+
entityId,
165
+
repoId,
166
+
issueId,
167
+
pullId,
168
+
)
169
+
n.notifyEvent(
170
+
actorDid,
171
+
mentions,
172
+
models.NotificationTypeUserMentioned,
173
entityType,
174
entityId,
175
repoId,
···
230
actorDid := syntax.DID(pull.OwnerDid)
231
eventType := models.NotificationTypePullCreated
232
entityType := "pull"
233
+
entityId := pull.AtUri().String()
234
repoId := &repo.Id
235
var issueId *int64
236
p := int64(pull.ID)
···
248
)
249
}
250
251
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
252
pull, err := db.GetPull(n.db,
253
syntax.ATURI(comment.RepoAt),
254
comment.PullId,
···
276
actorDid := syntax.DID(comment.OwnerDid)
277
eventType := models.NotificationTypePullCommented
278
entityType := "pull"
279
+
entityId := pull.AtUri().String()
280
repoId := &repo.Id
281
var issueId *int64
282
p := int64(pull.ID)
···
292
issueId,
293
pullId,
294
)
295
+
n.notifyEvent(
296
+
actorDid,
297
+
mentions,
298
+
models.NotificationTypeUserMentioned,
299
+
entityType,
300
+
entityId,
301
+
repoId,
302
+
issueId,
303
+
pullId,
304
+
)
305
}
306
307
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
320
// no-op
321
}
322
323
+
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
324
// build up the recipients list:
325
// - repo owner
326
// - repo collaborators
···
339
recipients = append(recipients, syntax.DID(p))
340
}
341
342
entityType := "pull"
343
entityId := issue.AtUri().String()
344
repoId := &issue.Repo.Id
···
353
}
354
355
n.notifyEvent(
356
+
actor,
357
recipients,
358
eventType,
359
entityType,
···
364
)
365
}
366
367
+
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
368
// Get repo details
369
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
370
if err != nil {
···
389
recipients = append(recipients, syntax.DID(p))
390
}
391
392
entityType := "pull"
393
+
entityId := pull.AtUri().String()
394
repoId := &repo.Id
395
var issueId *int64
396
var eventType models.NotificationType
···
409
pullId := &p
410
411
n.notifyEvent(
412
+
actor,
413
recipients,
414
eventType,
415
entityType,
···
430
issueId *int64,
431
pullId *int64,
432
) {
433
+
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
434
+
recipients = recipients[:maxMentions]
435
+
}
436
recipientSet := make(map[syntax.DID]struct{})
437
for _, did := range recipients {
438
// everybody except actor themselves
+11
-10
appview/notify/merged_notifier.go
+11
-10
appview/notify/merged_notifier.go
···
6
"reflect"
7
"sync"
8
9
"tangled.org/core/appview/models"
10
"tangled.org/core/log"
11
)
···
53
m.fanout("DeleteStar", ctx, star)
54
}
55
56
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
57
-
m.fanout("NewIssue", ctx, issue)
58
}
59
60
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
61
-
m.fanout("NewIssueComment", ctx, comment)
62
}
63
64
-
func (m *mergedNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
65
-
m.fanout("NewIssueState", ctx, issue)
66
}
67
68
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
···
81
m.fanout("NewPull", ctx, pull)
82
}
83
84
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
85
-
m.fanout("NewPullComment", ctx, comment)
86
}
87
88
-
func (m *mergedNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
89
-
m.fanout("NewPullState", ctx, pull)
90
}
91
92
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
6
"reflect"
7
"sync"
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/models"
11
"tangled.org/core/log"
12
)
···
54
m.fanout("DeleteStar", ctx, star)
55
}
56
57
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
+
m.fanout("NewIssue", ctx, issue, mentions)
59
}
60
61
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
+
m.fanout("NewIssueComment", ctx, comment, mentions)
63
}
64
65
+
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
66
+
m.fanout("NewIssueState", ctx, actor, issue)
67
}
68
69
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
···
82
m.fanout("NewPull", ctx, pull)
83
}
84
85
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
+
m.fanout("NewPullComment", ctx, comment, mentions)
87
}
88
89
+
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
90
+
m.fanout("NewPullState", ctx, actor, pull)
91
}
92
93
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+15
-12
appview/notify/notifier.go
+15
-12
appview/notify/notifier.go
···
3
import (
4
"context"
5
6
"tangled.org/core/appview/models"
7
)
8
···
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
-
NewIssueState(ctx context.Context, issue *models.Issue)
18
DeleteIssue(ctx context.Context, issue *models.Issue)
19
20
NewFollow(ctx context.Context, follow *models.Follow)
21
DeleteFollow(ctx context.Context, follow *models.Follow)
22
23
NewPull(ctx context.Context, pull *models.Pull)
24
-
NewPullComment(ctx context.Context, comment *models.PullComment)
25
-
NewPullState(ctx context.Context, pull *models.Pull)
26
27
UpdateProfile(ctx context.Context, profile *models.Profile)
28
···
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) NewIssueState(ctx context.Context, issue *models.Issue) {}
47
-
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
48
49
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
50
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
51
52
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
53
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
54
-
func (m *BaseNotifier) NewPullState(ctx context.Context, pull *models.Pull) {}
55
56
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
···
3
import (
4
"context"
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
"tangled.org/core/appview/models"
8
)
9
···
13
NewStar(ctx context.Context, star *models.Star)
14
DeleteStar(ctx context.Context, star *models.Star)
15
16
+
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
+
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
DeleteIssue(ctx context.Context, issue *models.Issue)
20
21
NewFollow(ctx context.Context, follow *models.Follow)
22
DeleteFollow(ctx context.Context, follow *models.Follow)
23
24
NewPull(ctx context.Context, pull *models.Pull)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
+
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
27
28
UpdateProfile(ctx context.Context, profile *models.Profile)
29
···
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
45
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
46
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
47
+
}
48
+
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
49
+
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
50
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
53
54
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
55
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) {
56
+
}
57
+
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
58
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
60
+15
-9
appview/notify/posthog/notifier.go
+15
-9
appview/notify/posthog/notifier.go
···
4
"context"
5
"log"
6
7
"github.com/posthog/posthog-go"
8
"tangled.org/core/appview/models"
9
"tangled.org/core/appview/notify"
···
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
})
···
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
})
···
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 {
···
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 {
···
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 {
···
190
}
191
}
192
193
-
func (n *posthogNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
194
var event string
195
if issue.Open {
196
event = "issue_reopen"
···
202
Event: event,
203
Properties: posthog.Properties{
204
"repo_at": issue.RepoAt.String(),
205
"issue_id": issue.IssueId,
206
},
207
})
···
210
}
211
}
212
213
-
func (n *posthogNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
214
var event string
215
switch pull.State {
216
case models.PullClosed:
···
229
Properties: posthog.Properties{
230
"repo_at": pull.RepoAt,
231
"pull_id": pull.PullId,
232
},
233
})
234
if err != nil {
···
4
"context"
5
"log"
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/posthog/posthog-go"
9
"tangled.org/core/appview/models"
10
"tangled.org/core/appview/notify"
···
37
38
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
39
err := n.client.Enqueue(posthog.Capture{
40
+
DistinctId: star.Did,
41
Event: "star",
42
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
43
})
···
48
49
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
50
err := n.client.Enqueue(posthog.Capture{
51
+
DistinctId: star.Did,
52
Event: "unstar",
53
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
54
})
···
57
}
58
}
59
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
61
err := n.client.Enqueue(posthog.Capture{
62
DistinctId: issue.Did,
63
Event: "new_issue",
64
Properties: posthog.Properties{
65
"repo_at": issue.RepoAt.String(),
66
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
68
},
69
})
70
if err != nil {
···
86
}
87
}
88
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
90
err := n.client.Enqueue(posthog.Capture{
91
DistinctId: comment.OwnerDid,
92
Event: "new_pull_comment",
93
Properties: posthog.Properties{
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
97
},
98
})
99
if err != nil {
···
180
}
181
}
182
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
184
err := n.client.Enqueue(posthog.Capture{
185
DistinctId: comment.Did,
186
Event: "new_issue_comment",
187
Properties: posthog.Properties{
188
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
190
},
191
})
192
if err != nil {
···
194
}
195
}
196
197
+
func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
198
var event string
199
if issue.Open {
200
event = "issue_reopen"
···
206
Event: event,
207
Properties: posthog.Properties{
208
"repo_at": issue.RepoAt.String(),
209
+
"actor": actor,
210
"issue_id": issue.IssueId,
211
},
212
})
···
215
}
216
}
217
218
+
func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
219
var event string
220
switch pull.State {
221
case models.PullClosed:
···
234
Properties: posthog.Properties{
235
"repo_at": pull.RepoAt,
236
"pull_id": pull.PullId,
237
+
"actor": actor,
238
},
239
})
240
if err != nil {
+19
-2
appview/oauth/oauth.go
+19
-2
appview/oauth/oauth.go
···
74
75
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
76
clientApp.Dir = res.Directory()
77
78
clientName := config.Core.AppviewName
79
···
198
exp int64
199
lxm string
200
dev bool
201
}
202
203
type ServiceClientOpt func(*ServiceClientOpts)
204
205
func WithService(service string) ServiceClientOpt {
206
return func(s *ServiceClientOpts) {
···
229
}
230
}
231
232
func (s *ServiceClientOpts) Audience() string {
233
return fmt.Sprintf("did:web:%s", s.service)
234
}
···
243
}
244
245
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
246
-
opts := ServiceClientOpts{}
247
for _, o := range os {
248
o(&opts)
249
}
···
270
},
271
Host: opts.Host(),
272
Client: &http.Client{
273
-
Timeout: time.Second * 5,
274
},
275
}, nil
276
}
···
74
75
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
76
clientApp.Dir = res.Directory()
77
+
// allow non-public transports in dev mode
78
+
if config.Core.Dev {
79
+
clientApp.Resolver.Client.Transport = http.DefaultTransport
80
+
}
81
82
clientName := config.Core.AppviewName
83
···
202
exp int64
203
lxm string
204
dev bool
205
+
timeout time.Duration
206
}
207
208
type ServiceClientOpt func(*ServiceClientOpts)
209
+
210
+
func DefaultServiceClientOpts() ServiceClientOpts {
211
+
return ServiceClientOpts{
212
+
timeout: time.Second * 5,
213
+
}
214
+
}
215
216
func WithService(service string) ServiceClientOpt {
217
return func(s *ServiceClientOpts) {
···
240
}
241
}
242
243
+
func WithTimeout(timeout time.Duration) ServiceClientOpt {
244
+
return func(s *ServiceClientOpts) {
245
+
s.timeout = timeout
246
+
}
247
+
}
248
+
249
func (s *ServiceClientOpts) Audience() string {
250
return fmt.Sprintf("did:web:%s", s.service)
251
}
···
260
}
261
262
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
263
+
opts := DefaultServiceClientOpts()
264
for _, o := range os {
265
o(&opts)
266
}
···
287
},
288
Host: opts.Host(),
289
Client: &http.Client{
290
+
Timeout: opts.timeout,
291
},
292
}, nil
293
}
+59
-10
appview/ogcard/card.go
+59
-10
appview/ogcard/card.go
···
7
import (
8
"bytes"
9
"fmt"
10
"image"
11
"image/color"
12
"io"
···
279
return width, nil
280
}
281
282
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
-
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
-
svgData, err := pages.Files.ReadFile(svgPath)
285
-
if err != nil {
286
-
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
-
}
288
-
289
// Convert color to hex string for SVG
290
rgba, isRGBA := iconColor.(color.RGBA)
291
if !isRGBA {
···
304
// Parse SVG
305
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
if err != nil {
307
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
}
309
310
// Set the icon size
311
w, h := float64(size), float64(size)
312
icon.SetTarget(0, 0, w, h)
···
334
}
335
336
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
-
338
-
return nil
339
}
340
341
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
···
7
import (
8
"bytes"
9
"fmt"
10
+
"html/template"
11
"image"
12
"image/color"
13
"io"
···
280
return width, nil
281
}
282
283
+
func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) {
284
// Convert color to hex string for SVG
285
rgba, isRGBA := iconColor.(color.RGBA)
286
if !isRGBA {
···
299
// Parse SVG
300
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
301
if err != nil {
302
+
return nil, fmt.Errorf("failed to parse SVG: %w", err)
303
}
304
305
+
return icon, nil
306
+
}
307
+
308
+
func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) {
309
+
svgData, err := pages.Files.ReadFile(svgPath)
310
+
if err != nil {
311
+
return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
312
+
}
313
+
314
+
icon, err := BuildSVGIconFromData(svgData, iconColor)
315
+
if err != nil {
316
+
return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err)
317
+
}
318
+
319
+
return icon, nil
320
+
}
321
+
322
+
func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) {
323
+
return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
324
+
}
325
+
326
+
func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error {
327
+
icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
328
+
if err != nil {
329
+
return err
330
+
}
331
+
332
+
c.DrawSVGIcon(icon, x, y, size)
333
+
334
+
return nil
335
+
}
336
+
337
+
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
+
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
+
if err != nil {
341
+
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
+
}
343
+
344
+
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
+
}
348
+
349
+
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
350
+
if err != nil {
351
+
return err
352
+
}
353
+
354
+
c.DrawSVGIcon(icon, x, y, size)
355
+
356
+
return nil
357
+
}
358
+
359
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
360
+
func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) {
361
// Set the icon size
362
w, h := float64(size), float64(size)
363
icon.SetTarget(0, 0, w, h)
···
385
}
386
387
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
388
}
389
390
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
+73
-5
appview/pages/funcmap.go
+73
-5
appview/pages/funcmap.go
···
1
package pages
2
3
import (
4
"context"
5
"crypto/hmac"
6
"crypto/sha256"
···
17
"strings"
18
"time"
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"
···
38
"contains": func(s string, target string) bool {
39
return strings.Contains(s, target)
40
},
41
"mapContains": func(m any, key any) bool {
42
mapValue := reflect.ValueOf(m)
43
if mapValue.Kind() != reflect.Map {
···
57
return "handle.invalid"
58
}
59
60
-
return "@" + identity.Handle.String()
61
},
62
"truncateAt30": func(s string) string {
63
if len(s) <= 30 {
···
68
"splitOn": func(s, sep string) []string {
69
return strings.Split(s, sep)
70
},
71
"int64": func(a int) int64 {
72
return int64(a)
73
},
···
84
"sub": func(a, b int) int {
85
return a - b
86
},
87
"f64": func(a int) float64 {
88
return float64(a)
89
},
···
117
return b
118
},
119
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
122
} else {
123
return did
124
}
···
232
},
233
"description": func(text string) template.HTML {
234
p.rctx.RendererType = markup.RendererTypeDefault
235
-
htmlString := p.rctx.RenderMarkdown(text)
236
sanitized := p.rctx.SanitizeDescription(htmlString)
237
return template.HTML(sanitized)
238
},
239
"isNil": func(t any) bool {
240
// returns false for other "zero" values
241
return t == nil
···
281
u, _ := url.PathUnescape(s)
282
return u
283
},
284
-
285
"tinyAvatar": func(handle string) string {
286
return p.AvatarUrl(handle, "tiny")
287
},
···
1
package pages
2
3
import (
4
+
"bytes"
5
"context"
6
"crypto/hmac"
7
"crypto/sha256"
···
18
"strings"
19
"time"
20
21
+
"github.com/alecthomas/chroma/v2"
22
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23
+
"github.com/alecthomas/chroma/v2/lexers"
24
+
"github.com/alecthomas/chroma/v2/styles"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
26
"github.com/dustin/go-humanize"
27
"github.com/go-enry/go-enry/v2"
28
+
"github.com/yuin/goldmark"
29
"tangled.org/core/appview/filetree"
30
"tangled.org/core/appview/pages/markup"
31
"tangled.org/core/crypto"
···
45
"contains": func(s string, target string) bool {
46
return strings.Contains(s, target)
47
},
48
+
"stripPort": func(hostname string) string {
49
+
if strings.Contains(hostname, ":") {
50
+
return strings.Split(hostname, ":")[0]
51
+
}
52
+
return hostname
53
+
},
54
"mapContains": func(m any, key any) bool {
55
mapValue := reflect.ValueOf(m)
56
if mapValue.Kind() != reflect.Map {
···
70
return "handle.invalid"
71
}
72
73
+
return identity.Handle.String()
74
},
75
"truncateAt30": func(s string) string {
76
if len(s) <= 30 {
···
81
"splitOn": func(s, sep string) []string {
82
return strings.Split(s, sep)
83
},
84
+
"string": func(v any) string {
85
+
return fmt.Sprint(v)
86
+
},
87
"int64": func(a int) int64 {
88
return int64(a)
89
},
···
100
"sub": func(a, b int) int {
101
return a - b
102
},
103
+
"mul": func (a, b int) int {
104
+
return a * b
105
+
},
106
+
"div": func (a, b int) int {
107
+
return a / b
108
+
},
109
+
"mod": func(a, b int) int {
110
+
return a % b
111
+
},
112
"f64": func(a int) float64 {
113
return float64(a)
114
},
···
142
return b
143
},
144
"didOrHandle": func(did, handle string) string {
145
+
if handle != "" && handle != syntax.HandleInvalid.String() {
146
+
return handle
147
} else {
148
return did
149
}
···
257
},
258
"description": func(text string) template.HTML {
259
p.rctx.RendererType = markup.RendererTypeDefault
260
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
261
sanitized := p.rctx.SanitizeDescription(htmlString)
262
return template.HTML(sanitized)
263
},
264
+
"readme": func(text string) template.HTML {
265
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
266
+
htmlString := p.rctx.RenderMarkdown(text)
267
+
sanitized := p.rctx.SanitizeDefault(htmlString)
268
+
return template.HTML(sanitized)
269
+
},
270
+
"code": func(content, path string) string {
271
+
var style *chroma.Style = styles.Get("catpuccin-latte")
272
+
formatter := chromahtml.New(
273
+
chromahtml.InlineCode(false),
274
+
chromahtml.WithLineNumbers(true),
275
+
chromahtml.WithLinkableLineNumbers(true, "L"),
276
+
chromahtml.Standalone(false),
277
+
chromahtml.WithClasses(true),
278
+
)
279
+
280
+
lexer := lexers.Get(filepath.Base(path))
281
+
if lexer == nil {
282
+
lexer = lexers.Fallback
283
+
}
284
+
285
+
iterator, err := lexer.Tokenise(nil, content)
286
+
if err != nil {
287
+
p.logger.Error("chroma tokenize", "err", "err")
288
+
return ""
289
+
}
290
+
291
+
var code bytes.Buffer
292
+
err = formatter.Format(&code, style, iterator)
293
+
if err != nil {
294
+
p.logger.Error("chroma format", "err", "err")
295
+
return ""
296
+
}
297
+
298
+
return code.String()
299
+
},
300
+
"trimUriScheme": func(text string) string {
301
+
text = strings.TrimPrefix(text, "https://")
302
+
text = strings.TrimPrefix(text, "http://")
303
+
return text
304
+
},
305
"isNil": func(t any) bool {
306
// returns false for other "zero" values
307
return t == nil
···
347
u, _ := url.PathUnescape(s)
348
return u
349
},
350
+
"safeUrl": func(s string) template.URL {
351
+
return template.URL(s)
352
+
},
353
"tinyAvatar": func(handle string) string {
354
return p.AvatarUrl(handle, "tiny")
355
},
+111
appview/pages/markup/extension/atlink.go
+111
appview/pages/markup/extension/atlink.go
···
···
1
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
2
+
3
+
package extension
4
+
5
+
import (
6
+
"regexp"
7
+
8
+
"github.com/yuin/goldmark"
9
+
"github.com/yuin/goldmark/ast"
10
+
"github.com/yuin/goldmark/parser"
11
+
"github.com/yuin/goldmark/renderer"
12
+
"github.com/yuin/goldmark/renderer/html"
13
+
"github.com/yuin/goldmark/text"
14
+
"github.com/yuin/goldmark/util"
15
+
)
16
+
17
+
// An AtNode struct represents an AtNode
18
+
type AtNode struct {
19
+
Handle string
20
+
ast.BaseInline
21
+
}
22
+
23
+
var _ ast.Node = &AtNode{}
24
+
25
+
// Dump implements Node.Dump.
26
+
func (n *AtNode) Dump(source []byte, level int) {
27
+
ast.DumpHelper(n, source, level, nil, nil)
28
+
}
29
+
30
+
// KindAt is a NodeKind of the At node.
31
+
var KindAt = ast.NewNodeKind("At")
32
+
33
+
// Kind implements Node.Kind.
34
+
func (n *AtNode) Kind() ast.NodeKind {
35
+
return KindAt
36
+
}
37
+
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
+
40
+
type atParser struct{}
41
+
42
+
// NewAtParser return a new InlineParser that parses
43
+
// at expressions.
44
+
func NewAtParser() parser.InlineParser {
45
+
return &atParser{}
46
+
}
47
+
48
+
func (s *atParser) Trigger() []byte {
49
+
return []byte{'@'}
50
+
}
51
+
52
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
53
+
line, segment := block.PeekLine()
54
+
m := atRegexp.FindSubmatchIndex(line)
55
+
if m == nil {
56
+
return nil
57
+
}
58
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
59
+
block.Advance(m[1])
60
+
node := &AtNode{}
61
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
62
+
node.Handle = string(atSegment.Value(block.Source())[1:])
63
+
return node
64
+
}
65
+
66
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
67
+
// renders At nodes.
68
+
type atHtmlRenderer struct {
69
+
html.Config
70
+
}
71
+
72
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
73
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
74
+
r := &atHtmlRenderer{
75
+
Config: html.NewConfig(),
76
+
}
77
+
for _, opt := range opts {
78
+
opt.SetHTMLOption(&r.Config)
79
+
}
80
+
return r
81
+
}
82
+
83
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
84
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
85
+
reg.Register(KindAt, r.renderAt)
86
+
}
87
+
88
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
89
+
if entering {
90
+
w.WriteString(`<a href="/@`)
91
+
w.WriteString(n.(*AtNode).Handle)
92
+
w.WriteString(`" class="mention font-bold">`)
93
+
} else {
94
+
w.WriteString("</a>")
95
+
}
96
+
return ast.WalkContinue, nil
97
+
}
98
+
99
+
type atExt struct{}
100
+
101
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
102
+
var AtExt = &atExt{}
103
+
104
+
func (e *atExt) Extend(m goldmark.Markdown) {
105
+
m.Parser().AddOptions(parser.WithInlineParsers(
106
+
util.Prioritized(NewAtParser(), 500),
107
+
))
108
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
109
+
util.Prioritized(NewAtHTMLRenderer(), 500),
110
+
))
111
+
}
+35
-2
appview/pages/markup/markdown.go
+35
-2
appview/pages/markup/markdown.go
···
25
htmlparse "golang.org/x/net/html"
26
27
"tangled.org/core/api/tangled"
28
"tangled.org/core/appview/pages/repoinfo"
29
)
30
···
50
Files fs.FS
51
}
52
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
md := goldmark.New(
55
goldmark.WithExtensions(
56
extension.GFM,
···
66
),
67
treeblood.MathML(),
68
callout.CalloutExtention,
69
),
70
goldmark.WithParserOptions(
71
parser.WithAutoHeadingID(),
72
),
73
goldmark.WithRendererOptions(html.WithUnsafe()),
74
)
75
76
if rctx != nil {
77
var transformers []util.PrioritizedValue
78
···
240
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
241
242
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
243
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
244
245
parsedURL := &url.URL{
246
Scheme: scheme,
···
293
}
294
295
return path.Join(rctx.CurrentDir, dst)
296
}
297
298
func isAbsoluteUrl(link string) bool {
···
25
htmlparse "golang.org/x/net/html"
26
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
29
"tangled.org/core/appview/pages/repoinfo"
30
)
31
···
51
Files fs.FS
52
}
53
54
+
func NewMarkdown() goldmark.Markdown {
55
md := goldmark.New(
56
goldmark.WithExtensions(
57
extension.GFM,
···
67
),
68
treeblood.MathML(),
69
callout.CalloutExtention,
70
+
textension.AtExt,
71
),
72
goldmark.WithParserOptions(
73
parser.WithAutoHeadingID(),
74
),
75
goldmark.WithRendererOptions(html.WithUnsafe()),
76
)
77
+
return md
78
+
}
79
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
return rctx.RenderMarkdownWith(source, NewMarkdown())
82
+
}
83
+
84
+
func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
85
if rctx != nil {
86
var transformers []util.PrioritizedValue
87
···
249
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
250
251
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
252
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
253
254
parsedURL := &url.URL{
255
Scheme: scheme,
···
302
}
303
304
return path.Join(rctx.CurrentDir, dst)
305
+
}
306
+
307
+
// FindUserMentions returns Set of user handles from given markup soruce.
308
+
// It doesn't guarntee unique DIDs
309
+
func FindUserMentions(source string) []string {
310
+
var (
311
+
mentions []string
312
+
mentionsSet = make(map[string]struct{})
313
+
md = NewMarkdown()
314
+
sourceBytes = []byte(source)
315
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
316
+
)
317
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
318
+
if entering && n.Kind() == textension.KindAt {
319
+
handle := n.(*textension.AtNode).Handle
320
+
mentionsSet[handle] = struct{}{}
321
+
return ast.WalkSkipChildren, nil
322
+
}
323
+
return ast.WalkContinue, nil
324
+
})
325
+
for handle := range mentionsSet {
326
+
mentions = append(mentions, handle)
327
+
}
328
+
return mentions
329
}
330
331
func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
···
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
80
// centering content
81
policy.AllowElements("center")
82
···
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
80
+
// at-mentions
81
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
+
83
// centering content
84
policy.AllowElements("center")
85
+19
-116
appview/pages/pages.go
+19
-116
appview/pages/pages.go
···
1
package pages
2
3
import (
4
-
"bytes"
5
"crypto/sha256"
6
"embed"
7
"encoding/hex"
···
29
"tangled.org/core/patchutil"
30
"tangled.org/core/types"
31
32
-
"github.com/alecthomas/chroma/v2"
33
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
34
-
"github.com/alecthomas/chroma/v2/lexers"
35
-
"github.com/alecthomas/chroma/v2/styles"
36
"github.com/bluesky-social/indigo/atproto/identity"
37
"github.com/bluesky-social/indigo/atproto/syntax"
38
"github.com/go-git/go-git/v5/plumbing"
···
630
return p.executePlain("user/fragments/editPins", w, params)
631
}
632
633
-
type RepoStarFragmentParams struct {
634
IsStarred bool
635
-
RepoAt syntax.ATURI
636
-
Stats models.RepoStats
637
-
}
638
-
639
-
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
640
-
return p.executePlain("repo/fragments/repoStar", w, params)
641
-
}
642
-
643
-
type RepoDescriptionParams struct {
644
-
RepoInfo repoinfo.RepoInfo
645
}
646
647
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
648
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
649
-
}
650
-
651
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
652
-
return p.executePlain("repo/fragments/repoDescription", w, params)
653
}
654
655
type RepoIndexParams struct {
···
756
func (r RepoTreeParams) TreeStats() RepoTreeStats {
757
numFolders, numFiles := 0, 0
758
for _, f := range r.Files {
759
-
if !f.IsFile {
760
numFolders += 1
761
-
} else if f.IsFile {
762
numFiles += 1
763
}
764
}
···
829
}
830
831
type RepoBlobParams struct {
832
-
LoggedInUser *oauth.User
833
-
RepoInfo repoinfo.RepoInfo
834
-
Active string
835
-
Unsupported bool
836
-
IsImage bool
837
-
IsVideo bool
838
-
ContentSrc string
839
-
BreadCrumbs [][]string
840
-
ShowRendered bool
841
-
RenderToggle bool
842
-
RenderedContents template.HTML
843
*tangled.RepoBlob_Output
844
-
// Computed fields for template compatibility
845
-
Contents string
846
-
Lines int
847
-
SizeHint uint64
848
-
IsBinary bool
849
}
850
851
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
852
-
var style *chroma.Style = styles.Get("catpuccin-latte")
853
-
854
-
if params.ShowRendered {
855
-
switch markup.GetFormat(params.Path) {
856
-
case markup.FormatMarkdown:
857
-
p.rctx.RepoInfo = params.RepoInfo
858
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
859
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
860
-
sanitized := p.rctx.SanitizeDefault(htmlString)
861
-
params.RenderedContents = template.HTML(sanitized)
862
-
}
863
}
864
865
-
c := params.Contents
866
-
formatter := chromahtml.New(
867
-
chromahtml.InlineCode(false),
868
-
chromahtml.WithLineNumbers(true),
869
-
chromahtml.WithLinkableLineNumbers(true, "L"),
870
-
chromahtml.Standalone(false),
871
-
chromahtml.WithClasses(true),
872
-
)
873
-
874
-
lexer := lexers.Get(filepath.Base(params.Path))
875
-
if lexer == nil {
876
-
lexer = lexers.Fallback
877
-
}
878
-
879
-
iterator, err := lexer.Tokenise(nil, c)
880
-
if err != nil {
881
-
return fmt.Errorf("chroma tokenize: %w", err)
882
-
}
883
-
884
-
var code bytes.Buffer
885
-
err = formatter.Format(&code, style, iterator)
886
-
if err != nil {
887
-
return fmt.Errorf("chroma format: %w", err)
888
-
}
889
-
890
-
params.Contents = code.String()
891
params.Active = "overview"
892
return p.executeRepo("repo/blob", w, params)
893
}
···
970
RepoInfo repoinfo.RepoInfo
971
Active string
972
Issues []models.Issue
973
LabelDefs map[string]*models.LabelDefinition
974
Page pagination.Page
975
FilteringByOpen bool
···
1438
ShowRendered bool
1439
RenderToggle bool
1440
RenderedContents template.HTML
1441
-
String models.String
1442
Stats models.StringStats
1443
Owner identity.Identity
1444
}
1445
1446
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1447
-
var style *chroma.Style = styles.Get("catpuccin-latte")
1448
-
1449
-
if params.ShowRendered {
1450
-
switch markup.GetFormat(params.String.Filename) {
1451
-
case markup.FormatMarkdown:
1452
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1453
-
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1454
-
sanitized := p.rctx.SanitizeDefault(htmlString)
1455
-
params.RenderedContents = template.HTML(sanitized)
1456
-
}
1457
-
}
1458
-
1459
-
c := params.String.Contents
1460
-
formatter := chromahtml.New(
1461
-
chromahtml.InlineCode(false),
1462
-
chromahtml.WithLineNumbers(true),
1463
-
chromahtml.WithLinkableLineNumbers(true, "L"),
1464
-
chromahtml.Standalone(false),
1465
-
chromahtml.WithClasses(true),
1466
-
)
1467
-
1468
-
lexer := lexers.Get(filepath.Base(params.String.Filename))
1469
-
if lexer == nil {
1470
-
lexer = lexers.Fallback
1471
-
}
1472
-
1473
-
iterator, err := lexer.Tokenise(nil, c)
1474
-
if err != nil {
1475
-
return fmt.Errorf("chroma tokenize: %w", err)
1476
-
}
1477
-
1478
-
var code bytes.Buffer
1479
-
err = formatter.Format(&code, style, iterator)
1480
-
if err != nil {
1481
-
return fmt.Errorf("chroma format: %w", err)
1482
-
}
1483
-
1484
-
params.String.Contents = code.String()
1485
return p.execute("strings/string", w, params)
1486
}
1487
···
1
package pages
2
3
import (
4
"crypto/sha256"
5
"embed"
6
"encoding/hex"
···
28
"tangled.org/core/patchutil"
29
"tangled.org/core/types"
30
31
"github.com/bluesky-social/indigo/atproto/identity"
32
"github.com/bluesky-social/indigo/atproto/syntax"
33
"github.com/go-git/go-git/v5/plumbing"
···
625
return p.executePlain("user/fragments/editPins", w, params)
626
}
627
628
+
type StarBtnFragmentParams struct {
629
IsStarred bool
630
+
SubjectAt syntax.ATURI
631
+
StarCount int
632
}
633
634
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
635
+
return p.executePlain("fragments/starBtn", w, params)
636
}
637
638
type RepoIndexParams struct {
···
739
func (r RepoTreeParams) TreeStats() RepoTreeStats {
740
numFolders, numFiles := 0, 0
741
for _, f := range r.Files {
742
+
if !f.IsFile() {
743
numFolders += 1
744
+
} else if f.IsFile() {
745
numFiles += 1
746
}
747
}
···
812
}
813
814
type RepoBlobParams struct {
815
+
LoggedInUser *oauth.User
816
+
RepoInfo repoinfo.RepoInfo
817
+
Active string
818
+
BreadCrumbs [][]string
819
+
BlobView models.BlobView
820
*tangled.RepoBlob_Output
821
}
822
823
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
824
+
switch params.BlobView.ContentType {
825
+
case models.BlobContentTypeMarkup:
826
+
p.rctx.RepoInfo = params.RepoInfo
827
}
828
829
params.Active = "overview"
830
return p.executeRepo("repo/blob", w, params)
831
}
···
908
RepoInfo repoinfo.RepoInfo
909
Active string
910
Issues []models.Issue
911
+
IssueCount int
912
LabelDefs map[string]*models.LabelDefinition
913
Page pagination.Page
914
FilteringByOpen bool
···
1377
ShowRendered bool
1378
RenderToggle bool
1379
RenderedContents template.HTML
1380
+
String *models.String
1381
Stats models.StringStats
1382
+
IsStarred bool
1383
+
StarCount int
1384
Owner identity.Identity
1385
}
1386
1387
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1388
return p.execute("strings/string", w, params)
1389
}
1390
+7
-7
appview/pages/repoinfo/repoinfo.go
+7
-7
appview/pages/repoinfo/repoinfo.go
···
1
package repoinfo
2
3
import (
4
-
"fmt"
5
"path"
6
"slices"
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 {
15
if r.OwnerHandle != "" {
16
-
return fmt.Sprintf("@%s", r.OwnerHandle)
17
} else {
18
return r.OwnerDid
19
}
20
}
21
22
func (r RepoInfo) FullName() string {
23
-
return path.Join(r.OwnerWithAt(), r.Name)
24
}
25
26
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
-
return after
29
} else {
30
return userutil.FlattenDid(r.OwnerDid)
31
}
···
56
OwnerDid string
57
OwnerHandle string
58
Description string
59
Knot string
60
Spindle string
61
RepoAt syntax.ATURI
···
1
package repoinfo
2
3
import (
4
"path"
5
"slices"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
"tangled.org/core/appview/models"
9
"tangled.org/core/appview/state/userutil"
10
)
11
12
+
func (r RepoInfo) Owner() string {
13
if r.OwnerHandle != "" {
14
+
return r.OwnerHandle
15
} else {
16
return r.OwnerDid
17
}
18
}
19
20
func (r RepoInfo) FullName() string {
21
+
return path.Join(r.Owner(), r.Name)
22
}
23
24
func (r RepoInfo) OwnerWithoutAt() string {
25
+
if r.OwnerHandle != "" {
26
+
return r.OwnerHandle
27
} else {
28
return userutil.FlattenDid(r.OwnerDid)
29
}
···
54
OwnerDid string
55
OwnerHandle string
56
Description string
57
+
Website string
58
+
Topics []string
59
Knot string
60
Spindle string
61
RepoAt syntax.ATURI
+82
-54
appview/pages/templates/fragments/dolly/logo.html
+82
-54
appview/pages/templates/fragments/dolly/logo.html
···
1
{{ define "fragments/dolly/logo" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
class="{{.}}"
6
-
width="25"
7
-
height="25"
8
-
viewBox="0 0 25 25"
9
-
sodipodi:docname="tangled_dolly_face_only.png"
10
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
-
xmlns:xlink="http://www.w3.org/1999/xlink"
13
-
xmlns="http://www.w3.org/2000/svg"
14
-
xmlns:svg="http://www.w3.org/2000/svg">
15
-
<title>Dolly</title>
16
-
<defs
17
-
id="defs1" />
18
-
<sodipodi:namedview
19
-
id="namedview1"
20
-
pagecolor="#ffffff"
21
-
bordercolor="#000000"
22
-
borderopacity="0.25"
23
-
inkscape:showpageshadow="2"
24
-
inkscape:pageopacity="0.0"
25
-
inkscape:pagecheckerboard="true"
26
-
inkscape:deskcolor="#d5d5d5">
27
-
<inkscape:page
28
-
x="0"
29
-
y="0"
30
-
width="25"
31
-
height="25"
32
-
id="page2"
33
-
margin="0"
34
-
bleed="0" />
35
-
</sodipodi:namedview>
36
-
<g
37
-
inkscape:groupmode="layer"
38
-
inkscape:label="Image"
39
-
id="g1">
40
-
<image
41
-
width="252.48"
42
-
height="248.96001"
43
-
preserveAspectRatio="none"
44
-
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ 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 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
-
id="image1"
46
-
x="-233.6257"
47
-
y="10.383364"
48
-
style="display:none" />
49
-
<path
50
-
fill="currentColor"
51
-
style="stroke-width:0.111183"
52
-
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
53
-
id="path4" />
54
-
</g>
55
-
</svg>
56
{{ end }}
···
1
{{ define "fragments/dolly/logo" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{ . }}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
10
+
inkscape:export-filename="tangled_logotype_black_on_trans.svg"
11
+
inkscape:export-xdpi="96"
12
+
inkscape:export-ydpi="96"
13
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
14
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
15
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
16
+
xmlns="http://www.w3.org/2000/svg"
17
+
xmlns:svg="http://www.w3.org/2000/svg"
18
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
+
xmlns:cc="http://creativecommons.org/ns#">
20
+
<sodipodi:namedview
21
+
id="namedview1"
22
+
pagecolor="#ffffff"
23
+
bordercolor="#000000"
24
+
borderopacity="0.25"
25
+
inkscape:showpageshadow="2"
26
+
inkscape:pageopacity="0.0"
27
+
inkscape:pagecheckerboard="true"
28
+
inkscape:deskcolor="#d5d5d5"
29
+
inkscape:zoom="45.254834"
30
+
inkscape:cx="3.1377863"
31
+
inkscape:cy="8.9382717"
32
+
inkscape:window-width="3840"
33
+
inkscape:window-height="2160"
34
+
inkscape:window-x="0"
35
+
inkscape:window-y="0"
36
+
inkscape:window-maximized="0"
37
+
inkscape:current-layer="g1"
38
+
borderlayer="true">
39
+
<inkscape:page
40
+
x="0"
41
+
y="0"
42
+
width="25"
43
+
height="25"
44
+
id="page2"
45
+
margin="0"
46
+
bleed="0" />
47
+
</sodipodi:namedview>
48
+
<g
49
+
inkscape:groupmode="layer"
50
+
inkscape:label="Image"
51
+
id="g1"
52
+
transform="translate(-0.42924038,-0.87777209)">
53
+
<path
54
+
fill="currentColor"
55
+
style="stroke-width:0.111183;"
56
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
+
id="path4"
58
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
59
+
</g>
60
+
<metadata
61
+
id="metadata1">
62
+
<rdf:RDF>
63
+
<cc:Work
64
+
rdf:about="">
65
+
<cc:license
66
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
67
+
</cc:Work>
68
+
<cc:License
69
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
70
+
<cc:permits
71
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
72
+
<cc:permits
73
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
74
+
<cc:requires
75
+
rdf:resource="http://creativecommons.org/ns#Notice" />
76
+
<cc:requires
77
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
78
+
<cc:permits
79
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
80
+
</cc:License>
81
+
</rdf:RDF>
82
+
</metadata>
83
+
</svg>
84
{{ end }}
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
···
2
<svg
3
version="1.1"
4
id="svg1"
5
-
width="32"
6
-
height="32"
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_silhouette.png"
9
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
xmlns="http://www.w3.org/2000/svg"
12
-
xmlns:svg="http://www.w3.org/2000/svg">
13
-
<style>
14
-
.dolly {
15
-
color: #000000;
16
-
}
17
18
-
@media (prefers-color-scheme: dark) {
19
-
.dolly {
20
-
color: #ffffff;
21
-
}
22
-
}
23
-
</style>
24
-
<title>Dolly</title>
25
-
<defs
26
-
id="defs1" />
27
<sodipodi:namedview
28
id="namedview1"
29
pagecolor="#ffffff"
···
32
inkscape:showpageshadow="2"
33
inkscape:pageopacity="0.0"
34
inkscape:pagecheckerboard="true"
35
-
inkscape:deskcolor="#d1d1d1">
36
<inkscape:page
37
x="0"
38
y="0"
···
45
<g
46
inkscape:groupmode="layer"
47
inkscape:label="Image"
48
-
id="g1">
49
<path
50
class="dolly"
51
fill="currentColor"
52
-
style="stroke-width:1.12248"
53
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
54
-
id="path1" />
55
</g>
56
</svg>
57
{{ end }}
···
2
<svg
3
version="1.1"
4
id="svg1"
5
+
width="25"
6
+
height="25"
7
viewBox="0 0 25 25"
8
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
+
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
+
inkscape:export-xdpi="96"
11
+
inkscape:export-ydpi="96"
12
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
13
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
xmlns="http://www.w3.org/2000/svg"
16
+
xmlns:svg="http://www.w3.org/2000/svg"
17
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
+
xmlns:cc="http://creativecommons.org/ns#">
19
+
<style>
20
+
.dolly {
21
+
color: #000000;
22
+
}
23
24
+
@media (prefers-color-scheme: dark) {
25
+
.dolly {
26
+
color: #ffffff;
27
+
}
28
+
}
29
+
</style>
30
<sodipodi:namedview
31
id="namedview1"
32
pagecolor="#ffffff"
···
35
inkscape:showpageshadow="2"
36
inkscape:pageopacity="0.0"
37
inkscape:pagecheckerboard="true"
38
+
inkscape:deskcolor="#d5d5d5"
39
+
inkscape:zoom="64"
40
+
inkscape:cx="4.96875"
41
+
inkscape:cy="13.429688"
42
+
inkscape:window-width="3840"
43
+
inkscape:window-height="2160"
44
+
inkscape:window-x="0"
45
+
inkscape:window-y="0"
46
+
inkscape:window-maximized="0"
47
+
inkscape:current-layer="g1"
48
+
borderlayer="true">
49
<inkscape:page
50
x="0"
51
y="0"
···
58
<g
59
inkscape:groupmode="layer"
60
inkscape:label="Image"
61
+
id="g1"
62
+
transform="translate(-0.42924038,-0.87777209)">
63
<path
64
class="dolly"
65
fill="currentColor"
66
+
style="stroke-width:0.111183"
67
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
+
id="path7"
69
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
70
</g>
71
+
<metadata
72
+
id="metadata1">
73
+
<rdf:RDF>
74
+
<cc:Work
75
+
rdf:about="">
76
+
<cc:license
77
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
+
</cc:Work>
79
+
<cc:License
80
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
+
<cc:permits
82
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
+
<cc:permits
84
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
+
<cc:requires
86
+
rdf:resource="http://creativecommons.org/ns#Notice" />
87
+
<cc:requires
88
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
+
<cc:permits
90
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
+
</cc:License>
92
+
</rdf:RDF>
93
+
</metadata>
94
</svg>
95
{{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
-44
appview/pages/templates/fragments/dolly/silhouette.svg
···
1
-
<svg
2
-
version="1.1"
3
-
id="svg1"
4
-
width="32"
5
-
height="32"
6
-
viewBox="0 0 25 25"
7
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
9
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
-
xmlns="http://www.w3.org/2000/svg"
11
-
xmlns:svg="http://www.w3.org/2000/svg">
12
-
<title>Dolly</title>
13
-
<defs
14
-
id="defs1" />
15
-
<sodipodi:namedview
16
-
id="namedview1"
17
-
pagecolor="#ffffff"
18
-
bordercolor="#000000"
19
-
borderopacity="0.25"
20
-
inkscape:showpageshadow="2"
21
-
inkscape:pageopacity="0.0"
22
-
inkscape:pagecheckerboard="true"
23
-
inkscape:deskcolor="#d1d1d1">
24
-
<inkscape:page
25
-
x="0"
26
-
y="0"
27
-
width="25"
28
-
height="25"
29
-
id="page2"
30
-
margin="0"
31
-
bleed="0" />
32
-
</sodipodi:namedview>
33
-
<g
34
-
inkscape:groupmode="layer"
35
-
inkscape:label="Image"
36
-
id="g1">
37
-
<path
38
-
class="dolly"
39
-
fill="currentColor"
40
-
style="stroke-width:1.12248"
41
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
42
-
id="path1" />
43
-
</g>
44
-
</svg>
···
+28
appview/pages/templates/fragments/starBtn.html
+28
appview/pages/templates/fragments/starBtn.html
···
···
1
+
{{ define "fragments/starBtn" }}
2
+
<button
3
+
id="starBtn"
4
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
+
data-star-subject-at="{{ .SubjectAt }}"
6
+
{{ if .IsStarred }}
7
+
hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
8
+
{{ else }}
9
+
hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
10
+
{{ end }}
11
+
12
+
hx-trigger="click"
13
+
hx-target="this"
14
+
hx-swap="outerHTML"
15
+
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
16
+
hx-disabled-elt="#starBtn"
17
+
>
18
+
{{ if .IsStarred }}
19
+
{{ i "star" "w-4 h-4 fill-current" }}
20
+
{{ else }}
21
+
{{ i "star" "w-4 h-4" }}
22
+
{{ end }}
23
+
<span class="text-sm">
24
+
{{ .StarCount }}
25
+
</span>
26
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
+
</button>
28
+
{{ end }}
+25
appview/pages/templates/fragments/tabSelector.html
+25
appview/pages/templates/fragments/tabSelector.html
···
···
1
+
{{ define "fragments/tabSelector" }}
2
+
{{ $name := .Name }}
3
+
{{ $all := .Values }}
4
+
{{ $active := .Active }}
5
+
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
6
+
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
7
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
8
+
{{ range $index, $value := $all }}
9
+
{{ $isActive := eq $value.Key $active }}
10
+
<a href="?{{ $name }}={{ $value.Key }}"
11
+
class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
12
+
{{ if $value.Icon }}
13
+
{{ i $value.Icon "size-4" }}
14
+
{{ end }}
15
+
16
+
{{ with $value.Meta }}
17
+
{{ . }}
18
+
{{ end }}
19
+
20
+
{{ $value.Value }}
21
+
</a>
22
+
{{ end }}
23
+
</div>
24
+
{{ end }}
25
+
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Id }}"
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
{{ block "addKnotMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
···
29
ADD MEMBER
30
</label>
31
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
39
<div class="flex gap-2 pt-2">
40
<button
41
type="button"
···
54
</div>
55
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
</form>
57
-
{{ end }}
···
13
<div
14
id="add-member-{{ .Id }}"
15
popover
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
19
{{ block "addKnotMemberPopover" . }} {{ end }}
20
</div>
21
{{ end }}
···
31
ADD MEMBER
32
</label>
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
47
<div class="flex gap-2 pt-2">
48
<button
49
type="button"
···
62
</div>
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
64
</form>
65
+
{{ end }}
+1
appview/pages/templates/layouts/base.html
+1
appview/pages/templates/layouts/base.html
+57
-26
appview/pages/templates/layouts/repobase.html
+57
-26
appview/pages/templates/layouts/repobase.html
···
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
-
{{ if .RepoInfo.Source }}
6
-
<p class="text-sm">
7
-
<div class="flex items-center">
8
-
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
9
-
forked from
10
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
-
</div>
13
-
</p>
14
-
{{ end }}
15
-
<div class="text-lg flex items-center justify-between">
16
-
<div>
17
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
-
<span class="select-none">/</span>
19
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
</div>
21
22
-
<div class="flex items-center gap-2 z-auto">
23
-
<a
24
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
-
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
-
>
27
-
{{ i "rss" "size-4" }}
28
-
</a>
29
-
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
<a
31
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
32
hx-boost="true"
···
36
fork
37
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
</a>
39
</div>
40
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
42
</section>
43
44
<section class="w-full flex flex-col" >
···
79
</div>
80
</nav>
81
{{ block "repoContentLayout" . }}
82
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
83
{{ block "repoContent" . }}{{ end }}
84
</section>
85
{{ block "repoAfter" . }}{{ end }}
···
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "content" }}
4
+
<section id="repo-header" class="mb-4 p-2 dark:text-white">
5
+
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
6
+
<!-- left items -->
7
+
<div class="flex flex-col gap-2">
8
+
<!-- repo owner / repo name -->
9
+
<div class="flex items-center gap-2 flex-wrap">
10
+
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
11
+
<span class="select-none">/</span>
12
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
13
+
</div>
14
+
15
+
{{ if .RepoInfo.Source }}
16
+
{{ $sourceOwner := resolve .RepoInfo.Source.Did }}
17
+
<div class="flex items-center gap-1 text-sm flex-wrap">
18
+
{{ i "git-fork" "w-3 h-3 shrink-0" }}
19
+
<span>forked from</span>
20
+
<a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">
21
+
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
22
+
</a>
23
+
</div>
24
+
{{ end }}
25
+
26
+
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
27
+
{{ if .RepoInfo.Description }}
28
+
{{ .RepoInfo.Description | description }}
29
+
{{ else }}
30
+
<span class="italic">this repo has no description</span>
31
+
{{ end }}
32
+
33
+
{{ with .RepoInfo.Website }}
34
+
<span class="flex items-center gap-1">
35
+
<span class="flex-shrink-0">{{ i "globe" "size-4" }}</span>
36
+
<a href="{{ . }}">{{ . | trimUriScheme }}</a>
37
+
</span>
38
+
{{ end }}
39
+
40
+
{{ if .RepoInfo.Topics }}
41
+
<div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300">
42
+
{{ range .RepoInfo.Topics }}
43
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span>
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
48
+
</span>
49
</div>
50
51
+
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
52
+
{{ template "fragments/starBtn"
53
+
(dict "SubjectAt" .RepoInfo.RepoAt
54
+
"IsStarred" .RepoInfo.IsStarred
55
+
"StarCount" .RepoInfo.Stats.StarCount) }}
56
<a
57
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
58
hx-boost="true"
···
62
fork
63
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
64
</a>
65
+
<a
66
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
67
+
href="/{{ .RepoInfo.FullName }}/feed.atom">
68
+
{{ i "rss" "size-4" }}
69
+
<span class="md:hidden">atom</span>
70
+
</a>
71
</div>
72
</div>
73
</section>
74
75
<section class="w-full flex flex-col" >
···
110
</div>
111
</nav>
112
{{ block "repoContentLayout" . }}
113
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
114
{{ block "repoContent" . }}{{ end }}
115
</section>
116
{{ block "repoAfter" . }}{{ end }}
+2
appview/pages/templates/notifications/fragments/item.html
+2
appview/pages/templates/notifications/fragments/item.html
+64
-39
appview/pages/templates/repo/blob.html
+64
-39
appview/pages/templates/repo/blob.html
···
11
{{ end }}
12
13
{{ define "repoContent" }}
14
-
{{ $lines := split .Contents }}
15
-
{{ $tot_lines := len $lines }}
16
-
{{ $tot_chars := len (printf "%d" $tot_lines) }}
17
-
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
18
{{ $linkstyle := "no-underline hover:underline" }}
19
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
20
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
36
</div>
37
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
38
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
39
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
40
-
<span>{{ .Lines }} lines</span>
41
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
42
-
<span>{{ byteFmt .SizeHint }}</span>
43
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
44
-
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
-
{{ if .RenderToggle }}
46
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
-
hx-boost="true"
50
-
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
{{ end }}
52
</div>
53
</div>
54
</div>
55
-
{{ if and .IsBinary .Unsupported }}
56
-
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
Previews are not supported for this file type.
58
-
</p>
59
-
{{ else if .IsBinary }}
60
-
<div class="text-center">
61
-
{{ if .IsImage }}
62
-
<img src="{{ .ContentSrc }}"
63
-
alt="{{ .Path }}"
64
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65
-
{{ else if .IsVideo }}
66
-
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67
-
<source src="{{ .ContentSrc }}">
68
-
Your browser does not support the video tag.
69
-
</video>
70
-
{{ end }}
71
-
</div>
72
-
{{ else }}
73
-
<div class="overflow-auto relative">
74
-
{{ if .ShowRendered }}
75
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
76
{{ else }}
77
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
78
{{ end }}
79
-
</div>
80
{{ end }}
81
{{ template "fragments/multiline-select" }}
82
{{ end }}
···
11
{{ end }}
12
13
{{ define "repoContent" }}
14
{{ $linkstyle := "no-underline hover:underline" }}
15
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
16
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
32
</div>
33
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
34
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
35
+
36
+
{{ if .BlobView.ShowingText }}
37
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
38
+
<span>{{ .Lines }} lines</span>
39
+
{{ end }}
40
+
41
+
{{ if .BlobView.SizeHint }}
42
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
43
+
<span>{{ byteFmt .BlobView.SizeHint }}</span>
44
+
{{ end }}
45
+
46
+
{{ if .BlobView.HasRawView }}
47
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
48
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
49
+
{{ end }}
50
+
51
+
{{ if .BlobView.ShowToggle }}
52
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
53
+
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true">
54
+
view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }}
55
+
</a>
56
{{ end }}
57
</div>
58
</div>
59
</div>
60
+
{{ if .BlobView.IsUnsupported }}
61
+
<p class="text-center text-gray-400 dark:text-gray-500">
62
+
Previews are not supported for this file type.
63
+
</p>
64
+
{{ else if .BlobView.ContentType.IsSubmodule }}
65
+
<p class="text-center text-gray-400 dark:text-gray-500">
66
+
This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>.
67
+
</p>
68
+
{{ else if .BlobView.ContentType.IsImage }}
69
+
<div class="text-center">
70
+
<img src="{{ .BlobView.ContentSrc }}"
71
+
alt="{{ .Path }}"
72
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
73
+
</div>
74
+
{{ else if .BlobView.ContentType.IsVideo }}
75
+
<div class="text-center">
76
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
77
+
<source src="{{ .BlobView.ContentSrc }}">
78
+
Your browser does not support the video tag.
79
+
</video>
80
+
</div>
81
+
{{ else if .BlobView.ContentType.IsSvg }}
82
+
<div class="overflow-auto relative">
83
+
{{ if .BlobView.ShowingRendered }}
84
+
<div class="text-center">
85
+
<img src="{{ .BlobView.ContentSrc }}"
86
+
alt="{{ .Path }}"
87
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
88
+
</div>
89
+
{{ else }}
90
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
91
+
{{ end }}
92
+
</div>
93
+
{{ else if .BlobView.ContentType.IsMarkup }}
94
+
<div class="overflow-auto relative">
95
+
{{ if .BlobView.ShowingRendered }}
96
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div>
97
{{ else }}
98
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
99
{{ end }}
100
+
</div>
101
+
{{ else if .BlobView.ContentType.IsCode }}
102
+
<div class="overflow-auto relative">
103
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
104
+
</div>
105
{{ end }}
106
{{ template "fragments/multiline-select" }}
107
{{ end }}
+1
-1
appview/pages/templates/repo/compare/compare.html
+1
-1
appview/pages/templates/repo/compare/compare.html
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
35
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
-
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
</div>
41
</div>
···
35
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
</div>
41
</div>
+1
appview/pages/templates/repo/fork.html
+1
appview/pages/templates/repo/fork.html
+4
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
+4
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
···
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"
···
48
<code
49
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"
50
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
<button
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
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"
···
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/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .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"
···
48
<code
49
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"
50
onclick="window.getSelection().selectAllChildren(this)"
51
+
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
<button
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
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"
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
···
5
{{ if .Split }}
6
{{ $active = "split" }}
7
{{ end }}
8
-
{{ $values := list "unified" "split" }}
9
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
10
</section>
11
{{ end }}
12
13
-
{{ define "tabSelector" }}
14
-
{{ $name := .Name }}
15
-
{{ $all := .Values }}
16
-
{{ $active := .Active }}
17
-
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
18
-
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
19
-
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
20
-
{{ range $index, $value := $all }}
21
-
{{ $isActive := eq $value $active }}
22
-
<a href="?{{ $name }}={{ $value }}"
23
-
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
24
-
{{ $value }}
25
-
</a>
26
-
{{ end }}
27
-
</div>
28
-
{{ end }}
···
5
{{ if .Split }}
6
{{ $active = "split" }}
7
{{ end }}
8
+
9
+
{{ $unified :=
10
+
(dict
11
+
"Key" "unified"
12
+
"Value" "unified"
13
+
"Icon" "square-split-vertical"
14
+
"Meta" "") }}
15
+
{{ $split :=
16
+
(dict
17
+
"Key" "split"
18
+
"Value" "split"
19
+
"Icon" "square-split-horizontal"
20
+
"Meta" "") }}
21
+
{{ $values := list $unified $split }}
22
+
23
+
{{ template "fragments/tabSelector"
24
+
(dict
25
+
"Name" "diff"
26
+
"Values" $values
27
+
"Active" $active) }}
28
</section>
29
{{ end }}
30
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
1
-
{{ define "repo/fragments/editRepoDescription" }}
2
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
-
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
5
-
{{ i "check" "w-3 h-3" }} save
6
-
</button>
7
-
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
-
{{ i "x" "w-3 h-3" }} cancel
9
-
</button>
10
-
</form>
11
-
{{ end }}
···
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
···
···
1
+
{{ define "repo/fragments/externalLinkPanel" }}
2
+
<div id="at-uri-panel" class="px-2 md:px-0">
3
+
<div class="flex justify-between items-center gap-2">
4
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span>
5
+
<div class="flex items-center gap-2">
6
+
<button
7
+
onclick="copyToClipboard(this)"
8
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
9
+
title="Copy to clipboard">
10
+
{{ i "copy" "w-4 h-4" }}
11
+
</button>
12
+
<a
13
+
href="https://pdsls.dev/{{.}}"
14
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
15
+
title="View in PDSls">
16
+
{{ i "arrow-up-right" "w-4 h-4" }}
17
+
</a>
18
+
</div>
19
+
</div>
20
+
<span
21
+
class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600"
22
+
onclick="window.getSelection().selectAllChildren(this)"
23
+
title="{{.}}"
24
+
data-aturi="{{ . | string | safeUrl }}"
25
+
>{{.}}</span>
26
+
27
+
28
+
</div>
29
+
30
+
<script>
31
+
function copyToClipboard(button) {
32
+
const container = document.getElementById("at-uri-panel");
33
+
const urlSpan = container?.querySelector('[data-aturi]');
34
+
const text = urlSpan?.getAttribute('data-aturi');
35
+
console.log("copying to clipboard", text)
36
+
if (!text) return;
37
+
38
+
navigator.clipboard.writeText(text).then(() => {
39
+
const originalContent = button.innerHTML;
40
+
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
41
+
setTimeout(() => {
42
+
button.innerHTML = originalContent;
43
+
}, 2000);
44
+
});
45
+
}
46
+
</script>
47
+
{{ end }}
48
+
-15
appview/pages/templates/repo/fragments/repoDescription.html
-15
appview/pages/templates/repo/fragments/repoDescription.html
···
1
-
{{ define "repo/fragments/repoDescription" }}
2
-
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
-
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description | description }}
5
-
{{ else }}
6
-
<span class="italic">this repo has no description</span>
7
-
{{ end }}
8
-
9
-
{{ if .RepoInfo.Roles.IsOwner }}
10
-
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
-
{{ i "pencil" "w-3 h-3" }}
12
-
</button>
13
-
{{ end }}
14
-
</span>
15
-
{{ end }}
···
-26
appview/pages/templates/repo/fragments/repoStar.html
-26
appview/pages/templates/repo/fragments/repoStar.html
···
1
-
{{ define "repo/fragments/repoStar" }}
2
-
<button
3
-
id="starBtn"
4
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
-
{{ if .IsStarred }}
6
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
7
-
{{ else }}
8
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
9
-
{{ end }}
10
-
11
-
hx-trigger="click"
12
-
hx-target="this"
13
-
hx-swap="outerHTML"
14
-
hx-disabled-elt="#starBtn"
15
-
>
16
-
{{ if .IsStarred }}
17
-
{{ i "star" "w-4 h-4 fill-current" }}
18
-
{{ else }}
19
-
{{ i "star" "w-4 h-4" }}
20
-
{{ end }}
21
-
<span class="text-sm">
22
-
{{ .Stats.StarCount }}
23
-
</span>
24
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
-
</button>
26
-
{{ end }}
···
+8
-1
appview/pages/templates/repo/index.html
+8
-1
appview/pages/templates/repo/index.html
···
35
{{ end }}
36
37
{{ define "repoLanguages" }}
38
-
<details class="group -m-6 mb-4">
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
{{ range $value := .Languages }}
41
<div
···
129
{{ $icon := "folder" }}
130
{{ $iconStyle := "size-4 fill-current" }}
131
132
{{ if .IsFile }}
133
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
{{ $icon = "file" }}
135
{{ $iconStyle = "size-4" }}
136
{{ end }}
137
<a href="{{ $link }}" class="{{ $linkstyle }}">
138
<div class="flex items-center gap-2">
139
{{ i $icon $iconStyle "flex-shrink-0" }}
···
35
{{ end }}
36
37
{{ define "repoLanguages" }}
38
+
<details class="group -my-4 -m-6 mb-4">
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
{{ range $value := .Languages }}
41
<div
···
129
{{ $icon := "folder" }}
130
{{ $iconStyle := "size-4 fill-current" }}
131
132
+
{{ if .IsSubmodule }}
133
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
+
{{ $icon = "folder-input" }}
135
+
{{ $iconStyle = "size-4" }}
136
+
{{ end }}
137
+
138
{{ if .IsFile }}
139
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
140
{{ $icon = "file" }}
141
{{ $iconStyle = "size-4" }}
142
{{ end }}
143
+
144
<a href="{{ $link }}" class="{{ $linkstyle }}">
145
<div class="flex items-center gap-2">
146
{{ i $icon $iconStyle "flex-shrink-0" }}
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
+1
appview/pages/templates/repo/issues/issue.html
+1
appview/pages/templates/repo/issues/issue.html
+145
-53
appview/pages/templates/repo/issues/issues.html
+145
-53
appview/pages/templates/repo/issues/issues.html
···
8
{{ end }}
9
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
12
-
<div class="flex gap-4">
13
-
<a
14
-
href="?state=open"
15
-
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
>
17
-
{{ i "circle-dot" "w-4 h-4" }}
18
-
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
19
-
</a>
20
-
<a
21
-
href="?state=closed"
22
-
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
>
24
-
{{ i "ban" "w-4 h-4" }}
25
-
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
26
-
</a>
27
-
<form class="flex gap-4" method="GET">
28
-
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
29
-
<input class="" type="text" name="q" value="{{ .FilterQuery }}">
30
-
<button class="btn" type="submit">
31
-
search
32
-
</button>
33
</form>
34
-
</div>
35
-
<a
36
href="/{{ .RepoInfo.FullName }}/issues/new"
37
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
38
-
>
39
{{ i "circle-plus" "w-4 h-4" }}
40
<span>new</span>
41
-
</a>
42
-
</div>
43
-
<div class="error" id="issues"></div>
44
{{ end }}
45
46
{{ define "repoAfter" }}
47
<div class="mt-2">
48
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
49
</div>
50
-
{{ block "pagination" . }} {{ end }}
51
{{ end }}
52
53
{{ define "pagination" }}
54
-
<div class="flex justify-end mt-4 gap-2">
55
-
{{ $currentState := "closed" }}
56
-
{{ if .FilteringByOpen }}
57
-
{{ $currentState = "open" }}
58
-
{{ end }}
59
60
{{ if gt .Page.Offset 0 }}
61
-
{{ $prev := .Page.Previous }}
62
-
<a
63
-
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
64
-
hx-boost="true"
65
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
66
-
>
67
-
{{ i "chevron-left" "w-4 h-4" }}
68
-
previous
69
-
</a>
70
-
{{ else }}
71
-
<div></div>
72
{{ end }}
73
74
{{ if eq (len .Issues) .Page.Limit }}
75
-
{{ $next := .Page.Next }}
76
-
<a
77
-
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
78
-
hx-boost="true"
79
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
80
-
>
81
-
next
82
-
{{ i "chevron-right" "w-4 h-4" }}
83
-
</a>
84
{{ end }}
85
</div>
86
{{ end }}
···
8
{{ end }}
9
10
{{ define "repoContent" }}
11
+
{{ $active := "closed" }}
12
+
{{ if .FilteringByOpen }}
13
+
{{ $active = "open" }}
14
+
{{ end }}
15
+
16
+
{{ $open :=
17
+
(dict
18
+
"Key" "open"
19
+
"Value" "open"
20
+
"Icon" "circle-dot"
21
+
"Meta" (string .RepoInfo.Stats.IssueCount.Open)) }}
22
+
{{ $closed :=
23
+
(dict
24
+
"Key" "closed"
25
+
"Value" "closed"
26
+
"Icon" "ban"
27
+
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
28
+
{{ $values := list $open $closed }}
29
+
30
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
31
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
32
+
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
33
+
<div class="flex-1 flex relative">
34
+
<input
35
+
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
36
+
type="text"
37
+
name="q"
38
+
value="{{ .FilterQuery }}"
39
+
placeholder=" "
40
>
41
+
<a
42
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
43
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
44
>
45
+
{{ i "x" "w-4 h-4" }}
46
+
</a>
47
+
</div>
48
+
<button
49
+
type="submit"
50
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
51
+
>
52
+
{{ i "search" "w-4 h-4" }}
53
+
</button>
54
</form>
55
+
<div class="sm:row-start-1">
56
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
57
+
</div>
58
+
<a
59
href="/{{ .RepoInfo.FullName }}/issues/new"
60
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
61
+
>
62
{{ i "circle-plus" "w-4 h-4" }}
63
<span>new</span>
64
+
</a>
65
+
</div>
66
+
<div class="error" id="issues"></div>
67
{{ end }}
68
69
{{ define "repoAfter" }}
70
<div class="mt-2">
71
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
72
</div>
73
+
{{if gt .IssueCount .Page.Limit }}
74
+
{{ block "pagination" . }} {{ end }}
75
+
{{ end }}
76
{{ end }}
77
78
{{ define "pagination" }}
79
+
<div class="flex justify-center items-center mt-4 gap-2">
80
+
{{ $currentState := "closed" }}
81
+
{{ if .FilteringByOpen }}
82
+
{{ $currentState = "open" }}
83
+
{{ end }}
84
85
+
{{ $prev := .Page.Previous.Offset }}
86
+
{{ $next := .Page.Next.Offset }}
87
+
{{ $lastPage := sub .IssueCount (mod .IssueCount .Page.Limit) }}
88
+
89
+
<a
90
+
class="
91
+
btn flex items-center gap-2 no-underline hover:no-underline
92
+
dark:text-white dark:hover:bg-gray-700
93
+
{{ if le .Page.Offset 0 }}
94
+
cursor-not-allowed opacity-50
95
+
{{ end }}
96
+
"
97
{{ if gt .Page.Offset 0 }}
98
+
hx-boost="true"
99
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}"
100
{{ end }}
101
+
>
102
+
{{ i "chevron-left" "w-4 h-4" }}
103
+
previous
104
+
</a>
105
106
+
<!-- dont show first page if current page is first page -->
107
+
{{ if gt .Page.Offset 0 }}
108
+
<a
109
+
hx-boost="true"
110
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset=0&limit={{ .Page.Limit }}"
111
+
>
112
+
1
113
+
</a>
114
+
{{ end }}
115
+
116
+
<!-- if previous page is not first or second page (prev > limit) -->
117
+
{{ if gt $prev .Page.Limit }}
118
+
<span>...</span>
119
+
{{ end }}
120
+
121
+
<!-- if previous page is not the first page -->
122
+
{{ if gt $prev 0 }}
123
+
<a
124
+
hx-boost="true"
125
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}"
126
+
>
127
+
{{ add (div $prev .Page.Limit) 1 }}
128
+
</a>
129
+
{{ end }}
130
+
131
+
<!-- current page. this is always visible -->
132
+
<span class="font-bold">
133
+
{{ add (div .Page.Offset .Page.Limit) 1 }}
134
+
</span>
135
+
136
+
<!-- if next page is not last page -->
137
+
{{ if lt $next $lastPage }}
138
+
<a
139
+
hx-boost="true"
140
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}"
141
+
>
142
+
{{ add (div $next .Page.Limit) 1 }}
143
+
</a>
144
+
{{ end }}
145
+
146
+
<!-- if next page is not second last or last page (next < issues - 2 * limit) -->
147
+
{{ if lt ($next) (sub .IssueCount (mul (2) .Page.Limit)) }}
148
+
<span>...</span>
149
+
{{ end }}
150
+
151
+
<!-- if its not the last page -->
152
+
{{ if lt .Page.Offset $lastPage }}
153
+
<a
154
+
hx-boost="true"
155
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $lastPage }}&limit={{ .Page.Limit }}"
156
+
>
157
+
{{ add (div $lastPage .Page.Limit) 1 }}
158
+
</a>
159
+
{{ end }}
160
+
161
+
<a
162
+
class="
163
+
btn flex items-center gap-2 no-underline hover:no-underline
164
+
dark:text-white dark:hover:bg-gray-700
165
+
{{ if ne (len .Issues) .Page.Limit }}
166
+
cursor-not-allowed opacity-50
167
+
{{ end }}
168
+
"
169
{{ if eq (len .Issues) .Page.Limit }}
170
+
hx-boost="true"
171
+
href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}"
172
{{ end }}
173
+
>
174
+
next
175
+
{{ i "chevron-right" "w-4 h-4" }}
176
+
</a>
177
</div>
178
{{ end }}
+1
appview/pages/templates/repo/new.html
+1
appview/pages/templates/repo/new.html
+3
-3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+3
-3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
2
<div id="lines" hx-swap-oob="beforeend">
3
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
5
-
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
6
-
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
7
</summary>
8
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
9
</details>
···
11
{{ end }}
12
13
{{ define "stepHeader" }}
14
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
15
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
16
{{ end }}
···
2
<div id="lines" hx-swap-oob="beforeend">
3
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
5
+
<div class="group-open:hidden flex items-center gap-1">{{ i "chevron-right" "w-4 h-4" }} {{ template "stepHeader" . }}</div>
6
+
<div class="hidden group-open:flex items-center gap-1">{{ i "chevron-down" "w-4 h-4" }} {{ template "stepHeader" . }}</div>
7
</summary>
8
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
9
</details>
···
11
{{ end }}
12
13
{{ define "stepHeader" }}
14
+
{{ .Name }}
15
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
16
{{ end }}
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
···
12
{{ range .Pipelines }}
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
{{ else }}
15
+
<div class="py-6 w-fit flex flex-col gap-4 mx-auto">
16
+
<p>
17
+
No pipelines have been run for this repository yet. To get started:
18
+
</p>
19
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
20
+
<p>
21
+
<span class="{{ $bullet }}">1</span>First, choose a spindle in your
22
+
<a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>.
23
+
</p>
24
+
<p>
25
+
<span class="{{ $bullet }}">2</span>Configure your CI/CD
26
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
27
+
</p>
28
+
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
+
</div>
30
{{ end }}
31
</div>
32
</div>
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
{{ $isUpToDate := .ResubmitCheck.No }}
25
-
<div class="relative w-fit">
26
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
27
-
<button
28
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
29
-
hx-target="#actions-{{$roundNumber}}"
30
-
hx-swap="outerHtml"
31
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
32
-
{{ i "message-square-plus" "w-4 h-4" }}
33
-
<span>comment</span>
34
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
-
</button>
36
-
{{ if .BranchDeleteStatus }}
37
-
<button
38
-
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
39
-
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
40
-
hx-swap="none"
41
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
42
-
{{ i "git-branch" "w-4 h-4" }}
43
-
<span>delete branch</span>
44
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
-
</button>
46
-
{{ end }}
47
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
48
-
{{ $disabled := "" }}
49
-
{{ if $isConflicted }}
50
-
{{ $disabled = "disabled" }}
51
-
{{ end }}
52
-
<button
53
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
54
-
hx-swap="none"
55
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
56
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
57
-
{{ i "git-merge" "w-4 h-4" }}
58
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
59
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
60
-
</button>
61
-
{{ end }}
62
63
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
64
-
{{ $disabled := "" }}
65
-
{{ if $isUpToDate }}
66
-
{{ $disabled = "disabled" }}
67
{{ end }}
68
-
<button id="resubmitBtn"
69
-
{{ if not .Pull.IsPatchBased }}
70
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
71
-
{{ else }}
72
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
73
-
hx-target="#actions-{{$roundNumber}}"
74
-
hx-swap="outerHtml"
75
-
{{ end }}
76
77
-
hx-disabled-elt="#resubmitBtn"
78
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
79
80
-
{{ if $disabled }}
81
-
title="Update this branch to resubmit this pull request"
82
-
{{ else }}
83
-
title="Resubmit this pull request"
84
-
{{ end }}
85
-
>
86
-
{{ i "rotate-ccw" "w-4 h-4" }}
87
-
<span>resubmit</span>
88
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
89
-
</button>
90
-
{{ end }}
91
92
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
93
-
<button
94
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
95
-
hx-swap="none"
96
-
class="btn p-2 flex items-center gap-2 group">
97
-
{{ i "ban" "w-4 h-4" }}
98
-
<span>close</span>
99
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
-
</button>
101
-
{{ end }}
102
103
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
104
-
<button
105
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
106
-
hx-swap="none"
107
-
class="btn p-2 flex items-center gap-2 group">
108
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
109
-
<span>reopen</span>
110
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
111
-
</button>
112
-
{{ end }}
113
-
</div>
114
</div>
115
{{ end }}
116
···
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
{{ $isUpToDate := .ResubmitCheck.No }}
25
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative">
26
+
<button
27
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
28
+
hx-target="#actions-{{$roundNumber}}"
29
+
hx-swap="outerHtml"
30
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
31
+
{{ i "message-square-plus" "w-4 h-4" }}
32
+
<span>comment</span>
33
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
34
+
</button>
35
+
{{ if .BranchDeleteStatus }}
36
+
<button
37
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
38
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
39
+
hx-swap="none"
40
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
41
+
{{ i "git-branch" "w-4 h-4" }}
42
+
<span>delete branch</span>
43
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
44
+
</button>
45
+
{{ end }}
46
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
47
+
{{ $disabled := "" }}
48
+
{{ if $isConflicted }}
49
+
{{ $disabled = "disabled" }}
50
+
{{ end }}
51
+
<button
52
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
53
+
hx-swap="none"
54
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
55
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
56
+
{{ i "git-merge" "w-4 h-4" }}
57
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
</button>
60
+
{{ end }}
61
62
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
63
+
{{ $disabled := "" }}
64
+
{{ if $isUpToDate }}
65
+
{{ $disabled = "disabled" }}
66
+
{{ end }}
67
+
<button id="resubmitBtn"
68
+
{{ if not .Pull.IsPatchBased }}
69
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
70
+
{{ else }}
71
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
72
+
hx-target="#actions-{{$roundNumber}}"
73
+
hx-swap="outerHtml"
74
{{ end }}
75
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
78
79
+
{{ if $disabled }}
80
+
title="Update this branch to resubmit this pull request"
81
+
{{ else }}
82
+
title="Resubmit this pull request"
83
+
{{ end }}
84
+
>
85
+
{{ i "rotate-ccw" "w-4 h-4" }}
86
+
<span>resubmit</span>
87
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
+
</button>
89
+
{{ end }}
90
91
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
92
+
<button
93
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
94
+
hx-swap="none"
95
+
class="btn p-2 flex items-center gap-2 group">
96
+
{{ i "ban" "w-4 h-4" }}
97
+
<span>close</span>
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</button>
100
+
{{ end }}
101
102
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
103
+
<button
104
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
105
+
hx-swap="none"
106
+
class="btn p-2 flex items-center gap-2 group">
107
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
108
+
<span>reopen</span>
109
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
110
+
</button>
111
+
{{ end }}
112
</div>
113
{{ end }}
114
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+2
-1
appview/pages/templates/repo/pulls/pull.html
+2
-1
appview/pages/templates/repo/pulls/pull.html
···
18
{{ template "repo/fragments/labelPanel"
19
(dict "RepoInfo" $.RepoInfo
20
"Defs" $.LabelDefs
21
+
"Subject" $.Pull.AtUri
22
"State" $.Pull.Labels) }}
23
{{ template "repo/fragments/participants" $.Pull.Participants }}
24
+
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
25
</div>
26
</div>
27
{{ end }}
+60
-38
appview/pages/templates/repo/pulls/pulls.html
+60
-38
appview/pages/templates/repo/pulls/pulls.html
···
8
{{ end }}
9
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center">
12
-
<div class="flex gap-4">
13
-
<a
14
-
href="?state=open"
15
-
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
-
>
17
-
{{ i "git-pull-request" "w-4 h-4" }}
18
-
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
19
-
</a>
20
-
<a
21
-
href="?state=merged"
22
-
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
-
>
24
-
{{ i "git-merge" "w-4 h-4" }}
25
-
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
26
-
</a>
27
-
<a
28
-
href="?state=closed"
29
-
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
30
-
>
31
-
{{ i "ban" "w-4 h-4" }}
32
-
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
33
-
</a>
34
-
<form class="flex gap-4" method="GET">
35
-
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
36
-
<input class="" type="text" name="q" value="{{ .FilterQuery }}">
37
-
<button class="btn" type="submit">
38
-
search
39
-
</button>
40
-
</form>
41
-
</div>
42
<a
43
-
href="/{{ .RepoInfo.FullName }}/pulls/new"
44
-
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
45
>
46
-
{{ i "git-pull-request-create" "w-4 h-4" }}
47
-
<span>new</span>
48
</a>
49
</div>
50
-
<div class="error" id="pulls"></div>
51
{{ end }}
52
53
{{ define "repoAfter" }}
···
140
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
141
</div>
142
</summary>
143
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
144
</details>
145
{{ end }}
146
{{ end }}
···
149
</div>
150
{{ end }}
151
152
-
{{ define "pullList" }}
153
{{ $list := index . 0 }}
154
{{ $root := index . 1 }}
155
<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">
···
8
{{ end }}
9
10
{{ define "repoContent" }}
11
+
{{ $active := "closed" }}
12
+
{{ if .FilteringBy.IsOpen }}
13
+
{{ $active = "open" }}
14
+
{{ else if .FilteringBy.IsMerged }}
15
+
{{ $active = "merged" }}
16
+
{{ end }}
17
+
{{ $open :=
18
+
(dict
19
+
"Key" "open"
20
+
"Value" "open"
21
+
"Icon" "git-pull-request"
22
+
"Meta" (string .RepoInfo.Stats.PullCount.Open)) }}
23
+
{{ $merged :=
24
+
(dict
25
+
"Key" "merged"
26
+
"Value" "merged"
27
+
"Icon" "git-merge"
28
+
"Meta" (string .RepoInfo.Stats.PullCount.Merged)) }}
29
+
{{ $closed :=
30
+
(dict
31
+
"Key" "closed"
32
+
"Value" "closed"
33
+
"Icon" "ban"
34
+
"Meta" (string .RepoInfo.Stats.PullCount.Closed)) }}
35
+
{{ $values := list $open $merged $closed }}
36
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
37
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
38
+
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
39
+
<div class="flex-1 flex relative">
40
+
<input
41
+
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
42
+
type="text"
43
+
name="q"
44
+
value="{{ .FilterQuery }}"
45
+
placeholder=" "
46
+
>
47
<a
48
+
href="?state={{ .FilteringBy.String }}"
49
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
50
>
51
+
{{ i "x" "w-4 h-4" }}
52
</a>
53
+
</div>
54
+
<button
55
+
type="submit"
56
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
57
+
>
58
+
{{ i "search" "w-4 h-4" }}
59
+
</button>
60
+
</form>
61
+
<div class="sm:row-start-1">
62
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
63
</div>
64
+
<a
65
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
66
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
67
+
>
68
+
{{ i "git-pull-request-create" "w-4 h-4" }}
69
+
<span>new</span>
70
+
</a>
71
+
</div>
72
+
<div class="error" id="pulls"></div>
73
{{ end }}
74
75
{{ define "repoAfter" }}
···
162
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
163
</div>
164
</summary>
165
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
166
</details>
167
{{ end }}
168
{{ end }}
···
171
</div>
172
{{ end }}
173
174
+
{{ define "stackedPullList" }}
175
{{ $list := index . 0 }}
176
{{ $root := index . 1 }}
177
<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">
+17
-10
appview/pages/templates/repo/settings/access.html
+17
-10
appview/pages/templates/repo/settings/access.html
···
66
<div
67
id="add-collaborator-modal"
68
popover
69
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
70
{{ template "addCollaboratorModal" . }}
71
</div>
72
{{ end }}
···
82
ADD COLLABORATOR
83
</label>
84
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
-
<input
86
-
autocapitalize="none"
87
-
autocorrect="off"
88
-
type="text"
89
-
id="add-collaborator"
90
-
name="collaborator"
91
-
required
92
-
placeholder="@foo.bsky.social"
93
-
/>
94
<div class="flex gap-2 pt-2">
95
<button
96
type="button"
···
66
<div
67
id="add-collaborator-modal"
68
popover
69
+
class="
70
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
71
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
72
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
73
{{ template "addCollaboratorModal" . }}
74
</div>
75
{{ end }}
···
85
ADD COLLABORATOR
86
</label>
87
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
88
+
<actor-typeahead>
89
+
<input
90
+
autocapitalize="none"
91
+
autocorrect="off"
92
+
autocomplete="off"
93
+
type="text"
94
+
id="add-collaborator"
95
+
name="collaborator"
96
+
required
97
+
placeholder="user.tngl.sh"
98
+
class="w-full"
99
+
/>
100
+
</actor-typeahead>
101
<div class="flex gap-2 pt-2">
102
<button
103
type="button"
+47
appview/pages/templates/repo/settings/general.html
+47
appview/pages/templates/repo/settings/general.html
···
6
{{ template "repo/settings/fragments/sidebar" . }}
7
</div>
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
{{ template "branchSettings" . }}
10
{{ template "defaultLabelSettings" . }}
11
{{ template "customLabelSettings" . }}
···
13
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
</div>
15
</section>
16
{{ end }}
17
18
{{ define "branchSettings" }}
···
6
{{ template "repo/settings/fragments/sidebar" . }}
7
</div>
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "baseSettings" . }}
10
{{ template "branchSettings" . }}
11
{{ template "defaultLabelSettings" . }}
12
{{ template "customLabelSettings" . }}
···
14
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
15
</div>
16
</section>
17
+
{{ end }}
18
+
19
+
{{ define "baseSettings" }}
20
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none">
21
+
<fieldset
22
+
class=""
23
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
24
+
>
25
+
<h2 class="text-sm pb-2 uppercase font-bold">Description</h2>
26
+
<textarea
27
+
rows="3"
28
+
class="w-full mb-2"
29
+
id="base-form-description"
30
+
name="description"
31
+
>{{ .RepoInfo.Description }}</textarea>
32
+
<h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2>
33
+
<input
34
+
type="text"
35
+
class="w-full mb-2"
36
+
id="base-form-website"
37
+
name="website"
38
+
value="{{ .RepoInfo.Website }}"
39
+
>
40
+
<h2 class="text-sm pb-2 uppercase font-bold">Topics</h2>
41
+
<p class="text-gray-500 dark:text-gray-400">
42
+
List of topics separated by spaces.
43
+
</p>
44
+
<textarea
45
+
rows="2"
46
+
class="w-full my-2"
47
+
id="base-form-topics"
48
+
name="topics"
49
+
>{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea>
50
+
<div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div>
51
+
<div class="flex justify-end pt-2">
52
+
<button
53
+
type="submit"
54
+
class="btn-create flex items-center gap-2 group"
55
+
>
56
+
{{ i "save" "w-4 h-4" }}
57
+
save
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
</button>
60
+
</div>
61
+
</fieldset>
62
+
</form>
63
{{ end }}
64
65
{{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
+8
appview/pages/templates/repo/tree.html
···
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
62
{{ if .IsFile }}
63
{{ $icon = "file" }}
64
{{ $iconStyle = "size-4" }}
65
{{ end }}
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
<div class="flex items-center gap-2">
68
{{ i $icon $iconStyle "flex-shrink-0" }}
···
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
62
+
{{ if .IsSubmodule }}
63
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
64
+
{{ $icon = "folder-input" }}
65
+
{{ $iconStyle = "size-4" }}
66
+
{{ end }}
67
+
68
{{ if .IsFile }}
69
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
70
{{ $icon = "file" }}
71
{{ $iconStyle = "size-4" }}
72
{{ end }}
73
+
74
<a href="{{ $link }}" class="{{ $linkstyle }}">
75
<div class="flex items-center gap-2">
76
{{ i $icon $iconStyle "flex-shrink-0" }}
+16
-10
appview/pages/templates/spindles/fragments/addMemberModal.html
+16
-10
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Instance }}"
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
···
29
ADD MEMBER
30
</label>
31
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
-
<input
33
-
autocapitalize="none"
34
-
autocorrect="off"
35
-
type="text"
36
-
id="member-did-{{ .Id }}"
37
-
name="member"
38
-
required
39
-
placeholder="@foo.bsky.social"
40
-
/>
41
<div class="flex gap-2 pt-2">
42
<button
43
type="button"
···
13
<div
14
id="add-member-{{ .Instance }}"
15
popover
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
20
</div>
21
{{ end }}
···
31
ADD MEMBER
32
</label>
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
47
<div class="flex gap-2 pt-2">
48
<button
49
type="button"
+10
-6
appview/pages/templates/strings/string.html
+10
-6
appview/pages/templates/strings/string.html
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
21
-
<div class="flex gap-2 text-base">
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
37
<span class="hidden md:inline">delete</span>
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
</button>
40
-
</div>
41
-
{{ end }}
42
</div>
43
<span>
44
{{ with .String.Description }}
···
75
</div>
76
<div class="overflow-x-auto overflow-y-hidden relative">
77
{{ if .ShowRendered }}
78
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
79
{{ else }}
80
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
81
{{ end }}
82
</div>
83
{{ template "fragments/multiline-select" }}
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
+
<div class="flex gap-2 text-base">
21
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
37
<span class="hidden md:inline">delete</span>
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
</button>
40
+
{{ end }}
41
+
{{ template "fragments/starBtn"
42
+
(dict "SubjectAt" .String.AtUri
43
+
"IsStarred" .IsStarred
44
+
"StarCount" .StarCount) }}
45
+
</div>
46
</div>
47
<span>
48
{{ with .String.Description }}
···
79
</div>
80
<div class="overflow-x-auto overflow-y-hidden relative">
81
{{ if .ShowRendered }}
82
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
83
{{ else }}
84
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div>
85
{{ end }}
86
</div>
87
{{ template "fragments/multiline-select" }}
+1
-2
appview/pages/templates/timeline/fragments/goodfirstissues.html
+1
-2
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
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>
···
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
<p>
7
+
Make your first contribution to an open-source project.
8
<em>good-first-issue</em> helps new contributors find easy ways to
9
start contributing to open-source projects.
10
</p>
+2
-2
appview/pages/templates/timeline/fragments/hero.html
+2
-2
appview/pages/templates/timeline/fragments/hero.html
···
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
6
<p class="text-lg">
7
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
8
</p>
9
<p class="text-lg">
10
-
we envision a place where developers have complete ownership of their
11
code, open source communities can freely self-govern and most
12
importantly, coding can be social and fun again.
13
</p>
···
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
6
<p class="text-lg">
7
+
Tangled is a decentralized Git hosting and collaboration platform.
8
</p>
9
<p class="text-lg">
10
+
We envision a place where developers have complete ownership of their
11
code, open source communities can freely self-govern and most
12
importantly, coding can be social and fun again.
13
</p>
+4
-4
appview/pages/templates/timeline/fragments/timeline.html
+4
-4
appview/pages/templates/timeline/fragments/timeline.html
···
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
</div>
54
{{ with $repo }}
55
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
56
{{ end }}
57
{{ end }}
58
59
{{ define "timeline/fragments/starEvent" }}
60
{{ $root := index . 0 }}
61
{{ $event := index . 1 }}
62
-
{{ $star := $event.Star }}
63
{{ with $star }}
64
-
{{ $starrerHandle := resolve .StarredByDid }}
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
66
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
67
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
</div>
74
{{ with .Repo }}
75
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
76
{{ end }}
77
{{ end }}
78
{{ end }}
···
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
</div>
54
{{ with $repo }}
55
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
56
{{ end }}
57
{{ end }}
58
59
{{ define "timeline/fragments/starEvent" }}
60
{{ $root := index . 0 }}
61
{{ $event := index . 1 }}
62
+
{{ $star := $event.RepoStar }}
63
{{ with $star }}
64
+
{{ $starrerHandle := resolve .Did }}
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
66
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
67
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
</div>
74
{{ with .Repo }}
75
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
76
{{ end }}
77
{{ end }}
78
{{ end }}
+17
appview/pages/templates/user/fragments/editBio.html
+17
appview/pages/templates/user/fragments/editBio.html
···
20
</div>
21
22
<div class="flex flex-col gap-1">
23
+
<label class="m-0 p-0" for="pronouns">pronouns</label>
24
+
<div class="flex items-center gap-2 w-full">
25
+
{{ $pronouns := "" }}
26
+
{{ if and .Profile .Profile.Pronouns }}
27
+
{{ $pronouns = .Profile.Pronouns }}
28
+
{{ end }}
29
+
<input
30
+
type="text"
31
+
class="py-1 px-1 w-full"
32
+
name="pronouns"
33
+
placeholder="they/them"
34
+
value="{{ $pronouns }}"
35
+
>
36
+
</div>
37
+
</div>
38
+
39
+
<div class="flex flex-col gap-1">
40
<label class="m-0 p-0" for="location">location</label>
41
<div class="flex items-center gap-2 w-full">
42
{{ $location := "" }}
+19
-6
appview/pages/templates/user/fragments/profileCard.html
+19
-6
appview/pages/templates/user/fragments/profileCard.html
···
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
{{ $userIdent }}
14
</p>
15
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
</div>
17
18
<div class="md:hidden">
···
67
{{ end }}
68
</div>
69
{{ end }}
70
-
{{ if ne .FollowStatus.String "IsSelf" }}
71
-
{{ template "user/fragments/follow" . }}
72
-
{{ else }}
73
<button id="editBtn"
74
-
class="btn mt-2 w-full flex items-center gap-2 group"
75
hx-target="#profile-bio"
76
hx-get="/profile/edit-bio"
77
hx-swap="innerHTML">
···
79
edit
80
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
</button>
82
-
{{ end }}
83
</div>
84
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
85
</div>
···
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
{{ $userIdent }}
14
</p>
15
+
{{ with .Profile }}
16
+
{{ if .Pronouns }}
17
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
+
{{ end }}
19
+
{{ end }}
20
</div>
21
22
<div class="md:hidden">
···
71
{{ end }}
72
</div>
73
{{ end }}
74
+
75
+
<div class="flex mt-2 items-center gap-2">
76
+
{{ if ne .FollowStatus.String "IsSelf" }}
77
+
{{ template "user/fragments/follow" . }}
78
+
{{ else }}
79
<button id="editBtn"
80
+
class="btn w-full flex items-center gap-2 group"
81
hx-target="#profile-bio"
82
hx-get="/profile/edit-bio"
83
hx-swap="innerHTML">
···
85
edit
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
87
</button>
88
+
{{ end }}
89
+
90
+
<a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
91
+
href="/{{ $userIdent }}/feed.atom">
92
+
{{ i "rss" "size-4" }}
93
+
</a>
94
+
</div>
95
+
96
</div>
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
98
</div>
+2
-1
appview/pages/templates/user/fragments/repoCard.html
+2
-1
appview/pages/templates/user/fragments/repoCard.html
···
1
{{ define "user/fragments/repoCard" }}
2
{{ $root := index . 0 }}
3
{{ $repo := index . 1 }}
4
{{ $fullName := index . 2 }}
···
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>
···
1
{{ define "user/fragments/repoCard" }}
2
+
{{/* root, repo, fullName [,starButton [,starData]] */}}
3
{{ $root := index . 0 }}
4
{{ $repo := index . 1 }}
5
{{ $fullName := index . 2 }}
···
30
</div>
31
{{ if and $starButton $root.LoggedInUser }}
32
<div class="shrink-0">
33
+
{{ template "fragments/starBtn" $starData }}
34
</div>
35
{{ end }}
36
</div>
+14
appview/pages/templates/user/settings/notifications.html
+14
appview/pages/templates/user/settings/notifications.html
···
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>
···
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">Mentions</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>When someone mentions you.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
158
+
<div class="flex items-center justify-between p-2">
159
+
<div class="flex items-center gap-2">
160
+
<div class="flex flex-col gap-1">
161
<span class="font-bold">Email notifications</span>
162
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
163
<span>Receive notifications via email in addition to in-app notifications.</span>
+3
appview/pipelines/pipelines.go
+3
appview/pipelines/pipelines.go
···
82
83
ps, err := db.GetPipelineStatuses(
84
p.db,
85
db.FilterEq("repo_owner", repoInfo.OwnerDid),
86
db.FilterEq("repo_name", repoInfo.Name),
87
db.FilterEq("knot", repoInfo.Knot),
···
124
125
ps, err := db.GetPipelineStatuses(
126
p.db,
127
db.FilterEq("repo_owner", repoInfo.OwnerDid),
128
db.FilterEq("repo_name", repoInfo.Name),
129
db.FilterEq("knot", repoInfo.Knot),
···
193
194
ps, err := db.GetPipelineStatuses(
195
p.db,
196
db.FilterEq("repo_owner", repoInfo.OwnerDid),
197
db.FilterEq("repo_name", repoInfo.Name),
198
db.FilterEq("knot", repoInfo.Knot),
···
82
83
ps, err := db.GetPipelineStatuses(
84
p.db,
85
+
30,
86
db.FilterEq("repo_owner", repoInfo.OwnerDid),
87
db.FilterEq("repo_name", repoInfo.Name),
88
db.FilterEq("knot", repoInfo.Knot),
···
125
126
ps, err := db.GetPipelineStatuses(
127
p.db,
128
+
1,
129
db.FilterEq("repo_owner", repoInfo.OwnerDid),
130
db.FilterEq("repo_name", repoInfo.Name),
131
db.FilterEq("knot", repoInfo.Knot),
···
195
196
ps, err := db.GetPipelineStatuses(
197
p.db,
198
+
1,
199
db.FilterEq("repo_owner", repoInfo.OwnerDid),
200
db.FilterEq("repo_name", repoInfo.Name),
201
db.FilterEq("knot", repoInfo.Knot),
+7
-7
appview/pulls/opengraph.go
+7
-7
appview/pulls/opengraph.go
···
146
var statusColor color.RGBA
147
148
if pull.State.IsOpen() {
149
-
statusIcon = "static/icons/git-pull-request.svg"
150
statusText = "open"
151
statusColor = color.RGBA{34, 139, 34, 255} // green
152
} else if pull.State.IsMerged() {
153
-
statusIcon = "static/icons/git-merge.svg"
154
statusText = "merged"
155
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
} else {
157
-
statusIcon = "static/icons/git-pull-request-closed.svg"
158
statusText = "closed"
159
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
}
···
162
statusIconSize := 36
163
164
// Draw icon with status color
165
-
err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
if err != nil {
167
log.Printf("failed to draw status icon: %v", err)
168
}
···
179
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
181
// Draw comment count
182
-
err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
if err != nil {
184
log.Printf("failed to draw comment icon: %v", err)
185
}
···
198
currentX += commentTextWidth + 40
199
200
// Draw files changed
201
-
err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
if err != nil {
203
log.Printf("failed to draw file diff icon: %v", err)
204
}
···
241
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
245
if err != nil {
246
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
}
···
146
var statusColor color.RGBA
147
148
if pull.State.IsOpen() {
149
+
statusIcon = "git-pull-request"
150
statusText = "open"
151
statusColor = color.RGBA{34, 139, 34, 255} // green
152
} else if pull.State.IsMerged() {
153
+
statusIcon = "git-merge"
154
statusText = "merged"
155
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
} else {
157
+
statusIcon = "git-pull-request-closed"
158
statusText = "closed"
159
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
}
···
162
statusIconSize := 36
163
164
// Draw icon with status color
165
+
err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
if err != nil {
167
log.Printf("failed to draw status icon: %v", err)
168
}
···
179
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
181
// Draw comment count
182
+
err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
if err != nil {
184
log.Printf("failed to draw comment icon: %v", err)
185
}
···
198
currentX += commentTextWidth + 40
199
200
// Draw files changed
201
+
err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
if err != nil {
203
log.Printf("failed to draw file diff icon: %v", err)
204
}
···
241
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
245
if err != nil {
246
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
}
+23
-9
appview/pulls/pulls.go
+23
-9
appview/pulls/pulls.go
···
33
"tangled.org/core/types"
34
35
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
lexutil "github.com/bluesky-social/indigo/lex/util"
37
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
38
"github.com/go-chi/chi/v5"
···
177
178
ps, err := db.GetPipelineStatuses(
179
s.db,
180
db.FilterEq("repo_owner", repoInfo.OwnerDid),
181
db.FilterEq("repo_name", repoInfo.Name),
182
db.FilterEq("knot", repoInfo.Knot),
···
191
m[p.Sha] = p
192
}
193
194
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
195
if err != nil {
196
log.Println("failed to get pull reactions")
197
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
199
200
userReactions := map[models.ReactionKind]bool{}
201
if user != nil {
202
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
203
}
204
205
labelDefs, err := db.GetLabelDefinitions(
···
647
repoInfo := f.RepoInfo(user)
648
ps, err := db.GetPipelineStatuses(
649
s.db,
650
db.FilterEq("repo_owner", repoInfo.OwnerDid),
651
db.FilterEq("repo_name", repoInfo.Name),
652
db.FilterEq("knot", repoInfo.Knot),
···
690
}
691
692
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
693
user := s.oauth.GetUser(r)
694
f, err := s.repoResolver.Resolve(r)
695
if err != nil {
···
751
Rkey: tid.TID(),
752
Record: &lexutil.LexiconTypeDecoder{
753
Val: &tangled.RepoPullComment{
754
-
Pull: pull.PullAt().String(),
755
Body: body,
756
CreatedAt: createdAt,
757
},
···
787
return
788
}
789
790
-
s.notifier.NewPullComment(r.Context(), comment)
791
792
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
793
return
···
1838
}
1839
defer tx.Rollback()
1840
1841
-
pullAt := pull.PullAt()
1842
newRoundNumber := len(pull.Submissions)
1843
newPatch := patch
1844
newSourceRev := sourceRev
···
2035
}
2036
2037
// resubmit the new pull
2038
-
pullAt := op.PullAt()
2039
newRoundNumber := len(op.Submissions)
2040
newPatch := np.LatestPatch()
2041
combinedPatch := np.LatestSubmission().Combined
···
2106
}
2107
2108
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2109
f, err := s.repoResolver.Resolve(r)
2110
if err != nil {
2111
log.Println("failed to resolve repo:", err)
···
2216
2217
// notify about the pull merge
2218
for _, p := range pullsToMerge {
2219
-
s.notifier.NewPullState(r.Context(), p)
2220
}
2221
2222
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2288
}
2289
2290
for _, p := range pullsToClose {
2291
-
s.notifier.NewPullState(r.Context(), p)
2292
}
2293
2294
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2361
}
2362
2363
for _, p := range pullsToReopen {
2364
-
s.notifier.NewPullState(r.Context(), p)
2365
}
2366
2367
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
33
"tangled.org/core/types"
34
35
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
+
"github.com/bluesky-social/indigo/atproto/syntax"
37
lexutil "github.com/bluesky-social/indigo/lex/util"
38
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
39
"github.com/go-chi/chi/v5"
···
178
179
ps, err := db.GetPipelineStatuses(
180
s.db,
181
+
len(shas),
182
db.FilterEq("repo_owner", repoInfo.OwnerDid),
183
db.FilterEq("repo_name", repoInfo.Name),
184
db.FilterEq("knot", repoInfo.Knot),
···
193
m[p.Sha] = p
194
}
195
196
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
197
if err != nil {
198
log.Println("failed to get pull reactions")
199
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
201
202
userReactions := map[models.ReactionKind]bool{}
203
if user != nil {
204
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
205
}
206
207
labelDefs, err := db.GetLabelDefinitions(
···
649
repoInfo := f.RepoInfo(user)
650
ps, err := db.GetPipelineStatuses(
651
s.db,
652
+
len(shas),
653
db.FilterEq("repo_owner", repoInfo.OwnerDid),
654
db.FilterEq("repo_name", repoInfo.Name),
655
db.FilterEq("knot", repoInfo.Knot),
···
693
}
694
695
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
696
+
l := s.logger.With("handler", "PullComment")
697
user := s.oauth.GetUser(r)
698
f, err := s.repoResolver.Resolve(r)
699
if err != nil {
···
755
Rkey: tid.TID(),
756
Record: &lexutil.LexiconTypeDecoder{
757
Val: &tangled.RepoPullComment{
758
+
Pull: pull.AtUri().String(),
759
Body: body,
760
CreatedAt: createdAt,
761
},
···
791
return
792
}
793
794
+
rawMentions := markup.FindUserMentions(comment.Body)
795
+
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
796
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
797
+
var mentions []syntax.DID
798
+
for _, ident := range idents {
799
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
800
+
mentions = append(mentions, ident.DID)
801
+
}
802
+
}
803
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
804
805
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
806
return
···
1851
}
1852
defer tx.Rollback()
1853
1854
+
pullAt := pull.AtUri()
1855
newRoundNumber := len(pull.Submissions)
1856
newPatch := patch
1857
newSourceRev := sourceRev
···
2048
}
2049
2050
// resubmit the new pull
2051
+
pullAt := op.AtUri()
2052
newRoundNumber := len(op.Submissions)
2053
newPatch := np.LatestPatch()
2054
combinedPatch := np.LatestSubmission().Combined
···
2119
}
2120
2121
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2122
+
user := s.oauth.GetUser(r)
2123
f, err := s.repoResolver.Resolve(r)
2124
if err != nil {
2125
log.Println("failed to resolve repo:", err)
···
2230
2231
// notify about the pull merge
2232
for _, p := range pullsToMerge {
2233
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2234
}
2235
2236
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2302
}
2303
2304
for _, p := range pullsToClose {
2305
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2306
}
2307
2308
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2375
}
2376
2377
for _, p := range pullsToReopen {
2378
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2379
}
2380
2381
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+49
appview/repo/archive.go
+49
appview/repo/archive.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
9
+
"tangled.org/core/api/tangled"
10
+
xrpcclient "tangled.org/core/appview/xrpcclient"
11
+
12
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/go-chi/chi/v5"
14
+
"github.com/go-git/go-git/v5/plumbing"
15
+
)
16
+
17
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "DownloadArchive")
19
+
ref := chi.URLParam(r, "ref")
20
+
ref, _ = url.PathUnescape(ref)
21
+
f, err := rp.repoResolver.Resolve(r)
22
+
if err != nil {
23
+
l.Error("failed to get repo and knot", "err", err)
24
+
return
25
+
}
26
+
scheme := "http"
27
+
if !rp.config.Core.Dev {
28
+
scheme = "https"
29
+
}
30
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
31
+
xrpcc := &indigoxrpc.Client{
32
+
Host: host,
33
+
}
34
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
35
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
36
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
+
rp.pages.Error503(w)
39
+
return
40
+
}
41
+
// Set headers for file download, just pass along whatever the knot specifies
42
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
43
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
44
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
45
+
w.Header().Set("Content-Type", "application/gzip")
46
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
47
+
// Write the archive data directly
48
+
w.Write(archiveBytes)
49
+
}
+291
appview/repo/blob.go
+291
appview/repo/blob.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"fmt"
6
+
"io"
7
+
"net/http"
8
+
"net/url"
9
+
"path/filepath"
10
+
"slices"
11
+
"strings"
12
+
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/models"
16
+
"tangled.org/core/appview/pages"
17
+
"tangled.org/core/appview/pages/markup"
18
+
"tangled.org/core/appview/reporesolver"
19
+
xrpcclient "tangled.org/core/appview/xrpcclient"
20
+
21
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
+
"github.com/go-chi/chi/v5"
23
+
)
24
+
25
+
// the content can be one of the following:
26
+
//
27
+
// - code : text | | raw
28
+
// - markup : text | rendered | raw
29
+
// - svg : text | rendered | raw
30
+
// - png : | rendered | raw
31
+
// - video : | rendered | raw
32
+
// - submodule : | rendered |
33
+
// - rest : | |
34
+
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
35
+
l := rp.logger.With("handler", "RepoBlob")
36
+
37
+
f, err := rp.repoResolver.Resolve(r)
38
+
if err != nil {
39
+
l.Error("failed to get repo and knot", "err", err)
40
+
return
41
+
}
42
+
43
+
ref := chi.URLParam(r, "ref")
44
+
ref, _ = url.PathUnescape(ref)
45
+
46
+
filePath := chi.URLParam(r, "*")
47
+
filePath, _ = url.PathUnescape(filePath)
48
+
49
+
scheme := "http"
50
+
if !rp.config.Core.Dev {
51
+
scheme = "https"
52
+
}
53
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
54
+
xrpcc := &indigoxrpc.Client{
55
+
Host: host,
56
+
}
57
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
58
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
59
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
60
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
61
+
rp.pages.Error503(w)
62
+
return
63
+
}
64
+
65
+
// Use XRPC response directly instead of converting to internal types
66
+
var breadcrumbs [][]string
67
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
68
+
if filePath != "" {
69
+
for idx, elem := range strings.Split(filePath, "/") {
70
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
71
+
}
72
+
}
73
+
74
+
// Create the blob view
75
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
76
+
77
+
user := rp.oauth.GetUser(r)
78
+
79
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
80
+
LoggedInUser: user,
81
+
RepoInfo: f.RepoInfo(user),
82
+
BreadCrumbs: breadcrumbs,
83
+
BlobView: blobView,
84
+
RepoBlob_Output: resp,
85
+
})
86
+
}
87
+
88
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
89
+
l := rp.logger.With("handler", "RepoBlobRaw")
90
+
91
+
f, err := rp.repoResolver.Resolve(r)
92
+
if err != nil {
93
+
l.Error("failed to get repo and knot", "err", err)
94
+
w.WriteHeader(http.StatusBadRequest)
95
+
return
96
+
}
97
+
98
+
ref := chi.URLParam(r, "ref")
99
+
ref, _ = url.PathUnescape(ref)
100
+
101
+
filePath := chi.URLParam(r, "*")
102
+
filePath, _ = url.PathUnescape(filePath)
103
+
104
+
scheme := "http"
105
+
if !rp.config.Core.Dev {
106
+
scheme = "https"
107
+
}
108
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
109
+
baseURL := &url.URL{
110
+
Scheme: scheme,
111
+
Host: f.Knot,
112
+
Path: "/xrpc/sh.tangled.repo.blob",
113
+
}
114
+
query := baseURL.Query()
115
+
query.Set("repo", repo)
116
+
query.Set("ref", ref)
117
+
query.Set("path", filePath)
118
+
query.Set("raw", "true")
119
+
baseURL.RawQuery = query.Encode()
120
+
blobURL := baseURL.String()
121
+
req, err := http.NewRequest("GET", blobURL, nil)
122
+
if err != nil {
123
+
l.Error("failed to create request", "err", err)
124
+
return
125
+
}
126
+
127
+
// forward the If-None-Match header
128
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
129
+
req.Header.Set("If-None-Match", clientETag)
130
+
}
131
+
client := &http.Client{}
132
+
133
+
resp, err := client.Do(req)
134
+
if err != nil {
135
+
l.Error("failed to reach knotserver", "err", err)
136
+
rp.pages.Error503(w)
137
+
return
138
+
}
139
+
140
+
defer resp.Body.Close()
141
+
142
+
// forward 304 not modified
143
+
if resp.StatusCode == http.StatusNotModified {
144
+
w.WriteHeader(http.StatusNotModified)
145
+
return
146
+
}
147
+
148
+
if resp.StatusCode != http.StatusOK {
149
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
150
+
w.WriteHeader(resp.StatusCode)
151
+
_, _ = io.Copy(w, resp.Body)
152
+
return
153
+
}
154
+
155
+
contentType := resp.Header.Get("Content-Type")
156
+
body, err := io.ReadAll(resp.Body)
157
+
if err != nil {
158
+
l.Error("error reading response body from knotserver", "err", err)
159
+
w.WriteHeader(http.StatusInternalServerError)
160
+
return
161
+
}
162
+
163
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
164
+
// serve all textual content as text/plain
165
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
166
+
w.Write(body)
167
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
168
+
// serve images and videos with their original content type
169
+
w.Header().Set("Content-Type", contentType)
170
+
w.Write(body)
171
+
} else {
172
+
w.WriteHeader(http.StatusUnsupportedMediaType)
173
+
w.Write([]byte("unsupported content type"))
174
+
return
175
+
}
176
+
}
177
+
178
+
// NewBlobView creates a BlobView from the XRPC response
179
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
180
+
view := models.BlobView{
181
+
Contents: "",
182
+
Lines: 0,
183
+
}
184
+
185
+
// Set size
186
+
if resp.Size != nil {
187
+
view.SizeHint = uint64(*resp.Size)
188
+
} else if resp.Content != nil {
189
+
view.SizeHint = uint64(len(*resp.Content))
190
+
}
191
+
192
+
if resp.Submodule != nil {
193
+
view.ContentType = models.BlobContentTypeSubmodule
194
+
view.HasRenderedView = true
195
+
view.ContentSrc = resp.Submodule.Url
196
+
return view
197
+
}
198
+
199
+
// Determine if binary
200
+
if resp.IsBinary != nil && *resp.IsBinary {
201
+
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
202
+
ext := strings.ToLower(filepath.Ext(resp.Path))
203
+
204
+
switch ext {
205
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
206
+
view.ContentType = models.BlobContentTypeImage
207
+
view.HasRawView = true
208
+
view.HasRenderedView = true
209
+
view.ShowingRendered = true
210
+
211
+
case ".svg":
212
+
view.ContentType = models.BlobContentTypeSvg
213
+
view.HasRawView = true
214
+
view.HasTextView = true
215
+
view.HasRenderedView = true
216
+
view.ShowingRendered = queryParams.Get("code") != "true"
217
+
if resp.Content != nil {
218
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
219
+
view.Contents = string(bytes)
220
+
view.Lines = strings.Count(view.Contents, "\n") + 1
221
+
}
222
+
223
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
224
+
view.ContentType = models.BlobContentTypeVideo
225
+
view.HasRawView = true
226
+
view.HasRenderedView = true
227
+
view.ShowingRendered = true
228
+
}
229
+
230
+
return view
231
+
}
232
+
233
+
// otherwise, we are dealing with text content
234
+
view.HasRawView = true
235
+
view.HasTextView = true
236
+
237
+
if resp.Content != nil {
238
+
view.Contents = *resp.Content
239
+
view.Lines = strings.Count(view.Contents, "\n") + 1
240
+
}
241
+
242
+
// with text, we may be dealing with markdown
243
+
format := markup.GetFormat(resp.Path)
244
+
if format == markup.FormatMarkdown {
245
+
view.ContentType = models.BlobContentTypeMarkup
246
+
view.HasRenderedView = true
247
+
view.ShowingRendered = queryParams.Get("code") != "true"
248
+
}
249
+
250
+
return view
251
+
}
252
+
253
+
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
254
+
scheme := "http"
255
+
if !config.Core.Dev {
256
+
scheme = "https"
257
+
}
258
+
259
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
260
+
baseURL := &url.URL{
261
+
Scheme: scheme,
262
+
Host: f.Knot,
263
+
Path: "/xrpc/sh.tangled.repo.blob",
264
+
}
265
+
query := baseURL.Query()
266
+
query.Set("repo", repoName)
267
+
query.Set("ref", ref)
268
+
query.Set("path", filePath)
269
+
query.Set("raw", "true")
270
+
baseURL.RawQuery = query.Encode()
271
+
blobURL := baseURL.String()
272
+
273
+
if !config.Core.Dev {
274
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
275
+
}
276
+
return blobURL
277
+
}
278
+
279
+
func isTextualMimeType(mimeType string) bool {
280
+
textualTypes := []string{
281
+
"application/json",
282
+
"application/xml",
283
+
"application/yaml",
284
+
"application/x-yaml",
285
+
"application/toml",
286
+
"application/javascript",
287
+
"application/ecmascript",
288
+
"message/",
289
+
}
290
+
return slices.Contains(textualTypes, mimeType)
291
+
}
+95
appview/repo/branches.go
+95
appview/repo/branches.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/appview/oauth"
10
+
"tangled.org/core/appview/pages"
11
+
xrpcclient "tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/types"
13
+
14
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15
+
)
16
+
17
+
func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "RepoBranches")
19
+
f, err := rp.repoResolver.Resolve(r)
20
+
if err != nil {
21
+
l.Error("failed to get repo and knot", "err", err)
22
+
return
23
+
}
24
+
scheme := "http"
25
+
if !rp.config.Core.Dev {
26
+
scheme = "https"
27
+
}
28
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
29
+
xrpcc := &indigoxrpc.Client{
30
+
Host: host,
31
+
}
32
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
33
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
34
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
35
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
36
+
rp.pages.Error503(w)
37
+
return
38
+
}
39
+
var result types.RepoBranchesResponse
40
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
41
+
l.Error("failed to decode XRPC response", "err", err)
42
+
rp.pages.Error503(w)
43
+
return
44
+
}
45
+
sortBranches(result.Branches)
46
+
user := rp.oauth.GetUser(r)
47
+
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
48
+
LoggedInUser: user,
49
+
RepoInfo: f.RepoInfo(user),
50
+
RepoBranchesResponse: result,
51
+
})
52
+
}
53
+
54
+
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
55
+
l := rp.logger.With("handler", "DeleteBranch")
56
+
f, err := rp.repoResolver.Resolve(r)
57
+
if err != nil {
58
+
l.Error("failed to get repo and knot", "err", err)
59
+
return
60
+
}
61
+
noticeId := "delete-branch-error"
62
+
fail := func(msg string, err error) {
63
+
l.Error(msg, "err", err)
64
+
rp.pages.Notice(w, noticeId, msg)
65
+
}
66
+
branch := r.FormValue("branch")
67
+
if branch == "" {
68
+
fail("No branch provided.", nil)
69
+
return
70
+
}
71
+
client, err := rp.oauth.ServiceClient(
72
+
r,
73
+
oauth.WithService(f.Knot),
74
+
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
75
+
oauth.WithDev(rp.config.Core.Dev),
76
+
)
77
+
if err != nil {
78
+
fail("Failed to connect to knotserver", nil)
79
+
return
80
+
}
81
+
err = tangled.RepoDeleteBranch(
82
+
r.Context(),
83
+
client,
84
+
&tangled.RepoDeleteBranch_Input{
85
+
Branch: branch,
86
+
Repo: f.RepoAt().String(),
87
+
},
88
+
)
89
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
90
+
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
91
+
return
92
+
}
93
+
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
94
+
rp.pages.HxRefresh(w)
95
+
}
+218
appview/repo/compare.go
+218
appview/repo/compare.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
"strings"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/pages"
12
+
xrpcclient "tangled.org/core/appview/xrpcclient"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/types"
15
+
16
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
17
+
"github.com/go-chi/chi/v5"
18
+
)
19
+
20
+
func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) {
21
+
l := rp.logger.With("handler", "RepoCompareNew")
22
+
23
+
user := rp.oauth.GetUser(r)
24
+
f, err := rp.repoResolver.Resolve(r)
25
+
if err != nil {
26
+
l.Error("failed to get repo and knot", "err", err)
27
+
return
28
+
}
29
+
30
+
scheme := "http"
31
+
if !rp.config.Core.Dev {
32
+
scheme = "https"
33
+
}
34
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
35
+
xrpcc := &indigoxrpc.Client{
36
+
Host: host,
37
+
}
38
+
39
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
40
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
41
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
42
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
43
+
rp.pages.Error503(w)
44
+
return
45
+
}
46
+
47
+
var branchResult types.RepoBranchesResponse
48
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
49
+
l.Error("failed to decode XRPC branches response", "err", err)
50
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
51
+
return
52
+
}
53
+
branches := branchResult.Branches
54
+
55
+
sortBranches(branches)
56
+
57
+
var defaultBranch string
58
+
for _, b := range branches {
59
+
if b.IsDefault {
60
+
defaultBranch = b.Name
61
+
}
62
+
}
63
+
64
+
base := defaultBranch
65
+
head := defaultBranch
66
+
67
+
params := r.URL.Query()
68
+
queryBase := params.Get("base")
69
+
queryHead := params.Get("head")
70
+
if queryBase != "" {
71
+
base = queryBase
72
+
}
73
+
if queryHead != "" {
74
+
head = queryHead
75
+
}
76
+
77
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
78
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
79
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
80
+
rp.pages.Error503(w)
81
+
return
82
+
}
83
+
84
+
var tags types.RepoTagsResponse
85
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
86
+
l.Error("failed to decode XRPC tags response", "err", err)
87
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
88
+
return
89
+
}
90
+
91
+
repoinfo := f.RepoInfo(user)
92
+
93
+
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
94
+
LoggedInUser: user,
95
+
RepoInfo: repoinfo,
96
+
Branches: branches,
97
+
Tags: tags.Tags,
98
+
Base: base,
99
+
Head: head,
100
+
})
101
+
}
102
+
103
+
func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) {
104
+
l := rp.logger.With("handler", "RepoCompare")
105
+
106
+
user := rp.oauth.GetUser(r)
107
+
f, err := rp.repoResolver.Resolve(r)
108
+
if err != nil {
109
+
l.Error("failed to get repo and knot", "err", err)
110
+
return
111
+
}
112
+
113
+
var diffOpts types.DiffOpts
114
+
if d := r.URL.Query().Get("diff"); d == "split" {
115
+
diffOpts.Split = true
116
+
}
117
+
118
+
// if user is navigating to one of
119
+
// /compare/{base}...{head}
120
+
// /compare/{base}/{head}
121
+
var base, head string
122
+
rest := chi.URLParam(r, "*")
123
+
124
+
var parts []string
125
+
if strings.Contains(rest, "...") {
126
+
parts = strings.SplitN(rest, "...", 2)
127
+
} else if strings.Contains(rest, "/") {
128
+
parts = strings.SplitN(rest, "/", 2)
129
+
}
130
+
131
+
if len(parts) == 2 {
132
+
base = parts[0]
133
+
head = parts[1]
134
+
}
135
+
136
+
base, _ = url.PathUnescape(base)
137
+
head, _ = url.PathUnescape(head)
138
+
139
+
if base == "" || head == "" {
140
+
l.Error("invalid comparison")
141
+
rp.pages.Error404(w)
142
+
return
143
+
}
144
+
145
+
scheme := "http"
146
+
if !rp.config.Core.Dev {
147
+
scheme = "https"
148
+
}
149
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
150
+
xrpcc := &indigoxrpc.Client{
151
+
Host: host,
152
+
}
153
+
154
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
155
+
156
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
157
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
158
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
159
+
rp.pages.Error503(w)
160
+
return
161
+
}
162
+
163
+
var branches types.RepoBranchesResponse
164
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
165
+
l.Error("failed to decode XRPC branches response", "err", err)
166
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
167
+
return
168
+
}
169
+
170
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
171
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
172
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
173
+
rp.pages.Error503(w)
174
+
return
175
+
}
176
+
177
+
var tags types.RepoTagsResponse
178
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
179
+
l.Error("failed to decode XRPC tags response", "err", err)
180
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
181
+
return
182
+
}
183
+
184
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
185
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
186
+
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
187
+
rp.pages.Error503(w)
188
+
return
189
+
}
190
+
191
+
var formatPatch types.RepoFormatPatchResponse
192
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
193
+
l.Error("failed to decode XRPC compare response", "err", err)
194
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
195
+
return
196
+
}
197
+
198
+
var diff types.NiceDiff
199
+
if formatPatch.CombinedPatchRaw != "" {
200
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
201
+
} else {
202
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
203
+
}
204
+
205
+
repoinfo := f.RepoInfo(user)
206
+
207
+
rp.pages.RepoCompare(w, pages.RepoCompareParams{
208
+
LoggedInUser: user,
209
+
RepoInfo: repoinfo,
210
+
Branches: branches.Branches,
211
+
Tags: tags.Tags,
212
+
Base: base,
213
+
Head: head,
214
+
Diff: &diff,
215
+
DiffOpts: diffOpts,
216
+
})
217
+
218
+
}
+1
-1
appview/repo/feed.go
+1
-1
appview/repo/feed.go
+5
-6
appview/repo/index.go
+5
-6
appview/repo/index.go
···
30
"github.com/go-enry/go-enry/v2"
31
)
32
33
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
34
l := rp.logger.With("handler", "RepoIndex")
35
36
ref := chi.URLParam(r, "ref")
···
351
if treeResp != nil && treeResp.Files != nil {
352
for _, file := range treeResp.Files {
353
niceFile := types.NiceTree{
354
-
IsFile: file.Is_file,
355
-
IsSubtree: file.Is_subtree,
356
-
Name: file.Name,
357
-
Mode: file.Mode,
358
-
Size: file.Size,
359
}
360
if file.Last_commit != nil {
361
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
362
niceFile.LastCommit = &types.LastCommitInfo{
···
30
"github.com/go-enry/go-enry/v2"
31
)
32
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
l := rp.logger.With("handler", "RepoIndex")
35
36
ref := chi.URLParam(r, "ref")
···
351
if treeResp != nil && treeResp.Files != nil {
352
for _, file := range treeResp.Files {
353
niceFile := types.NiceTree{
354
+
Name: file.Name,
355
+
Mode: file.Mode,
356
+
Size: file.Size,
357
}
358
+
359
if file.Last_commit != nil {
360
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
361
niceFile.LastCommit = &types.LastCommitInfo{
+223
appview/repo/log.go
+223
appview/repo/log.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
"strconv"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/commitverify"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/types"
17
+
18
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
+
"github.com/go-chi/chi/v5"
20
+
"github.com/go-git/go-git/v5/plumbing"
21
+
)
22
+
23
+
func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) {
24
+
l := rp.logger.With("handler", "RepoLog")
25
+
26
+
f, err := rp.repoResolver.Resolve(r)
27
+
if err != nil {
28
+
l.Error("failed to fully resolve repo", "err", err)
29
+
return
30
+
}
31
+
32
+
page := 1
33
+
if r.URL.Query().Get("page") != "" {
34
+
page, err = strconv.Atoi(r.URL.Query().Get("page"))
35
+
if err != nil {
36
+
page = 1
37
+
}
38
+
}
39
+
40
+
ref := chi.URLParam(r, "ref")
41
+
ref, _ = url.PathUnescape(ref)
42
+
43
+
scheme := "http"
44
+
if !rp.config.Core.Dev {
45
+
scheme = "https"
46
+
}
47
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
48
+
xrpcc := &indigoxrpc.Client{
49
+
Host: host,
50
+
}
51
+
52
+
limit := int64(60)
53
+
cursor := ""
54
+
if page > 1 {
55
+
// Convert page number to cursor (offset)
56
+
offset := (page - 1) * int(limit)
57
+
cursor = strconv.Itoa(offset)
58
+
}
59
+
60
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
61
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
62
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
63
+
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
64
+
rp.pages.Error503(w)
65
+
return
66
+
}
67
+
68
+
var xrpcResp types.RepoLogResponse
69
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
70
+
l.Error("failed to decode XRPC response", "err", err)
71
+
rp.pages.Error503(w)
72
+
return
73
+
}
74
+
75
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
76
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
77
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
78
+
rp.pages.Error503(w)
79
+
return
80
+
}
81
+
82
+
tagMap := make(map[string][]string)
83
+
if tagBytes != nil {
84
+
var tagResp types.RepoTagsResponse
85
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
86
+
for _, tag := range tagResp.Tags {
87
+
hash := tag.Hash
88
+
if tag.Tag != nil {
89
+
hash = tag.Tag.Target.String()
90
+
}
91
+
tagMap[hash] = append(tagMap[hash], tag.Name)
92
+
}
93
+
}
94
+
}
95
+
96
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
97
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
98
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
99
+
rp.pages.Error503(w)
100
+
return
101
+
}
102
+
103
+
if branchBytes != nil {
104
+
var branchResp types.RepoBranchesResponse
105
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
106
+
for _, branch := range branchResp.Branches {
107
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
108
+
}
109
+
}
110
+
}
111
+
112
+
user := rp.oauth.GetUser(r)
113
+
114
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
115
+
if err != nil {
116
+
l.Error("failed to fetch email to did mapping", "err", err)
117
+
}
118
+
119
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
+
if err != nil {
121
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
+
}
123
+
124
+
repoInfo := f.RepoInfo(user)
125
+
126
+
var shas []string
127
+
for _, c := range xrpcResp.Commits {
128
+
shas = append(shas, c.Hash.String())
129
+
}
130
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
131
+
if err != nil {
132
+
l.Error("failed to getPipelineStatuses", "err", err)
133
+
// non-fatal
134
+
}
135
+
136
+
rp.pages.RepoLog(w, pages.RepoLogParams{
137
+
LoggedInUser: user,
138
+
TagMap: tagMap,
139
+
RepoInfo: repoInfo,
140
+
RepoLogResponse: xrpcResp,
141
+
EmailToDid: emailToDidMap,
142
+
VerifiedCommits: vc,
143
+
Pipelines: pipelines,
144
+
})
145
+
}
146
+
147
+
func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) {
148
+
l := rp.logger.With("handler", "RepoCommit")
149
+
150
+
f, err := rp.repoResolver.Resolve(r)
151
+
if err != nil {
152
+
l.Error("failed to fully resolve repo", "err", err)
153
+
return
154
+
}
155
+
ref := chi.URLParam(r, "ref")
156
+
ref, _ = url.PathUnescape(ref)
157
+
158
+
var diffOpts types.DiffOpts
159
+
if d := r.URL.Query().Get("diff"); d == "split" {
160
+
diffOpts.Split = true
161
+
}
162
+
163
+
if !plumbing.IsHash(ref) {
164
+
rp.pages.Error404(w)
165
+
return
166
+
}
167
+
168
+
scheme := "http"
169
+
if !rp.config.Core.Dev {
170
+
scheme = "https"
171
+
}
172
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
173
+
xrpcc := &indigoxrpc.Client{
174
+
Host: host,
175
+
}
176
+
177
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
179
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
180
+
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
181
+
rp.pages.Error503(w)
182
+
return
183
+
}
184
+
185
+
var result types.RepoCommitResponse
186
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
187
+
l.Error("failed to decode XRPC response", "err", err)
188
+
rp.pages.Error503(w)
189
+
return
190
+
}
191
+
192
+
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
193
+
if err != nil {
194
+
l.Error("failed to get email to did mapping", "err", err)
195
+
}
196
+
197
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
198
+
if err != nil {
199
+
l.Error("failed to GetVerifiedCommits", "err", err)
200
+
}
201
+
202
+
user := rp.oauth.GetUser(r)
203
+
repoInfo := f.RepoInfo(user)
204
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
205
+
if err != nil {
206
+
l.Error("failed to getPipelineStatuses", "err", err)
207
+
// non-fatal
208
+
}
209
+
var pipeline *models.Pipeline
210
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
211
+
pipeline = &p
212
+
}
213
+
214
+
rp.pages.RepoCommit(w, pages.RepoCommitParams{
215
+
LoggedInUser: user,
216
+
RepoInfo: f.RepoInfo(user),
217
+
RepoCommitResponse: result,
218
+
EmailToDid: emailToDidMap,
219
+
VerifiedCommit: vc,
220
+
Pipeline: pipeline,
221
+
DiffOpts: diffOpts,
222
+
})
223
+
}
+5
-5
appview/repo/opengraph.go
+5
-5
appview/repo/opengraph.go
···
158
// Draw star icon, count, and label
159
// Align icon baseline with text baseline
160
iconBaselineOffset := int(textSize) / 2
161
-
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
if err != nil {
163
log.Printf("failed to draw star icon: %v", err)
164
}
···
185
186
// Draw issues icon, count, and label
187
issueStartX := currentX
188
-
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
if err != nil {
190
log.Printf("failed to draw circle-dot icon: %v", err)
191
}
···
210
211
// Draw pull request icon, count, and label
212
prStartX := currentX
213
-
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
if err != nil {
215
log.Printf("failed to draw git-pull-request icon: %v", err)
216
}
···
236
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
240
if err != nil {
241
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
}
···
327
return nil
328
}
329
330
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
331
f, err := rp.repoResolver.Resolve(r)
332
if err != nil {
333
log.Println("failed to get repo and knot", err)
···
158
// Draw star icon, count, and label
159
// Align icon baseline with text baseline
160
iconBaselineOffset := int(textSize) / 2
161
+
err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
if err != nil {
163
log.Printf("failed to draw star icon: %v", err)
164
}
···
185
186
// Draw issues icon, count, and label
187
issueStartX := currentX
188
+
err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
if err != nil {
190
log.Printf("failed to draw circle-dot icon: %v", err)
191
}
···
210
211
// Draw pull request icon, count, and label
212
prStartX := currentX
213
+
err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
if err != nil {
215
log.Printf("failed to draw git-pull-request icon: %v", err)
216
}
···
236
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
240
if err != nil {
241
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
}
···
327
return nil
328
}
329
330
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
f, err := rp.repoResolver.Resolve(r)
332
if err != nil {
333
log.Println("failed to get repo and knot", err)
+4
-1378
appview/repo/repo.go
+4
-1378
appview/repo/repo.go
···
3
import (
4
"context"
5
"database/sql"
6
-
"encoding/json"
7
"errors"
8
"fmt"
9
-
"io"
10
"log/slog"
11
"net/http"
12
"net/url"
13
-
"path/filepath"
14
"slices"
15
-
"strconv"
16
"strings"
17
"time"
18
19
"tangled.org/core/api/tangled"
20
-
"tangled.org/core/appview/commitverify"
21
"tangled.org/core/appview/config"
22
"tangled.org/core/appview/db"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
"tangled.org/core/appview/reporesolver"
29
"tangled.org/core/appview/validator"
30
xrpcclient "tangled.org/core/appview/xrpcclient"
31
"tangled.org/core/eventconsumer"
32
"tangled.org/core/idresolver"
33
-
"tangled.org/core/patchutil"
34
"tangled.org/core/rbac"
35
"tangled.org/core/tid"
36
-
"tangled.org/core/types"
37
"tangled.org/core/xrpc/serviceauth"
38
39
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
"github.com/bluesky-social/indigo/atproto/syntax"
42
lexutil "github.com/bluesky-social/indigo/lex/util"
43
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44
securejoin "github.com/cyphar/filepath-securejoin"
45
"github.com/go-chi/chi/v5"
46
-
"github.com/go-git/go-git/v5/plumbing"
47
)
48
49
type Repo struct {
···
88
}
89
}
90
91
-
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
92
-
l := rp.logger.With("handler", "DownloadArchive")
93
-
94
-
ref := chi.URLParam(r, "ref")
95
-
ref, _ = url.PathUnescape(ref)
96
-
97
-
f, err := rp.repoResolver.Resolve(r)
98
-
if err != nil {
99
-
l.Error("failed to get repo and knot", "err", err)
100
-
return
101
-
}
102
-
103
-
scheme := "http"
104
-
if !rp.config.Core.Dev {
105
-
scheme = "https"
106
-
}
107
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
108
-
xrpcc := &indigoxrpc.Client{
109
-
Host: host,
110
-
}
111
-
112
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
113
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
114
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
115
-
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
116
-
rp.pages.Error503(w)
117
-
return
118
-
}
119
-
120
-
// Set headers for file download, just pass along whatever the knot specifies
121
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
122
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
123
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
124
-
w.Header().Set("Content-Type", "application/gzip")
125
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
126
-
127
-
// Write the archive data directly
128
-
w.Write(archiveBytes)
129
-
}
130
-
131
-
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
132
-
l := rp.logger.With("handler", "RepoLog")
133
-
134
-
f, err := rp.repoResolver.Resolve(r)
135
-
if err != nil {
136
-
l.Error("failed to fully resolve repo", "err", err)
137
-
return
138
-
}
139
-
140
-
page := 1
141
-
if r.URL.Query().Get("page") != "" {
142
-
page, err = strconv.Atoi(r.URL.Query().Get("page"))
143
-
if err != nil {
144
-
page = 1
145
-
}
146
-
}
147
-
148
-
ref := chi.URLParam(r, "ref")
149
-
ref, _ = url.PathUnescape(ref)
150
-
151
-
scheme := "http"
152
-
if !rp.config.Core.Dev {
153
-
scheme = "https"
154
-
}
155
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
156
-
xrpcc := &indigoxrpc.Client{
157
-
Host: host,
158
-
}
159
-
160
-
limit := int64(60)
161
-
cursor := ""
162
-
if page > 1 {
163
-
// Convert page number to cursor (offset)
164
-
offset := (page - 1) * int(limit)
165
-
cursor = strconv.Itoa(offset)
166
-
}
167
-
168
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
169
-
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
170
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
171
-
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
172
-
rp.pages.Error503(w)
173
-
return
174
-
}
175
-
176
-
var xrpcResp types.RepoLogResponse
177
-
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
178
-
l.Error("failed to decode XRPC response", "err", err)
179
-
rp.pages.Error503(w)
180
-
return
181
-
}
182
-
183
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
184
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
185
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
186
-
rp.pages.Error503(w)
187
-
return
188
-
}
189
-
190
-
tagMap := make(map[string][]string)
191
-
if tagBytes != nil {
192
-
var tagResp types.RepoTagsResponse
193
-
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
194
-
for _, tag := range tagResp.Tags {
195
-
hash := tag.Hash
196
-
if tag.Tag != nil {
197
-
hash = tag.Tag.Target.String()
198
-
}
199
-
tagMap[hash] = append(tagMap[hash], tag.Name)
200
-
}
201
-
}
202
-
}
203
-
204
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
205
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
206
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
207
-
rp.pages.Error503(w)
208
-
return
209
-
}
210
-
211
-
if branchBytes != nil {
212
-
var branchResp types.RepoBranchesResponse
213
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
214
-
for _, branch := range branchResp.Branches {
215
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
216
-
}
217
-
}
218
-
}
219
-
220
-
user := rp.oauth.GetUser(r)
221
-
222
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
223
-
if err != nil {
224
-
l.Error("failed to fetch email to did mapping", "err", err)
225
-
}
226
-
227
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
228
-
if err != nil {
229
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
230
-
}
231
-
232
-
repoInfo := f.RepoInfo(user)
233
-
234
-
var shas []string
235
-
for _, c := range xrpcResp.Commits {
236
-
shas = append(shas, c.Hash.String())
237
-
}
238
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
239
-
if err != nil {
240
-
l.Error("failed to getPipelineStatuses", "err", err)
241
-
// non-fatal
242
-
}
243
-
244
-
rp.pages.RepoLog(w, pages.RepoLogParams{
245
-
LoggedInUser: user,
246
-
TagMap: tagMap,
247
-
RepoInfo: repoInfo,
248
-
RepoLogResponse: xrpcResp,
249
-
EmailToDid: emailToDidMap,
250
-
VerifiedCommits: vc,
251
-
Pipelines: pipelines,
252
-
})
253
-
}
254
-
255
-
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
256
-
l := rp.logger.With("handler", "RepoDescriptionEdit")
257
-
258
-
f, err := rp.repoResolver.Resolve(r)
259
-
if err != nil {
260
-
l.Error("failed to get repo and knot", "err", err)
261
-
w.WriteHeader(http.StatusBadRequest)
262
-
return
263
-
}
264
-
265
-
user := rp.oauth.GetUser(r)
266
-
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
267
-
RepoInfo: f.RepoInfo(user),
268
-
})
269
-
}
270
-
271
-
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
272
-
l := rp.logger.With("handler", "RepoDescription")
273
-
274
-
f, err := rp.repoResolver.Resolve(r)
275
-
if err != nil {
276
-
l.Error("failed to get repo and knot", "err", err)
277
-
w.WriteHeader(http.StatusBadRequest)
278
-
return
279
-
}
280
-
281
-
repoAt := f.RepoAt()
282
-
rkey := repoAt.RecordKey().String()
283
-
if rkey == "" {
284
-
l.Error("invalid aturi for repo", "err", err)
285
-
w.WriteHeader(http.StatusInternalServerError)
286
-
return
287
-
}
288
-
289
-
user := rp.oauth.GetUser(r)
290
-
291
-
switch r.Method {
292
-
case http.MethodGet:
293
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
294
-
RepoInfo: f.RepoInfo(user),
295
-
})
296
-
return
297
-
case http.MethodPut:
298
-
newDescription := r.FormValue("description")
299
-
client, err := rp.oauth.AuthorizedClient(r)
300
-
if err != nil {
301
-
l.Error("failed to get client")
302
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
303
-
return
304
-
}
305
-
306
-
// optimistic update
307
-
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
308
-
if err != nil {
309
-
l.Error("failed to perform update-description query", "err", err)
310
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
311
-
return
312
-
}
313
-
314
-
newRepo := f.Repo
315
-
newRepo.Description = newDescription
316
-
record := newRepo.AsRecord()
317
-
318
-
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
319
-
//
320
-
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
321
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
322
-
if err != nil {
323
-
// failed to get record
324
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
325
-
return
326
-
}
327
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
328
-
Collection: tangled.RepoNSID,
329
-
Repo: newRepo.Did,
330
-
Rkey: newRepo.Rkey,
331
-
SwapRecord: ex.Cid,
332
-
Record: &lexutil.LexiconTypeDecoder{
333
-
Val: &record,
334
-
},
335
-
})
336
-
337
-
if err != nil {
338
-
l.Error("failed to perferom update-description query", "err", err)
339
-
// failed to get record
340
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
341
-
return
342
-
}
343
-
344
-
newRepoInfo := f.RepoInfo(user)
345
-
newRepoInfo.Description = newDescription
346
-
347
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
348
-
RepoInfo: newRepoInfo,
349
-
})
350
-
return
351
-
}
352
-
}
353
-
354
-
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
355
-
l := rp.logger.With("handler", "RepoCommit")
356
-
357
-
f, err := rp.repoResolver.Resolve(r)
358
-
if err != nil {
359
-
l.Error("failed to fully resolve repo", "err", err)
360
-
return
361
-
}
362
-
ref := chi.URLParam(r, "ref")
363
-
ref, _ = url.PathUnescape(ref)
364
-
365
-
var diffOpts types.DiffOpts
366
-
if d := r.URL.Query().Get("diff"); d == "split" {
367
-
diffOpts.Split = true
368
-
}
369
-
370
-
if !plumbing.IsHash(ref) {
371
-
rp.pages.Error404(w)
372
-
return
373
-
}
374
-
375
-
scheme := "http"
376
-
if !rp.config.Core.Dev {
377
-
scheme = "https"
378
-
}
379
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
380
-
xrpcc := &indigoxrpc.Client{
381
-
Host: host,
382
-
}
383
-
384
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
385
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
386
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
387
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
388
-
rp.pages.Error503(w)
389
-
return
390
-
}
391
-
392
-
var result types.RepoCommitResponse
393
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
394
-
l.Error("failed to decode XRPC response", "err", err)
395
-
rp.pages.Error503(w)
396
-
return
397
-
}
398
-
399
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
400
-
if err != nil {
401
-
l.Error("failed to get email to did mapping", "err", err)
402
-
}
403
-
404
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
405
-
if err != nil {
406
-
l.Error("failed to GetVerifiedCommits", "err", err)
407
-
}
408
-
409
-
user := rp.oauth.GetUser(r)
410
-
repoInfo := f.RepoInfo(user)
411
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
412
-
if err != nil {
413
-
l.Error("failed to getPipelineStatuses", "err", err)
414
-
// non-fatal
415
-
}
416
-
var pipeline *models.Pipeline
417
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
418
-
pipeline = &p
419
-
}
420
-
421
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
422
-
LoggedInUser: user,
423
-
RepoInfo: f.RepoInfo(user),
424
-
RepoCommitResponse: result,
425
-
EmailToDid: emailToDidMap,
426
-
VerifiedCommit: vc,
427
-
Pipeline: pipeline,
428
-
DiffOpts: diffOpts,
429
-
})
430
-
}
431
-
432
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
433
-
l := rp.logger.With("handler", "RepoTree")
434
-
435
-
f, err := rp.repoResolver.Resolve(r)
436
-
if err != nil {
437
-
l.Error("failed to fully resolve repo", "err", err)
438
-
return
439
-
}
440
-
441
-
ref := chi.URLParam(r, "ref")
442
-
ref, _ = url.PathUnescape(ref)
443
-
444
-
// if the tree path has a trailing slash, let's strip it
445
-
// so we don't 404
446
-
treePath := chi.URLParam(r, "*")
447
-
treePath, _ = url.PathUnescape(treePath)
448
-
treePath = strings.TrimSuffix(treePath, "/")
449
-
450
-
scheme := "http"
451
-
if !rp.config.Core.Dev {
452
-
scheme = "https"
453
-
}
454
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
455
-
xrpcc := &indigoxrpc.Client{
456
-
Host: host,
457
-
}
458
-
459
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
460
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
461
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
462
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
463
-
rp.pages.Error503(w)
464
-
return
465
-
}
466
-
467
-
// Convert XRPC response to internal types.RepoTreeResponse
468
-
files := make([]types.NiceTree, len(xrpcResp.Files))
469
-
for i, xrpcFile := range xrpcResp.Files {
470
-
file := types.NiceTree{
471
-
Name: xrpcFile.Name,
472
-
Mode: xrpcFile.Mode,
473
-
Size: int64(xrpcFile.Size),
474
-
IsFile: xrpcFile.Is_file,
475
-
IsSubtree: xrpcFile.Is_subtree,
476
-
}
477
-
478
-
// Convert last commit info if present
479
-
if xrpcFile.Last_commit != nil {
480
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
481
-
file.LastCommit = &types.LastCommitInfo{
482
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
483
-
Message: xrpcFile.Last_commit.Message,
484
-
When: commitWhen,
485
-
}
486
-
}
487
-
488
-
files[i] = file
489
-
}
490
-
491
-
result := types.RepoTreeResponse{
492
-
Ref: xrpcResp.Ref,
493
-
Files: files,
494
-
}
495
-
496
-
if xrpcResp.Parent != nil {
497
-
result.Parent = *xrpcResp.Parent
498
-
}
499
-
if xrpcResp.Dotdot != nil {
500
-
result.DotDot = *xrpcResp.Dotdot
501
-
}
502
-
if xrpcResp.Readme != nil {
503
-
result.ReadmeFileName = xrpcResp.Readme.Filename
504
-
result.Readme = xrpcResp.Readme.Contents
505
-
}
506
-
507
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
508
-
// so we can safely redirect to the "parent" (which is the same file).
509
-
if len(result.Files) == 0 && result.Parent == treePath {
510
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
511
-
http.Redirect(w, r, redirectTo, http.StatusFound)
512
-
return
513
-
}
514
-
515
-
user := rp.oauth.GetUser(r)
516
-
517
-
var breadcrumbs [][]string
518
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
519
-
if treePath != "" {
520
-
for idx, elem := range strings.Split(treePath, "/") {
521
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
522
-
}
523
-
}
524
-
525
-
sortFiles(result.Files)
526
-
527
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
528
-
LoggedInUser: user,
529
-
BreadCrumbs: breadcrumbs,
530
-
TreePath: treePath,
531
-
RepoInfo: f.RepoInfo(user),
532
-
RepoTreeResponse: result,
533
-
})
534
-
}
535
-
536
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
537
-
l := rp.logger.With("handler", "RepoTags")
538
-
539
-
f, err := rp.repoResolver.Resolve(r)
540
-
if err != nil {
541
-
l.Error("failed to get repo and knot", "err", err)
542
-
return
543
-
}
544
-
545
-
scheme := "http"
546
-
if !rp.config.Core.Dev {
547
-
scheme = "https"
548
-
}
549
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
550
-
xrpcc := &indigoxrpc.Client{
551
-
Host: host,
552
-
}
553
-
554
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
555
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
556
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
557
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
558
-
rp.pages.Error503(w)
559
-
return
560
-
}
561
-
562
-
var result types.RepoTagsResponse
563
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
564
-
l.Error("failed to decode XRPC response", "err", err)
565
-
rp.pages.Error503(w)
566
-
return
567
-
}
568
-
569
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
570
-
if err != nil {
571
-
l.Error("failed grab artifacts", "err", err)
572
-
return
573
-
}
574
-
575
-
// convert artifacts to map for easy UI building
576
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
577
-
for _, a := range artifacts {
578
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
579
-
}
580
-
581
-
var danglingArtifacts []models.Artifact
582
-
for _, a := range artifacts {
583
-
found := false
584
-
for _, t := range result.Tags {
585
-
if t.Tag != nil {
586
-
if t.Tag.Hash == a.Tag {
587
-
found = true
588
-
}
589
-
}
590
-
}
591
-
592
-
if !found {
593
-
danglingArtifacts = append(danglingArtifacts, a)
594
-
}
595
-
}
596
-
597
-
user := rp.oauth.GetUser(r)
598
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
599
-
LoggedInUser: user,
600
-
RepoInfo: f.RepoInfo(user),
601
-
RepoTagsResponse: result,
602
-
ArtifactMap: artifactMap,
603
-
DanglingArtifacts: danglingArtifacts,
604
-
})
605
-
}
606
-
607
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
608
-
l := rp.logger.With("handler", "RepoBranches")
609
-
610
-
f, err := rp.repoResolver.Resolve(r)
611
-
if err != nil {
612
-
l.Error("failed to get repo and knot", "err", err)
613
-
return
614
-
}
615
-
616
-
scheme := "http"
617
-
if !rp.config.Core.Dev {
618
-
scheme = "https"
619
-
}
620
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
621
-
xrpcc := &indigoxrpc.Client{
622
-
Host: host,
623
-
}
624
-
625
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
626
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
627
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
628
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
629
-
rp.pages.Error503(w)
630
-
return
631
-
}
632
-
633
-
var result types.RepoBranchesResponse
634
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
635
-
l.Error("failed to decode XRPC response", "err", err)
636
-
rp.pages.Error503(w)
637
-
return
638
-
}
639
-
640
-
sortBranches(result.Branches)
641
-
642
-
user := rp.oauth.GetUser(r)
643
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
644
-
LoggedInUser: user,
645
-
RepoInfo: f.RepoInfo(user),
646
-
RepoBranchesResponse: result,
647
-
})
648
-
}
649
-
650
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
651
-
l := rp.logger.With("handler", "DeleteBranch")
652
-
653
-
f, err := rp.repoResolver.Resolve(r)
654
-
if err != nil {
655
-
l.Error("failed to get repo and knot", "err", err)
656
-
return
657
-
}
658
-
659
-
noticeId := "delete-branch-error"
660
-
fail := func(msg string, err error) {
661
-
l.Error(msg, "err", err)
662
-
rp.pages.Notice(w, noticeId, msg)
663
-
}
664
-
665
-
branch := r.FormValue("branch")
666
-
if branch == "" {
667
-
fail("No branch provided.", nil)
668
-
return
669
-
}
670
-
671
-
client, err := rp.oauth.ServiceClient(
672
-
r,
673
-
oauth.WithService(f.Knot),
674
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
675
-
oauth.WithDev(rp.config.Core.Dev),
676
-
)
677
-
if err != nil {
678
-
fail("Failed to connect to knotserver", nil)
679
-
return
680
-
}
681
-
682
-
err = tangled.RepoDeleteBranch(
683
-
r.Context(),
684
-
client,
685
-
&tangled.RepoDeleteBranch_Input{
686
-
Branch: branch,
687
-
Repo: f.RepoAt().String(),
688
-
},
689
-
)
690
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
691
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
692
-
return
693
-
}
694
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
695
-
696
-
rp.pages.HxRefresh(w)
697
-
}
698
-
699
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
700
-
l := rp.logger.With("handler", "RepoBlob")
701
-
702
-
f, err := rp.repoResolver.Resolve(r)
703
-
if err != nil {
704
-
l.Error("failed to get repo and knot", "err", err)
705
-
return
706
-
}
707
-
708
-
ref := chi.URLParam(r, "ref")
709
-
ref, _ = url.PathUnescape(ref)
710
-
711
-
filePath := chi.URLParam(r, "*")
712
-
filePath, _ = url.PathUnescape(filePath)
713
-
714
-
scheme := "http"
715
-
if !rp.config.Core.Dev {
716
-
scheme = "https"
717
-
}
718
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
719
-
xrpcc := &indigoxrpc.Client{
720
-
Host: host,
721
-
}
722
-
723
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
724
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
725
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
726
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
727
-
rp.pages.Error503(w)
728
-
return
729
-
}
730
-
731
-
// Use XRPC response directly instead of converting to internal types
732
-
733
-
var breadcrumbs [][]string
734
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
735
-
if filePath != "" {
736
-
for idx, elem := range strings.Split(filePath, "/") {
737
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
738
-
}
739
-
}
740
-
741
-
showRendered := false
742
-
renderToggle := false
743
-
744
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
745
-
renderToggle = true
746
-
showRendered = r.URL.Query().Get("code") != "true"
747
-
}
748
-
749
-
var unsupported bool
750
-
var isImage bool
751
-
var isVideo bool
752
-
var contentSrc string
753
-
754
-
if resp.IsBinary != nil && *resp.IsBinary {
755
-
ext := strings.ToLower(filepath.Ext(resp.Path))
756
-
switch ext {
757
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
758
-
isImage = true
759
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
760
-
isVideo = true
761
-
default:
762
-
unsupported = true
763
-
}
764
-
765
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
766
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
767
-
768
-
baseURL := &url.URL{
769
-
Scheme: scheme,
770
-
Host: f.Knot,
771
-
Path: "/xrpc/sh.tangled.repo.blob",
772
-
}
773
-
query := baseURL.Query()
774
-
query.Set("repo", repoName)
775
-
query.Set("ref", ref)
776
-
query.Set("path", filePath)
777
-
query.Set("raw", "true")
778
-
baseURL.RawQuery = query.Encode()
779
-
blobURL := baseURL.String()
780
-
781
-
contentSrc = blobURL
782
-
if !rp.config.Core.Dev {
783
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
784
-
}
785
-
}
786
-
787
-
lines := 0
788
-
if resp.IsBinary == nil || !*resp.IsBinary {
789
-
lines = strings.Count(resp.Content, "\n") + 1
790
-
}
791
-
792
-
var sizeHint uint64
793
-
if resp.Size != nil {
794
-
sizeHint = uint64(*resp.Size)
795
-
} else {
796
-
sizeHint = uint64(len(resp.Content))
797
-
}
798
-
799
-
user := rp.oauth.GetUser(r)
800
-
801
-
// Determine if content is binary (dereference pointer)
802
-
isBinary := false
803
-
if resp.IsBinary != nil {
804
-
isBinary = *resp.IsBinary
805
-
}
806
-
807
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
808
-
LoggedInUser: user,
809
-
RepoInfo: f.RepoInfo(user),
810
-
BreadCrumbs: breadcrumbs,
811
-
ShowRendered: showRendered,
812
-
RenderToggle: renderToggle,
813
-
Unsupported: unsupported,
814
-
IsImage: isImage,
815
-
IsVideo: isVideo,
816
-
ContentSrc: contentSrc,
817
-
RepoBlob_Output: resp,
818
-
Contents: resp.Content,
819
-
Lines: lines,
820
-
SizeHint: sizeHint,
821
-
IsBinary: isBinary,
822
-
})
823
-
}
824
-
825
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
826
-
l := rp.logger.With("handler", "RepoBlobRaw")
827
-
828
-
f, err := rp.repoResolver.Resolve(r)
829
-
if err != nil {
830
-
l.Error("failed to get repo and knot", "err", err)
831
-
w.WriteHeader(http.StatusBadRequest)
832
-
return
833
-
}
834
-
835
-
ref := chi.URLParam(r, "ref")
836
-
ref, _ = url.PathUnescape(ref)
837
-
838
-
filePath := chi.URLParam(r, "*")
839
-
filePath, _ = url.PathUnescape(filePath)
840
-
841
-
scheme := "http"
842
-
if !rp.config.Core.Dev {
843
-
scheme = "https"
844
-
}
845
-
846
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
847
-
baseURL := &url.URL{
848
-
Scheme: scheme,
849
-
Host: f.Knot,
850
-
Path: "/xrpc/sh.tangled.repo.blob",
851
-
}
852
-
query := baseURL.Query()
853
-
query.Set("repo", repo)
854
-
query.Set("ref", ref)
855
-
query.Set("path", filePath)
856
-
query.Set("raw", "true")
857
-
baseURL.RawQuery = query.Encode()
858
-
blobURL := baseURL.String()
859
-
860
-
req, err := http.NewRequest("GET", blobURL, nil)
861
-
if err != nil {
862
-
l.Error("failed to create request", "err", err)
863
-
return
864
-
}
865
-
866
-
// forward the If-None-Match header
867
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
868
-
req.Header.Set("If-None-Match", clientETag)
869
-
}
870
-
871
-
client := &http.Client{}
872
-
resp, err := client.Do(req)
873
-
if err != nil {
874
-
l.Error("failed to reach knotserver", "err", err)
875
-
rp.pages.Error503(w)
876
-
return
877
-
}
878
-
defer resp.Body.Close()
879
-
880
-
// forward 304 not modified
881
-
if resp.StatusCode == http.StatusNotModified {
882
-
w.WriteHeader(http.StatusNotModified)
883
-
return
884
-
}
885
-
886
-
if resp.StatusCode != http.StatusOK {
887
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
888
-
w.WriteHeader(resp.StatusCode)
889
-
_, _ = io.Copy(w, resp.Body)
890
-
return
891
-
}
892
-
893
-
contentType := resp.Header.Get("Content-Type")
894
-
body, err := io.ReadAll(resp.Body)
895
-
if err != nil {
896
-
l.Error("error reading response body from knotserver", "err", err)
897
-
w.WriteHeader(http.StatusInternalServerError)
898
-
return
899
-
}
900
-
901
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
902
-
// serve all textual content as text/plain
903
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
904
-
w.Write(body)
905
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
906
-
// serve images and videos with their original content type
907
-
w.Header().Set("Content-Type", contentType)
908
-
w.Write(body)
909
-
} else {
910
-
w.WriteHeader(http.StatusUnsupportedMediaType)
911
-
w.Write([]byte("unsupported content type"))
912
-
return
913
-
}
914
-
}
915
-
916
-
// isTextualMimeType returns true if the MIME type represents textual content
917
-
// that should be served as text/plain
918
-
func isTextualMimeType(mimeType string) bool {
919
-
textualTypes := []string{
920
-
"application/json",
921
-
"application/xml",
922
-
"application/yaml",
923
-
"application/x-yaml",
924
-
"application/toml",
925
-
"application/javascript",
926
-
"application/ecmascript",
927
-
"message/",
928
-
}
929
-
930
-
return slices.Contains(textualTypes, mimeType)
931
-
}
932
-
933
// modify the spindle configured for this repo
934
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
935
user := rp.oauth.GetUser(r)
···
1785
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1786
}
1787
1788
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1789
-
l := rp.logger.With("handler", "SetDefaultBranch")
1790
-
1791
-
f, err := rp.repoResolver.Resolve(r)
1792
-
if err != nil {
1793
-
l.Error("failed to get repo and knot", "err", err)
1794
-
return
1795
-
}
1796
-
1797
-
noticeId := "operation-error"
1798
-
branch := r.FormValue("branch")
1799
-
if branch == "" {
1800
-
http.Error(w, "malformed form", http.StatusBadRequest)
1801
-
return
1802
-
}
1803
-
1804
-
client, err := rp.oauth.ServiceClient(
1805
-
r,
1806
-
oauth.WithService(f.Knot),
1807
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1808
-
oauth.WithDev(rp.config.Core.Dev),
1809
-
)
1810
-
if err != nil {
1811
-
l.Error("failed to connect to knot server", "err", err)
1812
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1813
-
return
1814
-
}
1815
-
1816
-
xe := tangled.RepoSetDefaultBranch(
1817
-
r.Context(),
1818
-
client,
1819
-
&tangled.RepoSetDefaultBranch_Input{
1820
-
Repo: f.RepoAt().String(),
1821
-
DefaultBranch: branch,
1822
-
},
1823
-
)
1824
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1825
-
l.Error("xrpc failed", "err", xe)
1826
-
rp.pages.Notice(w, noticeId, err.Error())
1827
-
return
1828
-
}
1829
-
1830
-
rp.pages.HxRefresh(w)
1831
-
}
1832
-
1833
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1834
-
user := rp.oauth.GetUser(r)
1835
-
l := rp.logger.With("handler", "Secrets")
1836
-
l = l.With("did", user.Did)
1837
-
1838
-
f, err := rp.repoResolver.Resolve(r)
1839
-
if err != nil {
1840
-
l.Error("failed to get repo and knot", "err", err)
1841
-
return
1842
-
}
1843
-
1844
-
if f.Spindle == "" {
1845
-
l.Error("empty spindle cannot add/rm secret", "err", err)
1846
-
return
1847
-
}
1848
-
1849
-
lxm := tangled.RepoAddSecretNSID
1850
-
if r.Method == http.MethodDelete {
1851
-
lxm = tangled.RepoRemoveSecretNSID
1852
-
}
1853
-
1854
-
spindleClient, err := rp.oauth.ServiceClient(
1855
-
r,
1856
-
oauth.WithService(f.Spindle),
1857
-
oauth.WithLxm(lxm),
1858
-
oauth.WithExp(60),
1859
-
oauth.WithDev(rp.config.Core.Dev),
1860
-
)
1861
-
if err != nil {
1862
-
l.Error("failed to create spindle client", "err", err)
1863
-
return
1864
-
}
1865
-
1866
-
key := r.FormValue("key")
1867
-
if key == "" {
1868
-
w.WriteHeader(http.StatusBadRequest)
1869
-
return
1870
-
}
1871
-
1872
-
switch r.Method {
1873
-
case http.MethodPut:
1874
-
errorId := "add-secret-error"
1875
-
1876
-
value := r.FormValue("value")
1877
-
if value == "" {
1878
-
w.WriteHeader(http.StatusBadRequest)
1879
-
return
1880
-
}
1881
-
1882
-
err = tangled.RepoAddSecret(
1883
-
r.Context(),
1884
-
spindleClient,
1885
-
&tangled.RepoAddSecret_Input{
1886
-
Repo: f.RepoAt().String(),
1887
-
Key: key,
1888
-
Value: value,
1889
-
},
1890
-
)
1891
-
if err != nil {
1892
-
l.Error("Failed to add secret.", "err", err)
1893
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
1894
-
return
1895
-
}
1896
-
1897
-
case http.MethodDelete:
1898
-
errorId := "operation-error"
1899
-
1900
-
err = tangled.RepoRemoveSecret(
1901
-
r.Context(),
1902
-
spindleClient,
1903
-
&tangled.RepoRemoveSecret_Input{
1904
-
Repo: f.RepoAt().String(),
1905
-
Key: key,
1906
-
},
1907
-
)
1908
-
if err != nil {
1909
-
l.Error("Failed to delete secret.", "err", err)
1910
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1911
-
return
1912
-
}
1913
-
}
1914
-
1915
-
rp.pages.HxRefresh(w)
1916
-
}
1917
-
1918
-
type tab = map[string]any
1919
-
1920
-
var (
1921
-
// would be great to have ordered maps right about now
1922
-
settingsTabs []tab = []tab{
1923
-
{"Name": "general", "Icon": "sliders-horizontal"},
1924
-
{"Name": "access", "Icon": "users"},
1925
-
{"Name": "pipelines", "Icon": "layers-2"},
1926
-
}
1927
-
)
1928
-
1929
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1930
-
tabVal := r.URL.Query().Get("tab")
1931
-
if tabVal == "" {
1932
-
tabVal = "general"
1933
-
}
1934
-
1935
-
switch tabVal {
1936
-
case "general":
1937
-
rp.generalSettings(w, r)
1938
-
1939
-
case "access":
1940
-
rp.accessSettings(w, r)
1941
-
1942
-
case "pipelines":
1943
-
rp.pipelineSettings(w, r)
1944
-
}
1945
-
}
1946
-
1947
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1948
-
l := rp.logger.With("handler", "generalSettings")
1949
-
1950
-
f, err := rp.repoResolver.Resolve(r)
1951
-
user := rp.oauth.GetUser(r)
1952
-
1953
-
scheme := "http"
1954
-
if !rp.config.Core.Dev {
1955
-
scheme = "https"
1956
-
}
1957
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1958
-
xrpcc := &indigoxrpc.Client{
1959
-
Host: host,
1960
-
}
1961
-
1962
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1963
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1964
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1965
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1966
-
rp.pages.Error503(w)
1967
-
return
1968
-
}
1969
-
1970
-
var result types.RepoBranchesResponse
1971
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1972
-
l.Error("failed to decode XRPC response", "err", err)
1973
-
rp.pages.Error503(w)
1974
-
return
1975
-
}
1976
-
1977
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1978
-
if err != nil {
1979
-
l.Error("failed to fetch labels", "err", err)
1980
-
rp.pages.Error503(w)
1981
-
return
1982
-
}
1983
-
1984
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1985
-
if err != nil {
1986
-
l.Error("failed to fetch labels", "err", err)
1987
-
rp.pages.Error503(w)
1988
-
return
1989
-
}
1990
-
// remove default labels from the labels list, if present
1991
-
defaultLabelMap := make(map[string]bool)
1992
-
for _, dl := range defaultLabels {
1993
-
defaultLabelMap[dl.AtUri().String()] = true
1994
-
}
1995
-
n := 0
1996
-
for _, l := range labels {
1997
-
if !defaultLabelMap[l.AtUri().String()] {
1998
-
labels[n] = l
1999
-
n++
2000
-
}
2001
-
}
2002
-
labels = labels[:n]
2003
-
2004
-
subscribedLabels := make(map[string]struct{})
2005
-
for _, l := range f.Repo.Labels {
2006
-
subscribedLabels[l] = struct{}{}
2007
-
}
2008
-
2009
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
2010
-
// if all default labels are subbed, show the "unsubscribe all" button
2011
-
shouldSubscribeAll := false
2012
-
for _, dl := range defaultLabels {
2013
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
2014
-
// one of the default labels is not subscribed to
2015
-
shouldSubscribeAll = true
2016
-
break
2017
-
}
2018
-
}
2019
-
2020
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
2021
-
LoggedInUser: user,
2022
-
RepoInfo: f.RepoInfo(user),
2023
-
Branches: result.Branches,
2024
-
Labels: labels,
2025
-
DefaultLabels: defaultLabels,
2026
-
SubscribedLabels: subscribedLabels,
2027
-
ShouldSubscribeAll: shouldSubscribeAll,
2028
-
Tabs: settingsTabs,
2029
-
Tab: "general",
2030
-
})
2031
-
}
2032
-
2033
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2034
-
l := rp.logger.With("handler", "accessSettings")
2035
-
2036
-
f, err := rp.repoResolver.Resolve(r)
2037
-
user := rp.oauth.GetUser(r)
2038
-
2039
-
repoCollaborators, err := f.Collaborators(r.Context())
2040
-
if err != nil {
2041
-
l.Error("failed to get collaborators", "err", err)
2042
-
}
2043
-
2044
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
2045
-
LoggedInUser: user,
2046
-
RepoInfo: f.RepoInfo(user),
2047
-
Tabs: settingsTabs,
2048
-
Tab: "access",
2049
-
Collaborators: repoCollaborators,
2050
-
})
2051
-
}
2052
-
2053
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2054
-
l := rp.logger.With("handler", "pipelineSettings")
2055
-
2056
-
f, err := rp.repoResolver.Resolve(r)
2057
-
user := rp.oauth.GetUser(r)
2058
-
2059
-
// all spindles that the repo owner is a member of
2060
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2061
-
if err != nil {
2062
-
l.Error("failed to fetch spindles", "err", err)
2063
-
return
2064
-
}
2065
-
2066
-
var secrets []*tangled.RepoListSecrets_Secret
2067
-
if f.Spindle != "" {
2068
-
if spindleClient, err := rp.oauth.ServiceClient(
2069
-
r,
2070
-
oauth.WithService(f.Spindle),
2071
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
2072
-
oauth.WithExp(60),
2073
-
oauth.WithDev(rp.config.Core.Dev),
2074
-
); err != nil {
2075
-
l.Error("failed to create spindle client", "err", err)
2076
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2077
-
l.Error("failed to fetch secrets", "err", err)
2078
-
} else {
2079
-
secrets = resp.Secrets
2080
-
}
2081
-
}
2082
-
2083
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2084
-
return strings.Compare(a.Key, b.Key)
2085
-
})
2086
-
2087
-
var dids []string
2088
-
for _, s := range secrets {
2089
-
dids = append(dids, s.CreatedBy)
2090
-
}
2091
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2092
-
2093
-
// convert to a more manageable form
2094
-
var niceSecret []map[string]any
2095
-
for id, s := range secrets {
2096
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2097
-
niceSecret = append(niceSecret, map[string]any{
2098
-
"Id": id,
2099
-
"Key": s.Key,
2100
-
"CreatedAt": when,
2101
-
"CreatedBy": resolvedIdents[id].Handle.String(),
2102
-
})
2103
-
}
2104
-
2105
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2106
-
LoggedInUser: user,
2107
-
RepoInfo: f.RepoInfo(user),
2108
-
Tabs: settingsTabs,
2109
-
Tab: "pipelines",
2110
-
Spindles: spindles,
2111
-
CurrentSpindle: f.Spindle,
2112
-
Secrets: niceSecret,
2113
-
})
2114
-
}
2115
-
2116
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2117
l := rp.logger.With("handler", "SyncRepoFork")
2118
···
2253
Source: sourceAt,
2254
Description: f.Repo.Description,
2255
Created: time.Now(),
2256
-
Labels: models.DefaultLabelDefs(),
2257
}
2258
record := repo.AsRecord()
2259
···
2310
}
2311
defer rollback()
2312
2313
client, err := rp.oauth.ServiceClient(
2314
r,
2315
oauth.WithService(targetKnot),
2316
oauth.WithLxm(tangled.RepoCreateNSID),
2317
oauth.WithDev(rp.config.Core.Dev),
2318
)
2319
if err != nil {
2320
l.Error("could not create service client", "err", err)
···
2369
aturi = ""
2370
2371
rp.notifier.NewRepo(r.Context(), repo)
2372
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
2373
}
2374
}
2375
···
2394
})
2395
return err
2396
}
2397
-
2398
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2399
-
l := rp.logger.With("handler", "RepoCompareNew")
2400
-
2401
-
user := rp.oauth.GetUser(r)
2402
-
f, err := rp.repoResolver.Resolve(r)
2403
-
if err != nil {
2404
-
l.Error("failed to get repo and knot", "err", err)
2405
-
return
2406
-
}
2407
-
2408
-
scheme := "http"
2409
-
if !rp.config.Core.Dev {
2410
-
scheme = "https"
2411
-
}
2412
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2413
-
xrpcc := &indigoxrpc.Client{
2414
-
Host: host,
2415
-
}
2416
-
2417
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2418
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2419
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2420
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2421
-
rp.pages.Error503(w)
2422
-
return
2423
-
}
2424
-
2425
-
var branchResult types.RepoBranchesResponse
2426
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2427
-
l.Error("failed to decode XRPC branches response", "err", err)
2428
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2429
-
return
2430
-
}
2431
-
branches := branchResult.Branches
2432
-
2433
-
sortBranches(branches)
2434
-
2435
-
var defaultBranch string
2436
-
for _, b := range branches {
2437
-
if b.IsDefault {
2438
-
defaultBranch = b.Name
2439
-
}
2440
-
}
2441
-
2442
-
base := defaultBranch
2443
-
head := defaultBranch
2444
-
2445
-
params := r.URL.Query()
2446
-
queryBase := params.Get("base")
2447
-
queryHead := params.Get("head")
2448
-
if queryBase != "" {
2449
-
base = queryBase
2450
-
}
2451
-
if queryHead != "" {
2452
-
head = queryHead
2453
-
}
2454
-
2455
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2456
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2457
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2458
-
rp.pages.Error503(w)
2459
-
return
2460
-
}
2461
-
2462
-
var tags types.RepoTagsResponse
2463
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2464
-
l.Error("failed to decode XRPC tags response", "err", err)
2465
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2466
-
return
2467
-
}
2468
-
2469
-
repoinfo := f.RepoInfo(user)
2470
-
2471
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2472
-
LoggedInUser: user,
2473
-
RepoInfo: repoinfo,
2474
-
Branches: branches,
2475
-
Tags: tags.Tags,
2476
-
Base: base,
2477
-
Head: head,
2478
-
})
2479
-
}
2480
-
2481
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2482
-
l := rp.logger.With("handler", "RepoCompare")
2483
-
2484
-
user := rp.oauth.GetUser(r)
2485
-
f, err := rp.repoResolver.Resolve(r)
2486
-
if err != nil {
2487
-
l.Error("failed to get repo and knot", "err", err)
2488
-
return
2489
-
}
2490
-
2491
-
var diffOpts types.DiffOpts
2492
-
if d := r.URL.Query().Get("diff"); d == "split" {
2493
-
diffOpts.Split = true
2494
-
}
2495
-
2496
-
// if user is navigating to one of
2497
-
// /compare/{base}/{head}
2498
-
// /compare/{base}...{head}
2499
-
base := chi.URLParam(r, "base")
2500
-
head := chi.URLParam(r, "head")
2501
-
if base == "" && head == "" {
2502
-
rest := chi.URLParam(r, "*") // master...feature/xyz
2503
-
parts := strings.SplitN(rest, "...", 2)
2504
-
if len(parts) == 2 {
2505
-
base = parts[0]
2506
-
head = parts[1]
2507
-
}
2508
-
}
2509
-
2510
-
base, _ = url.PathUnescape(base)
2511
-
head, _ = url.PathUnescape(head)
2512
-
2513
-
if base == "" || head == "" {
2514
-
l.Error("invalid comparison")
2515
-
rp.pages.Error404(w)
2516
-
return
2517
-
}
2518
-
2519
-
scheme := "http"
2520
-
if !rp.config.Core.Dev {
2521
-
scheme = "https"
2522
-
}
2523
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2524
-
xrpcc := &indigoxrpc.Client{
2525
-
Host: host,
2526
-
}
2527
-
2528
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2529
-
2530
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2531
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2532
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2533
-
rp.pages.Error503(w)
2534
-
return
2535
-
}
2536
-
2537
-
var branches types.RepoBranchesResponse
2538
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2539
-
l.Error("failed to decode XRPC branches response", "err", err)
2540
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2541
-
return
2542
-
}
2543
-
2544
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2545
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2546
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2547
-
rp.pages.Error503(w)
2548
-
return
2549
-
}
2550
-
2551
-
var tags types.RepoTagsResponse
2552
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2553
-
l.Error("failed to decode XRPC tags response", "err", err)
2554
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2555
-
return
2556
-
}
2557
-
2558
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2559
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2560
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2561
-
rp.pages.Error503(w)
2562
-
return
2563
-
}
2564
-
2565
-
var formatPatch types.RepoFormatPatchResponse
2566
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2567
-
l.Error("failed to decode XRPC compare response", "err", err)
2568
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2569
-
return
2570
-
}
2571
-
2572
-
var diff types.NiceDiff
2573
-
if formatPatch.CombinedPatchRaw != "" {
2574
-
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2575
-
} else {
2576
-
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2577
-
}
2578
-
2579
-
repoinfo := f.RepoInfo(user)
2580
-
2581
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2582
-
LoggedInUser: user,
2583
-
RepoInfo: repoinfo,
2584
-
Branches: branches.Branches,
2585
-
Tags: tags.Tags,
2586
-
Base: base,
2587
-
Head: head,
2588
-
Diff: &diff,
2589
-
DiffOpts: diffOpts,
2590
-
})
2591
-
2592
-
}
···
3
import (
4
"context"
5
"database/sql"
6
"errors"
7
"fmt"
8
"log/slog"
9
"net/http"
10
"net/url"
11
"slices"
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/reporesolver"
23
"tangled.org/core/appview/validator"
24
xrpcclient "tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/eventconsumer"
26
"tangled.org/core/idresolver"
27
"tangled.org/core/rbac"
28
"tangled.org/core/tid"
29
"tangled.org/core/xrpc/serviceauth"
30
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
32
atpclient "github.com/bluesky-social/indigo/atproto/client"
33
"github.com/bluesky-social/indigo/atproto/syntax"
34
lexutil "github.com/bluesky-social/indigo/lex/util"
35
securejoin "github.com/cyphar/filepath-securejoin"
36
"github.com/go-chi/chi/v5"
37
)
38
39
type Repo struct {
···
78
}
79
}
80
81
// modify the spindle configured for this repo
82
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
83
user := rp.oauth.GetUser(r)
···
933
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
934
}
935
936
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
937
l := rp.logger.With("handler", "SyncRepoFork")
938
···
1073
Source: sourceAt,
1074
Description: f.Repo.Description,
1075
Created: time.Now(),
1076
+
Labels: rp.config.Label.DefaultLabelDefs,
1077
}
1078
record := repo.AsRecord()
1079
···
1130
}
1131
defer rollback()
1132
1133
+
// TODO: this could coordinate better with the knot to recieve a clone status
1134
client, err := rp.oauth.ServiceClient(
1135
r,
1136
oauth.WithService(targetKnot),
1137
oauth.WithLxm(tangled.RepoCreateNSID),
1138
oauth.WithDev(rp.config.Core.Dev),
1139
+
oauth.WithTimeout(time.Second*20), // big repos take time to clone
1140
)
1141
if err != nil {
1142
l.Error("could not create service client", "err", err)
···
1191
aturi = ""
1192
1193
rp.notifier.NewRepo(r.Context(), repo)
1194
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
1195
}
1196
}
1197
···
1216
})
1217
return err
1218
}
+3
-16
appview/repo/repo_util.go
+3
-16
appview/repo/repo_util.go
···
1
package repo
2
3
import (
4
-
"crypto/rand"
5
-
"math/big"
6
"slices"
7
"sort"
8
"strings"
···
17
18
func sortFiles(files []types.NiceTree) {
19
sort.Slice(files, func(i, j int) bool {
20
-
iIsFile := files[i].IsFile
21
-
jIsFile := files[j].IsFile
22
if iIsFile != jIsFile {
23
return !iIsFile
24
}
···
90
return
91
}
92
93
-
func randomString(n int) string {
94
-
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
95
-
result := make([]byte, n)
96
-
97
-
for i := 0; i < n; i++ {
98
-
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
99
-
result[i] = letters[n.Int64()]
100
-
}
101
-
102
-
return string(result)
103
-
}
104
-
105
// grab pipelines from DB and munge that into a hashmap with commit sha as key
106
//
107
// golang is so blessed that it requires 35 lines of imperative code for this
···
118
119
ps, err := db.GetPipelineStatuses(
120
d,
121
db.FilterEq("repo_owner", repoInfo.OwnerDid),
122
db.FilterEq("repo_name", repoInfo.Name),
123
db.FilterEq("knot", repoInfo.Knot),
···
1
package repo
2
3
import (
4
"slices"
5
"sort"
6
"strings"
···
15
16
func sortFiles(files []types.NiceTree) {
17
sort.Slice(files, func(i, j int) bool {
18
+
iIsFile := files[i].IsFile()
19
+
jIsFile := files[j].IsFile()
20
if iIsFile != jIsFile {
21
return !iIsFile
22
}
···
88
return
89
}
90
91
// grab pipelines from DB and munge that into a hashmap with commit sha as key
92
//
93
// golang is so blessed that it requires 35 lines of imperative code for this
···
104
105
ps, err := db.GetPipelineStatuses(
106
d,
107
+
len(shas),
108
db.FilterEq("repo_owner", repoInfo.OwnerDid),
109
db.FilterEq("repo_name", repoInfo.Name),
110
db.FilterEq("knot", repoInfo.Knot),
+14
-20
appview/repo/router.go
+14
-20
appview/repo/router.go
···
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
r := chi.NewRouter()
12
-
r.Get("/", rp.RepoIndex)
13
-
r.Get("/opengraph", rp.RepoOpenGraphSummary)
14
-
r.Get("/feed.atom", rp.RepoAtomFeed)
15
-
r.Get("/commits/{ref}", rp.RepoLog)
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
-
r.Get("/", rp.RepoIndex)
18
-
r.Get("/*", rp.RepoTree)
19
})
20
-
r.Get("/commit/{ref}", rp.RepoCommit)
21
-
r.Get("/branches", rp.RepoBranches)
22
r.Delete("/branches", rp.DeleteBranch)
23
r.Route("/tags", func(r chi.Router) {
24
-
r.Get("/", rp.RepoTags)
25
r.Route("/{tag}", func(r chi.Router) {
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
···
37
})
38
})
39
})
40
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
43
// intentionally doesn't use /* as this isn't
···
54
})
55
56
r.Route("/compare", func(r chi.Router) {
57
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
58
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
// /compare/{ref1}...{ref2}
61
// for example:
62
// /compare/master...some/feature
63
// /compare/master...example.com:another/feature <- this is a fork
64
-
r.Get("/{base}/{head}", rp.RepoCompare)
65
-
r.Get("/*", rp.RepoCompare)
66
})
67
68
// label panel in issues/pulls/discussions/tasks
···
74
// settings routes, needs auth
75
r.Group(func(r chi.Router) {
76
r.Use(middleware.AuthMiddleware(rp.oauth))
77
-
// repo description can only be edited by owner
78
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) {
79
-
r.Put("/", rp.RepoDescription)
80
-
r.Get("/", rp.RepoDescription)
81
-
r.Get("/edit", rp.RepoDescriptionEdit)
82
-
})
83
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
84
-
r.Get("/", rp.RepoSettings)
85
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
86
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
87
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
···
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
r := chi.NewRouter()
12
+
r.Get("/", rp.Index)
13
+
r.Get("/opengraph", rp.Opengraph)
14
+
r.Get("/feed.atom", rp.AtomFeed)
15
+
r.Get("/commits/{ref}", rp.Log)
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
19
})
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
r.Delete("/branches", rp.DeleteBranch)
23
r.Route("/tags", func(r chi.Router) {
24
+
r.Get("/", rp.Tags)
25
r.Route("/{tag}", func(r chi.Router) {
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
···
37
})
38
})
39
})
40
+
r.Get("/blob/{ref}/*", rp.Blob)
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
43
// intentionally doesn't use /* as this isn't
···
54
})
55
56
r.Route("/compare", func(r chi.Router) {
57
+
r.Get("/", rp.CompareNew) // start an new comparison
58
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
// /compare/{ref1}...{ref2}
61
// for example:
62
// /compare/master...some/feature
63
// /compare/master...example.com:another/feature <- this is a fork
64
+
r.Get("/*", rp.Compare)
65
})
66
67
// label panel in issues/pulls/discussions/tasks
···
73
// settings routes, needs auth
74
r.Group(func(r chi.Router) {
75
r.Use(middleware.AuthMiddleware(rp.oauth))
76
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
77
+
r.Get("/", rp.Settings)
78
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
79
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+442
appview/repo/settings.go
+442
appview/repo/settings.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"slices"
8
+
"strings"
9
+
"time"
10
+
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/oauth"
14
+
"tangled.org/core/appview/pages"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/types"
17
+
18
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
19
+
lexutil "github.com/bluesky-social/indigo/lex/util"
20
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
21
+
)
22
+
23
+
type tab = map[string]any
24
+
25
+
var (
26
+
// would be great to have ordered maps right about now
27
+
settingsTabs []tab = []tab{
28
+
{"Name": "general", "Icon": "sliders-horizontal"},
29
+
{"Name": "access", "Icon": "users"},
30
+
{"Name": "pipelines", "Icon": "layers-2"},
31
+
}
32
+
)
33
+
34
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
35
+
l := rp.logger.With("handler", "SetDefaultBranch")
36
+
37
+
f, err := rp.repoResolver.Resolve(r)
38
+
if err != nil {
39
+
l.Error("failed to get repo and knot", "err", err)
40
+
return
41
+
}
42
+
43
+
noticeId := "operation-error"
44
+
branch := r.FormValue("branch")
45
+
if branch == "" {
46
+
http.Error(w, "malformed form", http.StatusBadRequest)
47
+
return
48
+
}
49
+
50
+
client, err := rp.oauth.ServiceClient(
51
+
r,
52
+
oauth.WithService(f.Knot),
53
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
54
+
oauth.WithDev(rp.config.Core.Dev),
55
+
)
56
+
if err != nil {
57
+
l.Error("failed to connect to knot server", "err", err)
58
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
59
+
return
60
+
}
61
+
62
+
xe := tangled.RepoSetDefaultBranch(
63
+
r.Context(),
64
+
client,
65
+
&tangled.RepoSetDefaultBranch_Input{
66
+
Repo: f.RepoAt().String(),
67
+
DefaultBranch: branch,
68
+
},
69
+
)
70
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
71
+
l.Error("xrpc failed", "err", xe)
72
+
rp.pages.Notice(w, noticeId, err.Error())
73
+
return
74
+
}
75
+
76
+
rp.pages.HxRefresh(w)
77
+
}
78
+
79
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
80
+
user := rp.oauth.GetUser(r)
81
+
l := rp.logger.With("handler", "Secrets")
82
+
l = l.With("did", user.Did)
83
+
84
+
f, err := rp.repoResolver.Resolve(r)
85
+
if err != nil {
86
+
l.Error("failed to get repo and knot", "err", err)
87
+
return
88
+
}
89
+
90
+
if f.Spindle == "" {
91
+
l.Error("empty spindle cannot add/rm secret", "err", err)
92
+
return
93
+
}
94
+
95
+
lxm := tangled.RepoAddSecretNSID
96
+
if r.Method == http.MethodDelete {
97
+
lxm = tangled.RepoRemoveSecretNSID
98
+
}
99
+
100
+
spindleClient, err := rp.oauth.ServiceClient(
101
+
r,
102
+
oauth.WithService(f.Spindle),
103
+
oauth.WithLxm(lxm),
104
+
oauth.WithExp(60),
105
+
oauth.WithDev(rp.config.Core.Dev),
106
+
)
107
+
if err != nil {
108
+
l.Error("failed to create spindle client", "err", err)
109
+
return
110
+
}
111
+
112
+
key := r.FormValue("key")
113
+
if key == "" {
114
+
w.WriteHeader(http.StatusBadRequest)
115
+
return
116
+
}
117
+
118
+
switch r.Method {
119
+
case http.MethodPut:
120
+
errorId := "add-secret-error"
121
+
122
+
value := r.FormValue("value")
123
+
if value == "" {
124
+
w.WriteHeader(http.StatusBadRequest)
125
+
return
126
+
}
127
+
128
+
err = tangled.RepoAddSecret(
129
+
r.Context(),
130
+
spindleClient,
131
+
&tangled.RepoAddSecret_Input{
132
+
Repo: f.RepoAt().String(),
133
+
Key: key,
134
+
Value: value,
135
+
},
136
+
)
137
+
if err != nil {
138
+
l.Error("Failed to add secret.", "err", err)
139
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
140
+
return
141
+
}
142
+
143
+
case http.MethodDelete:
144
+
errorId := "operation-error"
145
+
146
+
err = tangled.RepoRemoveSecret(
147
+
r.Context(),
148
+
spindleClient,
149
+
&tangled.RepoRemoveSecret_Input{
150
+
Repo: f.RepoAt().String(),
151
+
Key: key,
152
+
},
153
+
)
154
+
if err != nil {
155
+
l.Error("Failed to delete secret.", "err", err)
156
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
157
+
return
158
+
}
159
+
}
160
+
161
+
rp.pages.HxRefresh(w)
162
+
}
163
+
164
+
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
165
+
tabVal := r.URL.Query().Get("tab")
166
+
if tabVal == "" {
167
+
tabVal = "general"
168
+
}
169
+
170
+
switch tabVal {
171
+
case "general":
172
+
rp.generalSettings(w, r)
173
+
174
+
case "access":
175
+
rp.accessSettings(w, r)
176
+
177
+
case "pipelines":
178
+
rp.pipelineSettings(w, r)
179
+
}
180
+
}
181
+
182
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
183
+
l := rp.logger.With("handler", "generalSettings")
184
+
185
+
f, err := rp.repoResolver.Resolve(r)
186
+
user := rp.oauth.GetUser(r)
187
+
188
+
scheme := "http"
189
+
if !rp.config.Core.Dev {
190
+
scheme = "https"
191
+
}
192
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
193
+
xrpcc := &indigoxrpc.Client{
194
+
Host: host,
195
+
}
196
+
197
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
198
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
199
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
200
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
201
+
rp.pages.Error503(w)
202
+
return
203
+
}
204
+
205
+
var result types.RepoBranchesResponse
206
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
207
+
l.Error("failed to decode XRPC response", "err", err)
208
+
rp.pages.Error503(w)
209
+
return
210
+
}
211
+
212
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
213
+
if err != nil {
214
+
l.Error("failed to fetch labels", "err", err)
215
+
rp.pages.Error503(w)
216
+
return
217
+
}
218
+
219
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
220
+
if err != nil {
221
+
l.Error("failed to fetch labels", "err", err)
222
+
rp.pages.Error503(w)
223
+
return
224
+
}
225
+
// remove default labels from the labels list, if present
226
+
defaultLabelMap := make(map[string]bool)
227
+
for _, dl := range defaultLabels {
228
+
defaultLabelMap[dl.AtUri().String()] = true
229
+
}
230
+
n := 0
231
+
for _, l := range labels {
232
+
if !defaultLabelMap[l.AtUri().String()] {
233
+
labels[n] = l
234
+
n++
235
+
}
236
+
}
237
+
labels = labels[:n]
238
+
239
+
subscribedLabels := make(map[string]struct{})
240
+
for _, l := range f.Repo.Labels {
241
+
subscribedLabels[l] = struct{}{}
242
+
}
243
+
244
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
245
+
// if all default labels are subbed, show the "unsubscribe all" button
246
+
shouldSubscribeAll := false
247
+
for _, dl := range defaultLabels {
248
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
249
+
// one of the default labels is not subscribed to
250
+
shouldSubscribeAll = true
251
+
break
252
+
}
253
+
}
254
+
255
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
256
+
LoggedInUser: user,
257
+
RepoInfo: f.RepoInfo(user),
258
+
Branches: result.Branches,
259
+
Labels: labels,
260
+
DefaultLabels: defaultLabels,
261
+
SubscribedLabels: subscribedLabels,
262
+
ShouldSubscribeAll: shouldSubscribeAll,
263
+
Tabs: settingsTabs,
264
+
Tab: "general",
265
+
})
266
+
}
267
+
268
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
269
+
l := rp.logger.With("handler", "accessSettings")
270
+
271
+
f, err := rp.repoResolver.Resolve(r)
272
+
user := rp.oauth.GetUser(r)
273
+
274
+
repoCollaborators, err := f.Collaborators(r.Context())
275
+
if err != nil {
276
+
l.Error("failed to get collaborators", "err", err)
277
+
}
278
+
279
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
280
+
LoggedInUser: user,
281
+
RepoInfo: f.RepoInfo(user),
282
+
Tabs: settingsTabs,
283
+
Tab: "access",
284
+
Collaborators: repoCollaborators,
285
+
})
286
+
}
287
+
288
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
289
+
l := rp.logger.With("handler", "pipelineSettings")
290
+
291
+
f, err := rp.repoResolver.Resolve(r)
292
+
user := rp.oauth.GetUser(r)
293
+
294
+
// all spindles that the repo owner is a member of
295
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
296
+
if err != nil {
297
+
l.Error("failed to fetch spindles", "err", err)
298
+
return
299
+
}
300
+
301
+
var secrets []*tangled.RepoListSecrets_Secret
302
+
if f.Spindle != "" {
303
+
if spindleClient, err := rp.oauth.ServiceClient(
304
+
r,
305
+
oauth.WithService(f.Spindle),
306
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
307
+
oauth.WithExp(60),
308
+
oauth.WithDev(rp.config.Core.Dev),
309
+
); err != nil {
310
+
l.Error("failed to create spindle client", "err", err)
311
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
312
+
l.Error("failed to fetch secrets", "err", err)
313
+
} else {
314
+
secrets = resp.Secrets
315
+
}
316
+
}
317
+
318
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
319
+
return strings.Compare(a.Key, b.Key)
320
+
})
321
+
322
+
var dids []string
323
+
for _, s := range secrets {
324
+
dids = append(dids, s.CreatedBy)
325
+
}
326
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
327
+
328
+
// convert to a more manageable form
329
+
var niceSecret []map[string]any
330
+
for id, s := range secrets {
331
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
332
+
niceSecret = append(niceSecret, map[string]any{
333
+
"Id": id,
334
+
"Key": s.Key,
335
+
"CreatedAt": when,
336
+
"CreatedBy": resolvedIdents[id].Handle.String(),
337
+
})
338
+
}
339
+
340
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
341
+
LoggedInUser: user,
342
+
RepoInfo: f.RepoInfo(user),
343
+
Tabs: settingsTabs,
344
+
Tab: "pipelines",
345
+
Spindles: spindles,
346
+
CurrentSpindle: f.Spindle,
347
+
Secrets: niceSecret,
348
+
})
349
+
}
350
+
351
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
352
+
l := rp.logger.With("handler", "EditBaseSettings")
353
+
354
+
noticeId := "repo-base-settings-error"
355
+
356
+
f, err := rp.repoResolver.Resolve(r)
357
+
if err != nil {
358
+
l.Error("failed to get repo and knot", "err", err)
359
+
w.WriteHeader(http.StatusBadRequest)
360
+
return
361
+
}
362
+
363
+
client, err := rp.oauth.AuthorizedClient(r)
364
+
if err != nil {
365
+
l.Error("failed to get client")
366
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
367
+
return
368
+
}
369
+
370
+
var (
371
+
description = r.FormValue("description")
372
+
website = r.FormValue("website")
373
+
topicStr = r.FormValue("topics")
374
+
)
375
+
376
+
err = rp.validator.ValidateURI(website)
377
+
if website != "" && err != nil {
378
+
l.Error("invalid uri", "err", err)
379
+
rp.pages.Notice(w, noticeId, err.Error())
380
+
return
381
+
}
382
+
383
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
384
+
if err != nil {
385
+
l.Error("invalid topics", "err", err)
386
+
rp.pages.Notice(w, noticeId, err.Error())
387
+
return
388
+
}
389
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
390
+
391
+
newRepo := f.Repo
392
+
newRepo.Description = description
393
+
newRepo.Website = website
394
+
newRepo.Topics = topics
395
+
record := newRepo.AsRecord()
396
+
397
+
tx, err := rp.db.BeginTx(r.Context(), nil)
398
+
if err != nil {
399
+
l.Error("failed to begin transaction", "err", err)
400
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
401
+
return
402
+
}
403
+
defer tx.Rollback()
404
+
405
+
err = db.PutRepo(tx, newRepo)
406
+
if err != nil {
407
+
l.Error("failed to update repository", "err", err)
408
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
409
+
return
410
+
}
411
+
412
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
413
+
if err != nil {
414
+
// failed to get record
415
+
l.Error("failed to get repo record", "err", err)
416
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
417
+
return
418
+
}
419
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
420
+
Collection: tangled.RepoNSID,
421
+
Repo: newRepo.Did,
422
+
Rkey: newRepo.Rkey,
423
+
SwapRecord: ex.Cid,
424
+
Record: &lexutil.LexiconTypeDecoder{
425
+
Val: &record,
426
+
},
427
+
})
428
+
429
+
if err != nil {
430
+
l.Error("failed to perferom update-repo query", "err", err)
431
+
// failed to get record
432
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
433
+
return
434
+
}
435
+
436
+
err = tx.Commit()
437
+
if err != nil {
438
+
l.Error("failed to commit", "err", err)
439
+
}
440
+
441
+
rp.pages.HxRefresh(w)
442
+
}
+106
appview/repo/tree.go
+106
appview/repo/tree.go
···
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
"time"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/pages"
12
+
xrpcclient "tangled.org/core/appview/xrpcclient"
13
+
"tangled.org/core/types"
14
+
15
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
+
"github.com/go-chi/chi/v5"
17
+
"github.com/go-git/go-git/v5/plumbing"
18
+
)
19
+
20
+
func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) {
21
+
l := rp.logger.With("handler", "RepoTree")
22
+
f, err := rp.repoResolver.Resolve(r)
23
+
if err != nil {
24
+
l.Error("failed to fully resolve repo", "err", err)
25
+
return
26
+
}
27
+
ref := chi.URLParam(r, "ref")
28
+
ref, _ = url.PathUnescape(ref)
29
+
// if the tree path has a trailing slash, let's strip it
30
+
// so we don't 404
31
+
treePath := chi.URLParam(r, "*")
32
+
treePath, _ = url.PathUnescape(treePath)
33
+
treePath = strings.TrimSuffix(treePath, "/")
34
+
scheme := "http"
35
+
if !rp.config.Core.Dev {
36
+
scheme = "https"
37
+
}
38
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
39
+
xrpcc := &indigoxrpc.Client{
40
+
Host: host,
41
+
}
42
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
43
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
44
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
45
+
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
46
+
rp.pages.Error503(w)
47
+
return
48
+
}
49
+
// Convert XRPC response to internal types.RepoTreeResponse
50
+
files := make([]types.NiceTree, len(xrpcResp.Files))
51
+
for i, xrpcFile := range xrpcResp.Files {
52
+
file := types.NiceTree{
53
+
Name: xrpcFile.Name,
54
+
Mode: xrpcFile.Mode,
55
+
Size: int64(xrpcFile.Size),
56
+
}
57
+
// Convert last commit info if present
58
+
if xrpcFile.Last_commit != nil {
59
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
60
+
file.LastCommit = &types.LastCommitInfo{
61
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
62
+
Message: xrpcFile.Last_commit.Message,
63
+
When: commitWhen,
64
+
}
65
+
}
66
+
files[i] = file
67
+
}
68
+
result := types.RepoTreeResponse{
69
+
Ref: xrpcResp.Ref,
70
+
Files: files,
71
+
}
72
+
if xrpcResp.Parent != nil {
73
+
result.Parent = *xrpcResp.Parent
74
+
}
75
+
if xrpcResp.Dotdot != nil {
76
+
result.DotDot = *xrpcResp.Dotdot
77
+
}
78
+
if xrpcResp.Readme != nil {
79
+
result.ReadmeFileName = xrpcResp.Readme.Filename
80
+
result.Readme = xrpcResp.Readme.Contents
81
+
}
82
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
83
+
// so we can safely redirect to the "parent" (which is the same file).
84
+
if len(result.Files) == 0 && result.Parent == treePath {
85
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
86
+
http.Redirect(w, r, redirectTo, http.StatusFound)
87
+
return
88
+
}
89
+
user := rp.oauth.GetUser(r)
90
+
var breadcrumbs [][]string
91
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
92
+
if treePath != "" {
93
+
for idx, elem := range strings.Split(treePath, "/") {
94
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
95
+
}
96
+
}
97
+
sortFiles(result.Files)
98
+
99
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
100
+
LoggedInUser: user,
101
+
BreadCrumbs: breadcrumbs,
102
+
TreePath: treePath,
103
+
RepoInfo: f.RepoInfo(user),
104
+
RepoTreeResponse: result,
105
+
})
106
+
}
+2
appview/reporesolver/resolver.go
+2
appview/reporesolver/resolver.go
+1
appview/settings/settings.go
+1
appview/settings/settings.go
+9
appview/spindles/spindles.go
+9
appview/spindles/spindles.go
···
6
"log/slog"
7
"net/http"
8
"slices"
9
"time"
10
11
"github.com/go-chi/chi/v5"
···
146
}
147
148
instance := r.FormValue("instance")
149
if instance == "" {
150
s.Pages.Notice(w, noticeId, "Incomplete form.")
151
return
···
484
}
485
486
member := r.FormValue("member")
487
if member == "" {
488
l.Error("empty member")
489
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
613
}
614
615
member := r.FormValue("member")
616
if member == "" {
617
l.Error("empty member")
618
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
6
"log/slog"
7
"net/http"
8
"slices"
9
+
"strings"
10
"time"
11
12
"github.com/go-chi/chi/v5"
···
147
}
148
149
instance := r.FormValue("instance")
150
+
// Strip protocol, trailing slashes, and whitespace
151
+
// Rkey cannot contain slashes
152
+
instance = strings.TrimSpace(instance)
153
+
instance = strings.TrimPrefix(instance, "https://")
154
+
instance = strings.TrimPrefix(instance, "http://")
155
+
instance = strings.TrimSuffix(instance, "/")
156
if instance == "" {
157
s.Pages.Notice(w, noticeId, "Incomplete form.")
158
return
···
491
}
492
493
member := r.FormValue("member")
494
+
member = strings.TrimPrefix(member, "@")
495
if member == "" {
496
l.Error("empty member")
497
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
621
}
622
623
member := r.FormValue("member")
624
+
member = strings.TrimPrefix(member, "@")
625
if member == "" {
626
l.Error("empty member")
627
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+10
-4
appview/state/gfi.go
+10
-4
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"
···
20
21
page := pagination.FromContext(r.Context())
22
23
-
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
24
25
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
26
if err != nil {
···
35
RepoGroups: []*models.RepoGroup{},
36
LabelDefs: make(map[string]*models.LabelDefinition),
37
Page: page,
38
})
39
return
40
}
···
143
RepoGroups: paginatedGroups,
144
LabelDefs: labelDefsMap,
145
Page: page,
146
-
GfiLabel: labelDefsMap[goodFirstIssueLabel],
147
})
148
}
···
1
package state
2
3
import (
4
"log"
5
"net/http"
6
"sort"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
"tangled.org/core/appview/db"
10
"tangled.org/core/appview/models"
11
"tangled.org/core/appview/pages"
···
18
19
page := pagination.FromContext(r.Context())
20
21
+
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
22
+
23
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
24
+
if err != nil {
25
+
log.Println("failed to get gfi label def", err)
26
+
s.pages.Error500(w)
27
+
return
28
+
}
29
30
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
31
if err != nil {
···
40
RepoGroups: []*models.RepoGroup{},
41
LabelDefs: make(map[string]*models.LabelDefinition),
42
Page: page,
43
+
GfiLabel: gfiLabelDef,
44
})
45
return
46
}
···
149
RepoGroups: paginatedGroups,
150
LabelDefs: labelDefsMap,
151
Page: page,
152
+
GfiLabel: gfiLabelDef,
153
})
154
}
+1
appview/state/login.go
+1
appview/state/login.go
+5
-5
appview/state/profile.go
+5
-5
appview/state/profile.go
···
66
return nil, fmt.Errorf("failed to get string count: %w", err)
67
}
68
69
-
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
70
if err != nil {
71
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
}
···
211
}
212
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
213
214
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
215
if err != nil {
216
l.Error("failed to get stars", "err", err)
217
s.pages.Error500(w)
···
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{
···
538
profile.Description = r.FormValue("description")
539
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
540
profile.Location = r.FormValue("location")
541
542
var links [5]string
543
for i := range 5 {
···
652
Location: &profile.Location,
653
PinnedRepositories: pinnedRepoStrings,
654
Stats: vanityStats[:],
655
}},
656
SwapRecord: cid,
657
})
···
66
return nil, fmt.Errorf("failed to get string count: %w", err)
67
}
68
69
+
starredCount, err := db.CountStars(s.db, db.FilterEq("did", did))
70
if err != nil {
71
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
}
···
211
}
212
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
213
214
+
stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid))
215
if err != nil {
216
l.Error("failed to get stars", "err", err)
217
s.pages.Error500(w)
···
219
}
220
var repos []models.Repo
221
for _, s := range stars {
222
+
repos = append(repos, *s.Repo)
223
}
224
225
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
536
profile.Description = r.FormValue("description")
537
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
538
profile.Location = r.FormValue("location")
539
+
profile.Pronouns = r.FormValue("pronouns")
540
541
var links [5]string
542
for i := range 5 {
···
651
Location: &profile.Location,
652
PinnedRepositories: pinnedRepoStrings,
653
Stats: vanityStats[:],
654
+
Pronouns: &profile.Pronouns,
655
}},
656
SwapRecord: cid,
657
})
+36
-31
appview/state/router.go
+36
-31
appview/state/router.go
···
42
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
pat := chi.URLParam(r, "*")
45
-
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
46
-
userRouter.ServeHTTP(w, r)
47
-
} else {
48
-
// Check if the first path element is a valid handle without '@' or a flattened DID
49
-
pathParts := strings.SplitN(pat, "/", 2)
50
-
if len(pathParts) > 0 {
51
-
if userutil.IsHandleNoAt(pathParts[0]) {
52
-
// Redirect to the same path but with '@' prefixed to the handle
53
-
redirectPath := "@" + pat
54
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
55
-
return
56
-
} else if userutil.IsFlattenedDid(pathParts[0]) {
57
-
// Redirect to the unflattened DID version
58
-
unflattenedDid := userutil.UnflattenDid(pathParts[0])
59
-
var redirectPath string
60
-
if len(pathParts) > 1 {
61
-
redirectPath = unflattenedDid + "/" + pathParts[1]
62
-
} else {
63
-
redirectPath = unflattenedDid
64
-
}
65
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
66
-
return
67
-
}
68
}
69
-
standardRouter.ServeHTTP(w, r)
70
}
71
})
72
73
return router
···
79
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
80
r.Get("/", s.Profile)
81
r.Get("/feed.atom", s.AtomFeedPage)
82
-
83
-
// redirect /@handle/repo.git -> /@handle/repo
84
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
85
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
86
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
87
-
})
88
89
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
90
r.Use(mw.GoImport())
···
134
// r.Post("/import", s.ImportRepo)
135
})
136
137
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
138
139
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
140
r.Post("/", s.Follow)
···
42
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
pat := chi.URLParam(r, "*")
45
+
pathParts := strings.SplitN(pat, "/", 2)
46
+
47
+
if len(pathParts) > 0 {
48
+
firstPart := pathParts[0]
49
+
50
+
// if using a DID or handle, just continue as per usual
51
+
if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
52
+
userRouter.ServeHTTP(w, r)
53
+
return
54
+
}
55
+
56
+
// if using a flattened DID (like you would in go modules), unflatten
57
+
if userutil.IsFlattenedDid(firstPart) {
58
+
unflattenedDid := userutil.UnflattenDid(firstPart)
59
+
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
60
+
61
+
redirectURL := *r.URL
62
+
redirectURL.Path = "/" + redirectPath
63
+
64
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
65
+
return
66
+
}
67
+
68
+
// if using a handle with @, rewrite to work without @
69
+
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
70
+
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
71
+
72
+
redirectURL := *r.URL
73
+
redirectURL.Path = "/" + redirectPath
74
+
75
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
76
+
return
77
}
78
+
79
}
80
+
81
+
standardRouter.ServeHTTP(w, r)
82
})
83
84
return router
···
90
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
91
r.Get("/", s.Profile)
92
r.Get("/feed.atom", s.AtomFeedPage)
93
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
95
r.Use(mw.GoImport())
···
139
// r.Post("/import", s.ImportRepo)
140
})
141
142
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
143
144
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
145
r.Post("/", s.Follow)
+9
-13
appview/state/star.go
+9
-13
appview/state/star.go
···
57
log.Println("created atproto record: ", resp.Uri)
58
59
star := &models.Star{
60
-
StarredByDid: currentUser.Did,
61
-
RepoAt: subjectUri,
62
-
Rkey: rkey,
63
}
64
65
err = db.AddStar(s.db, star)
···
75
76
s.notifier.NewStar(r.Context(), star)
77
78
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
79
IsStarred: true,
80
-
RepoAt: subjectUri,
81
-
Stats: models.RepoStats{
82
-
StarCount: starCount,
83
-
},
84
})
85
86
return
···
117
118
s.notifier.DeleteStar(r.Context(), star)
119
120
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
121
IsStarred: false,
122
-
RepoAt: subjectUri,
123
-
Stats: models.RepoStats{
124
-
StarCount: starCount,
125
-
},
126
})
127
128
return
···
57
log.Println("created atproto record: ", resp.Uri)
58
59
star := &models.Star{
60
+
Did: currentUser.Did,
61
+
RepoAt: subjectUri,
62
+
Rkey: rkey,
63
}
64
65
err = db.AddStar(s.db, star)
···
75
76
s.notifier.NewStar(r.Context(), star)
77
78
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
79
IsStarred: true,
80
+
SubjectAt: subjectUri,
81
+
StarCount: starCount,
82
})
83
84
return
···
115
116
s.notifier.DeleteStar(r.Context(), star)
117
118
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
119
IsStarred: false,
120
+
SubjectAt: subjectUri,
121
+
StarCount: starCount,
122
})
123
124
return
+11
-11
appview/state/state.go
+11
-11
appview/state/state.go
···
78
return nil, fmt.Errorf("failed to create enforcer: %w", err)
79
}
80
81
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
82
if err != nil {
83
logger.Error("failed to create redis resolver", "err", err)
84
-
res = idresolver.DefaultResolver()
85
}
86
87
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
129
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
130
}
131
132
-
if err := BackfillDefaultDefs(d, res); err != nil {
133
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
134
}
135
···
294
return
295
}
296
297
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
298
if err != nil {
299
// non-fatal
300
}
···
386
387
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
388
if err != nil {
389
-
w.WriteHeader(http.StatusNotFound)
390
return
391
}
392
393
if len(pubKeys) == 0 {
394
-
w.WriteHeader(http.StatusNotFound)
395
return
396
}
397
···
516
Rkey: rkey,
517
Description: description,
518
Created: time.Now(),
519
-
Labels: models.DefaultLabelDefs(),
520
}
521
record := repo.AsRecord()
522
···
632
aturi = ""
633
634
s.notifier.NewRepo(r.Context(), repo)
635
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
636
}
637
}
638
···
658
return err
659
}
660
661
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
662
-
defaults := models.DefaultLabelDefs()
663
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
664
if err != nil {
665
return err
···
669
return nil
670
}
671
672
-
labelDefs, err := models.FetchDefaultDefs(r)
673
if err != nil {
674
return err
675
}
···
78
return nil, fmt.Errorf("failed to create enforcer: %w", err)
79
}
80
81
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
82
if err != nil {
83
logger.Error("failed to create redis resolver", "err", err)
84
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
85
}
86
87
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
129
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
130
}
131
132
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
133
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
134
}
135
···
294
return
295
}
296
297
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
if err != nil {
299
// non-fatal
300
}
···
386
387
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
388
if err != nil {
389
+
s.logger.Error("failed to get public keys", "err", err)
390
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
391
return
392
}
393
394
if len(pubKeys) == 0 {
395
+
w.WriteHeader(http.StatusNoContent)
396
return
397
}
398
···
517
Rkey: rkey,
518
Description: description,
519
Created: time.Now(),
520
+
Labels: s.config.Label.DefaultLabelDefs,
521
}
522
record := repo.AsRecord()
523
···
633
aturi = ""
634
635
s.notifier.NewRepo(r.Context(), repo)
636
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
637
}
638
}
639
···
659
return err
660
}
661
662
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
663
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
664
if err != nil {
665
return err
···
669
return nil
670
}
671
672
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
673
if err != nil {
674
return err
675
}
+6
-6
appview/state/userutil/userutil.go
+6
-6
appview/state/userutil/userutil.go
···
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
)
12
13
-
func IsHandleNoAt(s string) bool {
14
// ref: https://atproto.com/specs/handle
15
return handleRegex.MatchString(s)
16
}
17
18
func UnflattenDid(s string) string {
···
45
return strings.Replace(s, ":", "-", 2)
46
}
47
return s
48
-
}
49
-
50
-
// IsDid checks if the given string is a standard DID.
51
-
func IsDid(s string) bool {
52
-
return didRegex.MatchString(s)
53
}
54
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
···
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
)
12
13
+
func IsHandle(s string) bool {
14
// ref: https://atproto.com/specs/handle
15
return handleRegex.MatchString(s)
16
+
}
17
+
18
+
// IsDid checks if the given string is a standard DID.
19
+
func IsDid(s string) bool {
20
+
return didRegex.MatchString(s)
21
}
22
23
func UnflattenDid(s string) string {
···
50
return strings.Replace(s, ":", "-", 2)
51
}
52
return s
53
}
54
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+14
-2
appview/strings/strings.go
+14
-2
appview/strings/strings.go
···
148
showRendered = r.URL.Query().Get("code") != "true"
149
}
150
151
s.Pages.SingleString(w, pages.SingleStringParams{
152
-
LoggedInUser: s.OAuth.GetUser(r),
153
RenderToggle: renderToggle,
154
ShowRendered: showRendered,
155
-
String: string,
156
Stats: string.Stats(),
157
Owner: id,
158
})
159
}
···
148
showRendered = r.URL.Query().Get("code") != "true"
149
}
150
151
+
starCount, err := db.GetStarCount(s.Db, string.AtUri())
152
+
if err != nil {
153
+
l.Error("failed to get star count", "err", err)
154
+
}
155
+
user := s.OAuth.GetUser(r)
156
+
isStarred := false
157
+
if user != nil {
158
+
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
159
+
}
160
+
161
s.Pages.SingleString(w, pages.SingleStringParams{
162
+
LoggedInUser: user,
163
RenderToggle: renderToggle,
164
ShowRendered: showRendered,
165
+
String: &string,
166
Stats: string.Stats(),
167
+
IsStarred: isStarred,
168
+
StarCount: starCount,
169
Owner: id,
170
})
171
}
+53
appview/validator/repo_topics.go
+53
appview/validator/repo_topics.go
···
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"maps"
6
+
"regexp"
7
+
"slices"
8
+
"strings"
9
+
)
10
+
11
+
const (
12
+
maxTopicLen = 50
13
+
maxTopics = 20
14
+
)
15
+
16
+
var (
17
+
topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`)
18
+
)
19
+
20
+
// ValidateRepoTopicStr parses and validates whitespace-separated topic string.
21
+
//
22
+
// Rules:
23
+
// - topics are separated by whitespace
24
+
// - each topic may contain lowercase letters, digits, and hyphens only
25
+
// - each topic must be <= 50 characters long
26
+
// - no more than 20 topics allowed
27
+
// - duplicates are removed
28
+
func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) {
29
+
topicsStr = strings.TrimSpace(topicsStr)
30
+
if topicsStr == "" {
31
+
return nil, nil
32
+
}
33
+
parts := strings.Fields(topicsStr)
34
+
if len(parts) > maxTopics {
35
+
return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics)
36
+
}
37
+
38
+
topicSet := make(map[string]struct{})
39
+
40
+
for _, t := range parts {
41
+
if _, exists := topicSet[t]; exists {
42
+
continue
43
+
}
44
+
if len(t) > maxTopicLen {
45
+
return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics)
46
+
}
47
+
if !topicRE.MatchString(t) {
48
+
return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t)
49
+
}
50
+
topicSet[t] = struct{}{}
51
+
}
52
+
return slices.Collect(maps.Keys(topicSet)), nil
53
+
}
+17
appview/validator/uri.go
+17
appview/validator/uri.go
···
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
func (v *Validator) ValidateURI(uri string) error {
9
+
parsed, err := url.Parse(uri)
10
+
if err != nil {
11
+
return fmt.Errorf("invalid uri format")
12
+
}
13
+
if parsed.Scheme == "" {
14
+
return fmt.Errorf("uri scheme missing")
15
+
}
16
+
return nil
17
+
}
+3
-3
docs/hacking.md
+3
-3
docs/hacking.md
···
52
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
54
# the secret key from above
55
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
56
57
# run redis in at a new shell to store oauth sessions
58
redis-server
···
168
169
If for any reason you wish to disable either one of the
170
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
-
`services.tangled-spindle.enable` (or
172
-
`services.tangled-knot.enable`) to `false`.
···
52
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
54
# the secret key from above
55
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
56
57
# run redis in at a new shell to store oauth sessions
58
redis-server
···
168
169
If for any reason you wish to disable either one of the
170
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
+
`services.tangled.spindle.enable` (or
172
+
`services.tangled.knot.enable`) to `false`.
+1
-1
docs/migrations.md
+1
-1
docs/migrations.md
+19
-1
docs/spindle/pipeline.md
+19
-1
docs/spindle/pipeline.md
···
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
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
···
29
branch: ["main", "develop"]
30
- event: ["pull_request"]
31
branch: ["main"]
32
```
33
34
## Engine
···
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
- `manual`: The workflow can be triggered manually.
22
+
- `branch`: 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. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
+
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
24
25
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:
26
···
30
branch: ["main", "develop"]
31
- event: ["pull_request"]
32
branch: ["main"]
33
+
```
34
+
35
+
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
+
37
+
```yaml
38
+
when:
39
+
- event: ["push"]
40
+
tag: ["v*"]
41
+
```
42
+
43
+
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
+
45
+
```yaml
46
+
when:
47
+
- event: ["push"]
48
+
branch: ["main", "release-*"]
49
+
tag: ["v*", "stable"]
50
```
51
52
## Engine
+17
flake.lock
+17
flake.lock
···
1
{
2
"nodes": {
3
+
"actor-typeahead-src": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1762835797,
7
+
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
8
+
"ref": "refs/heads/main",
9
+
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
10
+
"revCount": 6,
11
+
"type": "git",
12
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
13
+
},
14
+
"original": {
15
+
"type": "git",
16
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
17
+
}
18
+
},
19
"flake-compat": {
20
"flake": false,
21
"locked": {
···
166
},
167
"root": {
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
170
"flake-compat": "flake-compat",
171
"gomod2nix": "gomod2nix",
172
"htmx-src": "htmx-src",
+12
-10
flake.nix
+12
-10
flake.nix
···
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
flake = false;
35
};
36
ibm-plex-mono-src = {
37
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
flake = false;
···
54
inter-fonts-src,
55
sqlite-lib-src,
56
ibm-plex-mono-src,
57
...
58
}: let
59
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
81
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
82
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
84
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
85
};
86
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
179
air-watcher = name: arg:
180
pkgs.writeShellScriptBin "run"
181
''
182
-
${pkgs.air}/bin/air -c /dev/null \
183
-
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
184
-
-build.bin "./out/${name}.out" \
185
-
-build.args_bin "${arg}" \
186
-
-build.stop_on_error "true" \
187
-
-build.include_ext "go"
188
'';
189
tailwind-watcher =
190
pkgs.writeShellScriptBin "run"
···
283
}: {
284
imports = [./nix/modules/appview.nix];
285
286
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
287
};
288
nixosModules.knot = {
289
lib,
···
292
}: {
293
imports = [./nix/modules/knot.nix];
294
295
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
296
};
297
nixosModules.spindle = {
298
lib,
···
301
}: {
302
imports = [./nix/modules/spindle.nix];
303
304
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
305
};
306
};
307
}
···
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
flake = false;
35
};
36
+
actor-typeahead-src = {
37
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
+
flake = false;
39
+
};
40
ibm-plex-mono-src = {
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
42
flake = false;
···
58
inter-fonts-src,
59
sqlite-lib-src,
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
62
...
63
}: let
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
87
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
88
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
90
};
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
184
air-watcher = name: arg:
185
pkgs.writeShellScriptBin "run"
186
''
187
+
export PATH=${pkgs.go}/bin:$PATH
188
+
${pkgs.air}/bin/air -c ./.air/${name}.toml \
189
+
-build.args_bin "${arg}"
190
'';
191
tailwind-watcher =
192
pkgs.writeShellScriptBin "run"
···
285
}: {
286
imports = [./nix/modules/appview.nix];
287
288
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview;
289
};
290
nixosModules.knot = {
291
lib,
···
294
}: {
295
imports = [./nix/modules/knot.nix];
296
297
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot;
298
};
299
nixosModules.spindle = {
300
lib,
···
303
}: {
304
imports = [./nix/modules/spindle.nix];
305
306
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
307
};
308
};
309
}
+4
-12
go.mod
+4
-12
go.mod
···
7
github.com/alecthomas/assert/v2 v2.11.0
8
github.com/alecthomas/chroma/v2 v2.15.0
9
github.com/avast/retry-go/v4 v4.6.1
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
15
github.com/cloudflare/cloudflare-go v0.115.0
16
github.com/cyphar/filepath-securejoin v0.4.1
17
github.com/dgraph-io/ristretto v0.2.0
···
29
github.com/hiddeco/sshsig v0.2.0
30
github.com/hpcloud/tail v1.0.0
31
github.com/ipfs/go-cid v0.5.0
32
-
github.com/lestrrat-go/jwx/v2 v2.1.6
33
github.com/mattn/go-sqlite3 v1.14.24
34
github.com/microcosm-cc/bluemonday v1.0.27
35
github.com/openbao/openbao/api/v2 v2.3.0
···
45
github.com/wyatt915/goldmark-treeblood v0.0.1
46
github.com/yuin/goldmark v1.7.13
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
48
golang.org/x/crypto v0.40.0
49
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
golang.org/x/image v0.31.0
···
65
github.com/aymerick/douceur v0.2.0 // indirect
66
github.com/beorn7/perks v1.0.1 // indirect
67
github.com/bits-and-blooms/bitset v1.22.0 // indirect
68
-
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
69
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
70
github.com/blevesearch/geo v0.2.4 // indirect
71
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
83
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
84
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
85
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
86
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
87
github.com/casbin/govaluate v1.3.0 // indirect
88
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
89
github.com/cespare/xxhash/v2 v2.3.0 // indirect
90
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
91
github.com/charmbracelet/lipgloss v1.1.0 // indirect
92
-
github.com/charmbracelet/log v0.4.2 // indirect
93
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
github.com/charmbracelet/x/term v0.2.1 // indirect
···
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
github.com/containerd/log v0.1.0 // indirect
100
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
101
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
102
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
103
github.com/distribution/reference v0.6.0 // indirect
104
github.com/dlclark/regexp2 v1.11.5 // indirect
···
152
github.com/kevinburke/ssh_config v1.2.0 // indirect
153
github.com/klauspost/compress v1.18.0 // indirect
154
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
155
-
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
156
-
github.com/lestrrat-go/httpcc v1.0.1 // indirect
157
-
github.com/lestrrat-go/httprc v1.0.6 // indirect
158
-
github.com/lestrrat-go/iter v1.0.2 // indirect
159
-
github.com/lestrrat-go/option v1.0.1 // indirect
160
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
161
github.com/mattn/go-isatty v0.0.20 // indirect
162
github.com/mattn/go-runewidth v0.0.16 // indirect
···
191
github.com/prometheus/procfs v0.16.1 // indirect
192
github.com/rivo/uniseg v0.4.7 // indirect
193
github.com/ryanuber/go-glob v1.0.0 // indirect
194
-
github.com/segmentio/asm v1.2.0 // indirect
195
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
196
github.com/spaolacci/murmur3 v1.1.0 // indirect
197
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
···
199
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
200
github.com/wyatt915/treeblood v0.1.16 // indirect
201
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
202
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
203
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
204
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
205
go.etcd.io/bbolt v1.4.0 // indirect
···
7
github.com/alecthomas/assert/v2 v2.11.0
8
github.com/alecthomas/chroma/v2 v2.15.0
9
github.com/avast/retry-go/v4 v4.6.1
10
+
github.com/blevesearch/bleve/v2 v2.5.3
11
github.com/bluekeyes/go-gitdiff v0.8.1
12
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
13
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
14
+
github.com/bmatcuk/doublestar/v4 v4.9.1
15
github.com/carlmjohnson/versioninfo v0.22.5
16
github.com/casbin/casbin/v2 v2.103.0
17
+
github.com/charmbracelet/log v0.4.2
18
github.com/cloudflare/cloudflare-go v0.115.0
19
github.com/cyphar/filepath-securejoin v0.4.1
20
github.com/dgraph-io/ristretto v0.2.0
···
32
github.com/hiddeco/sshsig v0.2.0
33
github.com/hpcloud/tail v1.0.0
34
github.com/ipfs/go-cid v0.5.0
35
github.com/mattn/go-sqlite3 v1.14.24
36
github.com/microcosm-cc/bluemonday v1.0.27
37
github.com/openbao/openbao/api/v2 v2.3.0
···
47
github.com/wyatt915/goldmark-treeblood v0.0.1
48
github.com/yuin/goldmark v1.7.13
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
golang.org/x/crypto v0.40.0
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
golang.org/x/image v0.31.0
···
68
github.com/aymerick/douceur v0.2.0 // indirect
69
github.com/beorn7/perks v1.0.1 // indirect
70
github.com/bits-and-blooms/bitset v1.22.0 // indirect
71
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
72
github.com/blevesearch/geo v0.2.4 // indirect
73
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
85
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
86
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
87
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
88
github.com/casbin/govaluate v1.3.0 // indirect
89
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
90
github.com/cespare/xxhash/v2 v2.3.0 // indirect
91
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
92
github.com/charmbracelet/lipgloss v1.1.0 // indirect
93
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
github.com/charmbracelet/x/term v0.2.1 // indirect
···
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
github.com/containerd/log v0.1.0 // indirect
100
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
101
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
102
github.com/distribution/reference v0.6.0 // indirect
103
github.com/dlclark/regexp2 v1.11.5 // indirect
···
151
github.com/kevinburke/ssh_config v1.2.0 // indirect
152
github.com/klauspost/compress v1.18.0 // indirect
153
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
154
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
155
github.com/mattn/go-isatty v0.0.20 // indirect
156
github.com/mattn/go-runewidth v0.0.16 // indirect
···
185
github.com/prometheus/procfs v0.16.1 // indirect
186
github.com/rivo/uniseg v0.4.7 // indirect
187
github.com/ryanuber/go-glob v1.0.0 // indirect
188
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
189
github.com/spaolacci/murmur3 v1.1.0 // indirect
190
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
···
192
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
193
github.com/wyatt915/treeblood v0.1.16 // indirect
194
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
195
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
196
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
197
go.etcd.io/bbolt v1.4.0 // indirect
+2
-17
go.sum
+2
-17
go.sum
···
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
73
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
74
-
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
75
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
76
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
77
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
78
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
124
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
125
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
127
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
128
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
129
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
130
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
131
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
328
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
329
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
330
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
331
-
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
332
-
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
333
-
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
334
-
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
335
-
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
336
-
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
337
-
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
338
-
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
339
-
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
340
-
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
341
-
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
342
-
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
343
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
344
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
345
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
464
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
465
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
466
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
467
-
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
468
-
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
469
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
470
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
471
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
73
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
74
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
75
+
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
76
+
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
77
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
78
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
79
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
125
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
127
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
128
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
129
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
130
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
327
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
328
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
329
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
330
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
331
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
332
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
451
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
452
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
453
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
454
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
455
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
456
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+36
-61
guard/guard.go
+36
-61
guard/guard.go
···
12
"os/exec"
13
"strings"
14
15
-
"github.com/bluesky-social/indigo/atproto/identity"
16
securejoin "github.com/cyphar/filepath-securejoin"
17
"github.com/urfave/cli/v3"
18
-
"tangled.org/core/idresolver"
19
"tangled.org/core/log"
20
)
21
···
93
"command", sshCommand,
94
"client", clientIP)
95
96
if sshCommand == "" {
97
l.Info("access denied: no interactive shells", "user", incomingUser)
98
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
107
}
108
109
gitCommand := cmdParts[0]
110
-
111
-
// did:foo/repo-name or
112
-
// handle/repo-name or
113
-
// any of the above with a leading slash (/)
114
-
115
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
116
-
l.Info("command components", "components", components)
117
-
118
-
if len(components) != 2 {
119
-
l.Error("invalid repo format", "components", components)
120
-
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
121
-
os.Exit(-1)
122
-
}
123
-
124
-
didOrHandle := components[0]
125
-
identity := resolveIdentity(ctx, l, didOrHandle)
126
-
did := identity.DID.String()
127
-
repoName := components[1]
128
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
129
130
validCommands := map[string]bool{
131
"git-receive-pack": true,
···
138
return fmt.Errorf("access denied: invalid git command")
139
}
140
141
-
if gitCommand != "git-upload-pack" {
142
-
if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
143
-
l.Error("access denied: user not allowed",
144
-
"did", incomingUser,
145
-
"reponame", qualifiedRepoName)
146
-
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
147
-
os.Exit(-1)
148
-
}
149
}
150
151
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
152
153
l.Info("processing command",
154
"user", incomingUser,
155
"command", gitCommand,
156
-
"repo", repoName,
157
"fullPath", fullPath,
158
"client", clientIP)
159
···
177
gitCmd.Stdin = os.Stdin
178
gitCmd.Env = append(os.Environ(),
179
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
181
)
182
183
if err := gitCmd.Run(); err != nil {
···
189
l.Info("command completed",
190
"user", incomingUser,
191
"command", gitCommand,
192
-
"repo", repoName,
193
"success", true)
194
195
return nil
196
}
197
198
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
-
resolver := idresolver.DefaultResolver()
200
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
201
if err != nil {
202
-
l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
203
-
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
204
-
os.Exit(1)
205
-
}
206
-
if ident.Handle.IsInvalidHandle() {
207
-
l.Error("Error resolving handle", "invalid handle", didOrHandle)
208
-
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
209
-
os.Exit(1)
210
}
211
-
return ident
212
-
}
213
214
-
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
215
-
u, _ := url.Parse(endpoint + "/push-allowed")
216
-
q := u.Query()
217
-
q.Add("user", user)
218
-
q.Add("repo", qualifiedRepoName)
219
-
u.RawQuery = q.Encode()
220
221
-
req, err := http.Get(u.String())
222
if err != nil {
223
-
l.Error("Error verifying permissions", "error", err)
224
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
225
-
os.Exit(1)
226
}
227
-
228
-
l.Info("Checking push permission",
229
-
"url", u.String(),
230
-
"status", req.Status)
231
232
-
return req.StatusCode == http.StatusNoContent
233
}
···
12
"os/exec"
13
"strings"
14
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/urfave/cli/v3"
17
"tangled.org/core/log"
18
)
19
···
91
"command", sshCommand,
92
"client", clientIP)
93
94
+
// TODO: greet user with their resolved handle instead of did
95
if sshCommand == "" {
96
l.Info("access denied: no interactive shells", "user", incomingUser)
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
106
}
107
108
gitCommand := cmdParts[0]
109
+
repoPath := cmdParts[1]
110
111
validCommands := map[string]bool{
112
"git-receive-pack": true,
···
119
return fmt.Errorf("access denied: invalid git command")
120
}
121
122
+
// qualify repo path from internal server which holds the knot config
123
+
qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
124
+
if err != nil {
125
+
l.Error("failed to run guard", "err", err)
126
+
fmt.Fprintln(os.Stderr, err)
127
+
os.Exit(1)
128
}
129
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
131
132
l.Info("processing command",
133
"user", incomingUser,
134
"command", gitCommand,
135
+
"repo", repoPath,
136
"fullPath", fullPath,
137
"client", clientIP)
138
···
156
gitCmd.Stdin = os.Stdin
157
gitCmd.Env = append(os.Environ(),
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
159
)
160
161
if err := gitCmd.Run(); err != nil {
···
167
l.Info("command completed",
168
"user", incomingUser,
169
"command", gitCommand,
170
+
"repo", repoPath,
171
"success", true)
172
173
return nil
174
}
175
176
+
// runs guardAndQualifyRepo logic
177
+
func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
178
+
u, _ := url.Parse(endpoint + "/guard")
179
+
q := u.Query()
180
+
q.Add("user", incomingUser)
181
+
q.Add("repo", repo)
182
+
q.Add("gitCmd", gitCommand)
183
+
u.RawQuery = q.Encode()
184
+
185
+
resp, err := http.Get(u.String())
186
if err != nil {
187
+
return "", err
188
}
189
+
defer resp.Body.Close()
190
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
192
193
+
body, err := io.ReadAll(resp.Body)
194
if err != nil {
195
+
return "", err
196
}
197
+
text := string(body)
198
199
+
switch resp.StatusCode {
200
+
case http.StatusOK:
201
+
return text, nil
202
+
case http.StatusForbidden:
203
+
l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
204
+
return text, errors.New("access denied: user not allowed")
205
+
default:
206
+
return "", errors.New(text)
207
+
}
208
}
+17
-8
idresolver/resolver.go
+17
-8
idresolver/resolver.go
···
17
directory identity.Directory
18
}
19
20
-
func BaseDirectory() identity.Directory {
21
base := identity.BaseDirectory{
22
-
PLCURL: identity.DefaultPLCURL,
23
HTTPClient: http.Client{
24
Timeout: time.Second * 10,
25
Transport: &http.Transport{
···
42
return &base
43
}
44
45
-
func RedisDirectory(url string) (identity.Directory, error) {
46
hitTTL := time.Hour * 24
47
errTTL := time.Second * 30
48
invalidHandleTTL := time.Minute * 5
49
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
50
}
51
52
-
func DefaultResolver() *Resolver {
53
return &Resolver{
54
-
directory: identity.DefaultDirectory(),
55
}
56
}
57
58
-
func RedisResolver(redisUrl string) (*Resolver, error) {
59
-
directory, err := RedisDirectory(redisUrl)
60
if err != nil {
61
return nil, err
62
}
···
17
directory identity.Directory
18
}
19
20
+
func BaseDirectory(plcUrl string) identity.Directory {
21
base := identity.BaseDirectory{
22
+
PLCURL: plcUrl,
23
HTTPClient: http.Client{
24
Timeout: time.Second * 10,
25
Transport: &http.Transport{
···
42
return &base
43
}
44
45
+
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
46
hitTTL := time.Hour * 24
47
errTTL := time.Second * 30
48
invalidHandleTTL := time.Minute * 5
49
+
return redisdir.NewRedisDirectory(
50
+
BaseDirectory(plcUrl),
51
+
url,
52
+
hitTTL,
53
+
errTTL,
54
+
invalidHandleTTL,
55
+
10000,
56
+
)
57
}
58
59
+
func DefaultResolver(plcUrl string) *Resolver {
60
+
base := BaseDirectory(plcUrl)
61
+
cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5)
62
return &Resolver{
63
+
directory: &cached,
64
}
65
}
66
67
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
+
directory, err := RedisDirectory(redisUrl, plcUrl)
69
if err != nil {
70
return nil, err
71
}
+38
input.css
+38
input.css
···
161
@apply no-underline;
162
}
163
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
166
+
}
167
+
168
.prose li {
169
@apply my-0 py-0;
170
}
···
245
details[data-callout] > summary::-webkit-details-marker {
246
display: none;
247
}
248
+
249
}
250
@layer utilities {
251
.error {
···
929
text-decoration: underline;
930
}
931
}
932
+
933
+
actor-typeahead {
934
+
--color-background: #ffffff;
935
+
--color-border: #d1d5db;
936
+
--color-shadow: #000000;
937
+
--color-hover: #f9fafb;
938
+
--color-avatar-fallback: #e5e7eb;
939
+
--radius: 0.0;
940
+
--padding-menu: 0.0rem;
941
+
z-index: 1000;
942
+
}
943
+
944
+
actor-typeahead::part(handle) {
945
+
color: #111827;
946
+
}
947
+
948
+
actor-typeahead::part(menu) {
949
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
950
+
}
951
+
952
+
@media (prefers-color-scheme: dark) {
953
+
actor-typeahead {
954
+
--color-background: #1f2937;
955
+
--color-border: #4b5563;
956
+
--color-shadow: #000000;
957
+
--color-hover: #374151;
958
+
--color-avatar-fallback: #4b5563;
959
+
}
960
+
961
+
actor-typeahead::part(handle) {
962
+
color: #f9fafb;
963
+
}
964
+
}
+1
knotserver/config/config.go
+1
knotserver/config/config.go
···
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
Hostname string `env:"HOSTNAME, required"`
22
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
Owner string `env:"OWNER, required"`
24
LogDids bool `env:"LOG_DIDS, default=true"`
···
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
Hostname string `env:"HOSTNAME, required"`
22
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
24
Owner string `env:"OWNER, required"`
25
LogDids bool `env:"LOG_DIDS, default=true"`
+60
-2
knotserver/git/git.go
+60
-2
knotserver/git/git.go
···
3
import (
4
"archive/tar"
5
"bytes"
6
"fmt"
7
"io"
8
"io/fs"
···
12
"time"
13
14
"github.com/go-git/go-git/v5"
15
"github.com/go-git/go-git/v5/plumbing"
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
18
19
var (
20
-
ErrBinaryFile = fmt.Errorf("binary file")
21
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
22
)
23
24
type GitRepo struct {
···
188
defer reader.Close()
189
190
return io.ReadAll(reader)
191
}
192
193
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
···
3
import (
4
"archive/tar"
5
"bytes"
6
+
"errors"
7
"fmt"
8
"io"
9
"io/fs"
···
13
"time"
14
15
"github.com/go-git/go-git/v5"
16
+
"github.com/go-git/go-git/v5/config"
17
"github.com/go-git/go-git/v5/plumbing"
18
"github.com/go-git/go-git/v5/plumbing/object"
19
)
20
21
var (
22
+
ErrBinaryFile = errors.New("binary file")
23
+
ErrNotBinaryFile = errors.New("not binary file")
24
+
ErrMissingGitModules = errors.New("no .gitmodules file found")
25
+
ErrInvalidGitModules = errors.New("invalid .gitmodules file")
26
+
ErrNotSubmodule = errors.New("path is not a submodule")
27
)
28
29
type GitRepo struct {
···
193
defer reader.Close()
194
195
return io.ReadAll(reader)
196
+
}
197
+
198
+
// read and parse .gitmodules
199
+
func (g *GitRepo) Submodules() (*config.Modules, error) {
200
+
c, err := g.r.CommitObject(g.h)
201
+
if err != nil {
202
+
return nil, fmt.Errorf("commit object: %w", err)
203
+
}
204
+
205
+
tree, err := c.Tree()
206
+
if err != nil {
207
+
return nil, fmt.Errorf("tree: %w", err)
208
+
}
209
+
210
+
// read .gitmodules file
211
+
modulesEntry, err := tree.FindEntry(".gitmodules")
212
+
if err != nil {
213
+
return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
214
+
}
215
+
216
+
modulesFile, err := tree.TreeEntryFile(modulesEntry)
217
+
if err != nil {
218
+
return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
219
+
}
220
+
221
+
content, err := modulesFile.Contents()
222
+
if err != nil {
223
+
return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
224
+
}
225
+
226
+
// parse .gitmodules
227
+
modules := config.NewModules()
228
+
if err = modules.Unmarshal([]byte(content)); err != nil {
229
+
return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
230
+
}
231
+
232
+
return modules, nil
233
+
}
234
+
235
+
func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
236
+
modules, err := g.Submodules()
237
+
if err != nil {
238
+
return nil, err
239
+
}
240
+
241
+
for _, submodule := range modules.Submodules {
242
+
if submodule.Path == path {
243
+
return submodule, nil
244
+
}
245
+
}
246
+
247
+
// path is not a submodule
248
+
return nil, ErrNotSubmodule
249
}
250
251
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4
-13
knotserver/git/tree.go
+4
-13
knotserver/git/tree.go
···
7
"path"
8
"time"
9
10
"github.com/go-git/go-git/v5/plumbing/object"
11
"tangled.org/core/types"
12
)
···
53
}
54
55
for _, e := range subtree.Entries {
56
-
mode, _ := e.Mode.ToOSFileMode()
57
sz, _ := subtree.Size(e.Name)
58
-
59
fpath := path.Join(parent, e.Name)
60
61
var lastCommit *types.LastCommitInfo
···
69
70
nts = append(nts, types.NiceTree{
71
Name: e.Name,
72
-
Mode: mode.String(),
73
-
IsFile: e.Mode.IsFile(),
74
Size: sz,
75
LastCommit: lastCommit,
76
})
···
126
default:
127
}
128
129
-
mode, err := e.Mode.ToOSFileMode()
130
-
if err != nil {
131
-
// TODO: log this
132
-
continue
133
-
}
134
-
135
if e.Mode.IsFile() {
136
-
err = cb(e, currentTree, root)
137
-
if errors.Is(err, TerminateWalk) {
138
return err
139
}
140
}
141
142
// e is a directory
143
-
if mode.IsDir() {
144
subtree, err := currentTree.Tree(e.Name)
145
if err != nil {
146
return fmt.Errorf("sub tree %s: %w", e.Name, err)
···
7
"path"
8
"time"
9
10
+
"github.com/go-git/go-git/v5/plumbing/filemode"
11
"github.com/go-git/go-git/v5/plumbing/object"
12
"tangled.org/core/types"
13
)
···
54
}
55
56
for _, e := range subtree.Entries {
57
sz, _ := subtree.Size(e.Name)
58
fpath := path.Join(parent, e.Name)
59
60
var lastCommit *types.LastCommitInfo
···
68
69
nts = append(nts, types.NiceTree{
70
Name: e.Name,
71
+
Mode: e.Mode.String(),
72
Size: sz,
73
LastCommit: lastCommit,
74
})
···
124
default:
125
}
126
127
if e.Mode.IsFile() {
128
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
129
return err
130
}
131
}
132
133
// e is a directory
134
+
if e.Mode == filemode.Dir {
135
subtree, err := currentTree.Tree(e.Name)
136
if err != nil {
137
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+4
-8
knotserver/ingester.go
+4
-8
knotserver/ingester.go
···
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"
···
120
}
121
122
// resolve this aturi to extract the repo record
123
-
resolver := idresolver.DefaultResolver()
124
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
125
if err != nil || ident.Handle.IsInvalidHandle() {
126
return fmt.Errorf("failed to resolve handle: %w", err)
127
}
···
163
164
var pipeline workflow.RawPipeline
165
for _, e := range workflowDir {
166
-
if !e.IsFile {
167
continue
168
}
169
···
233
return err
234
}
235
236
-
resolver := idresolver.DefaultResolver()
237
-
238
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
239
if err != nil || subjectId.Handle.IsInvalidHandle() {
240
return err
241
}
242
243
// TODO: fix this for good, we need to fetch the record here unfortunately
244
// resolve this aturi to extract the repo record
245
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
246
if err != nil || owner.Handle.IsInvalidHandle() {
247
return fmt.Errorf("failed to resolve handle: %w", err)
248
}
···
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/knotserver/db"
20
"tangled.org/core/knotserver/git"
21
"tangled.org/core/log"
···
119
}
120
121
// resolve this aturi to extract the repo record
122
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
123
if err != nil || ident.Handle.IsInvalidHandle() {
124
return fmt.Errorf("failed to resolve handle: %w", err)
125
}
···
161
162
var pipeline workflow.RawPipeline
163
for _, e := range workflowDir {
164
+
if !e.IsFile() {
165
continue
166
}
167
···
231
return err
232
}
233
234
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
235
if err != nil || subjectId.Handle.IsInvalidHandle() {
236
return err
237
}
238
239
// TODO: fix this for good, we need to fetch the record here unfortunately
240
// resolve this aturi to extract the repo record
241
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
242
if err != nil || owner.Handle.IsInvalidHandle() {
243
return fmt.Errorf("failed to resolve handle: %w", err)
244
}
+146
-49
knotserver/internal.go
+146
-49
knotserver/internal.go
···
27
)
28
29
type InternalHandle struct {
30
-
db *db.DB
31
-
c *config.Config
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
35
}
36
37
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
67
writeJSON(w, data)
68
}
69
70
type PushOptions struct {
71
skipCi bool
72
verboseCi bool
···
121
// non-fatal
122
}
123
124
-
if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
125
-
msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context())
126
-
if err != nil {
127
-
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
128
-
// non-fatal
129
-
} else {
130
-
for msgLine := range msg {
131
-
resp.Messages = append(resp.Messages, msg[msgLine])
132
-
}
133
-
}
134
}
135
136
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
143
writeJSON(w, resp)
144
}
145
146
-
func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
147
-
l := h.l.With("handler", "replyCompare")
148
-
userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner)
149
-
user := repoOwner
150
-
if err != nil {
151
-
l.Error("Failed to fetch user identity", "err", err)
152
-
// non-fatal
153
-
} else {
154
-
user = userIdent.Handle.String()
155
-
}
156
-
gr, err := git.PlainOpen(gitRelativeDir)
157
-
if err != nil {
158
-
l.Error("Failed to open git repository", "err", err)
159
-
return []string{}, err
160
-
}
161
-
defaultBranch, err := gr.FindMainBranch()
162
-
if err != nil {
163
-
l.Error("Failed to fetch default branch", "err", err)
164
-
return []string{}, err
165
-
}
166
-
if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
167
-
return []string{}, nil
168
-
}
169
-
ZWS := "\u200B"
170
-
var msg []string
171
-
msg = append(msg, ZWS)
172
-
msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
173
-
msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
174
-
msg = append(msg, ZWS)
175
-
return msg, nil
176
-
}
177
-
178
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
179
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
180
if err != nil {
···
220
return errors.Join(errs, h.db.InsertEvent(event, h.n))
221
}
222
223
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
224
if pushOptions.skipCi {
225
return nil
226
}
···
247
248
var pipeline workflow.RawPipeline
249
for _, e := range workflowDir {
250
-
if !e.IsFile {
251
continue
252
}
253
···
315
return h.db.InsertEvent(event, h.n)
316
}
317
318
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
319
r := chi.NewRouter()
320
l := log.FromContext(ctx)
321
l = log.SubLogger(l, "internal")
322
323
h := InternalHandle{
324
db,
···
326
e,
327
l,
328
n,
329
}
330
331
r.Get("/push-allowed", h.PushAllowed)
332
r.Get("/keys", h.InternalKeys)
333
r.Post("/hooks/post-receive", h.PostReceiveHook)
334
r.Mount("/debug", middleware.Profiler())
335
···
27
)
28
29
type InternalHandle struct {
30
+
db *db.DB
31
+
c *config.Config
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
res *idresolver.Resolver
36
}
37
38
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
68
writeJSON(w, data)
69
}
70
71
+
// response in text/plain format
72
+
// the body will be qualified repository path on success/push-denied
73
+
// or an error message when process failed
74
+
func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
75
+
l := h.l.With("handler", "PostReceiveHook")
76
+
77
+
var (
78
+
incomingUser = r.URL.Query().Get("user")
79
+
repo = r.URL.Query().Get("repo")
80
+
gitCommand = r.URL.Query().Get("gitCmd")
81
+
)
82
+
83
+
if incomingUser == "" || repo == "" || gitCommand == "" {
84
+
w.WriteHeader(http.StatusBadRequest)
85
+
l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
86
+
fmt.Fprintln(w, "invalid internal request")
87
+
return
88
+
}
89
+
90
+
// did:foo/repo-name or
91
+
// handle/repo-name or
92
+
// any of the above with a leading slash (/)
93
+
components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
94
+
l.Info("command components", "components", components)
95
+
96
+
if len(components) != 2 {
97
+
w.WriteHeader(http.StatusBadRequest)
98
+
l.Error("invalid repo format", "components", components)
99
+
fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
100
+
return
101
+
}
102
+
repoOwner := components[0]
103
+
repoName := components[1]
104
+
105
+
resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
106
+
107
+
repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner)
108
+
if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
109
+
l.Error("Error resolving handle", "handle", repoOwner, "err", err)
110
+
w.WriteHeader(http.StatusInternalServerError)
111
+
fmt.Fprintf(w, "error resolving handle: invalid handle\n")
112
+
return
113
+
}
114
+
repoOwnerDid := repoOwnerIdent.DID.String()
115
+
116
+
qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
117
+
118
+
if gitCommand == "git-receive-pack" {
119
+
ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
120
+
if err != nil || !ok {
121
+
w.WriteHeader(http.StatusForbidden)
122
+
fmt.Fprint(w, repo)
123
+
return
124
+
}
125
+
}
126
+
127
+
w.WriteHeader(http.StatusOK)
128
+
fmt.Fprint(w, qualifiedRepo)
129
+
}
130
+
131
type PushOptions struct {
132
skipCi bool
133
verboseCi bool
···
182
// non-fatal
183
}
184
185
+
err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
186
+
if err != nil {
187
+
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
188
+
// non-fatal
189
}
190
191
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
198
writeJSON(w, resp)
199
}
200
201
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
202
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
203
if err != nil {
···
243
return errors.Join(errs, h.db.InsertEvent(event, h.n))
244
}
245
246
+
func (h *InternalHandle) triggerPipeline(
247
+
clientMsgs *[]string,
248
+
line git.PostReceiveLine,
249
+
gitUserDid string,
250
+
repoDid string,
251
+
repoName string,
252
+
pushOptions PushOptions,
253
+
) error {
254
if pushOptions.skipCi {
255
return nil
256
}
···
277
278
var pipeline workflow.RawPipeline
279
for _, e := range workflowDir {
280
+
if !e.IsFile() {
281
continue
282
}
283
···
345
return h.db.InsertEvent(event, h.n)
346
}
347
348
+
func (h *InternalHandle) emitCompareLink(
349
+
clientMsgs *[]string,
350
+
line git.PostReceiveLine,
351
+
repoDid string,
352
+
repoName string,
353
+
) error {
354
+
// this is a second push to a branch, don't reply with the link again
355
+
if !line.OldSha.IsZero() {
356
+
return nil
357
+
}
358
+
359
+
// the ref was not updated to a new hash, don't reply with the link
360
+
//
361
+
// NOTE: do we need this?
362
+
if line.NewSha.String() == line.OldSha.String() {
363
+
return nil
364
+
}
365
+
366
+
pushedRef := plumbing.ReferenceName(line.Ref)
367
+
368
+
userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
369
+
user := repoDid
370
+
if err == nil {
371
+
user = userIdent.Handle.String()
372
+
}
373
+
374
+
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
375
+
if err != nil {
376
+
return err
377
+
}
378
+
379
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
380
+
if err != nil {
381
+
return err
382
+
}
383
+
384
+
gr, err := git.PlainOpen(repoPath)
385
+
if err != nil {
386
+
return err
387
+
}
388
+
389
+
defaultBranch, err := gr.FindMainBranch()
390
+
if err != nil {
391
+
return err
392
+
}
393
+
394
+
// pushing to default branch
395
+
if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
396
+
return nil
397
+
}
398
+
399
+
// pushing a tag, don't prompt the user the open a PR
400
+
if pushedRef.IsTag() {
401
+
return nil
402
+
}
403
+
404
+
ZWS := "\u200B"
405
+
*clientMsgs = append(*clientMsgs, ZWS)
406
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
407
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
408
+
*clientMsgs = append(*clientMsgs, ZWS)
409
+
return nil
410
+
}
411
+
412
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
413
r := chi.NewRouter()
414
l := log.FromContext(ctx)
415
l = log.SubLogger(l, "internal")
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
417
418
h := InternalHandle{
419
db,
···
421
e,
422
l,
423
n,
424
+
res,
425
}
426
427
r.Get("/push-allowed", h.PushAllowed)
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
431
r.Mount("/debug", middleware.Profiler())
432
+1
-1
knotserver/router.go
+1
-1
knotserver/router.go
+21
-2
knotserver/xrpc/repo_blob.go
+21
-2
knotserver/xrpc/repo_blob.go
···
42
return
43
}
44
45
contents, err := gr.RawContent(treePath)
46
if err != nil {
47
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
101
var encoding string
102
103
isBinary := !isTextual(mimeType)
104
105
if isBinary {
106
content = base64.StdEncoding.EncodeToString(contents)
···
113
response := tangled.RepoBlob_Output{
114
Ref: ref,
115
Path: treePath,
116
-
Content: content,
117
Encoding: &encoding,
118
-
Size: &[]int64{int64(len(contents))}[0],
119
IsBinary: &isBinary,
120
}
121
···
42
return
43
}
44
45
+
// first check if this path is a submodule
46
+
submodule, err := gr.Submodule(treePath)
47
+
if err != nil {
48
+
// this is okay, continue and try to treat it as a regular file
49
+
} else {
50
+
response := tangled.RepoBlob_Output{
51
+
Ref: ref,
52
+
Path: treePath,
53
+
Submodule: &tangled.RepoBlob_Submodule{
54
+
Name: submodule.Name,
55
+
Url: submodule.URL,
56
+
Branch: &submodule.Branch,
57
+
},
58
+
}
59
+
writeJson(w, response)
60
+
return
61
+
}
62
+
63
contents, err := gr.RawContent(treePath)
64
if err != nil {
65
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
119
var encoding string
120
121
isBinary := !isTextual(mimeType)
122
+
size := int64(len(contents))
123
124
if isBinary {
125
content = base64.StdEncoding.EncodeToString(contents)
···
132
response := tangled.RepoBlob_Output{
133
Ref: ref,
134
Path: treePath,
135
+
Content: &content,
136
Encoding: &encoding,
137
+
Size: &size,
138
IsBinary: &isBinary,
139
}
140
+3
-5
knotserver/xrpc/repo_tree.go
+3
-5
knotserver/xrpc/repo_tree.go
···
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
for i, file := range files {
69
entry := &tangled.RepoTree_TreeEntry{
70
-
Name: file.Name,
71
-
Mode: file.Mode,
72
-
Size: file.Size,
73
-
Is_file: file.IsFile,
74
-
Is_subtree: file.IsSubtree,
75
}
76
77
if file.LastCommit != nil {
+5
lexicons/actor/profile.json
+5
lexicons/actor/profile.json
+49
-5
lexicons/repo/blob.json
+49
-5
lexicons/repo/blob.json
···
6
"type": "query",
7
"parameters": {
8
"type": "params",
9
-
"required": ["repo", "ref", "path"],
10
"properties": {
11
"repo": {
12
"type": "string",
···
31
"encoding": "application/json",
32
"schema": {
33
"type": "object",
34
-
"required": ["ref", "path", "content"],
35
"properties": {
36
"ref": {
37
"type": "string",
···
48
"encoding": {
49
"type": "string",
50
"description": "Content encoding",
51
-
"enum": ["utf-8", "base64"]
52
},
53
"size": {
54
"type": "integer",
···
61
"mimeType": {
62
"type": "string",
63
"description": "MIME type of the file"
64
},
65
"lastCommit": {
66
"type": "ref",
···
90
},
91
"lastCommit": {
92
"type": "object",
93
-
"required": ["hash", "message", "when"],
94
"properties": {
95
"hash": {
96
"type": "string",
···
117
},
118
"signature": {
119
"type": "object",
120
-
"required": ["name", "email", "when"],
121
"properties": {
122
"name": {
123
"type": "string",
···
131
"type": "string",
132
"format": "datetime",
133
"description": "Author timestamp"
134
}
135
}
136
}
···
6
"type": "query",
7
"parameters": {
8
"type": "params",
9
+
"required": [
10
+
"repo",
11
+
"ref",
12
+
"path"
13
+
],
14
"properties": {
15
"repo": {
16
"type": "string",
···
35
"encoding": "application/json",
36
"schema": {
37
"type": "object",
38
+
"required": [
39
+
"ref",
40
+
"path"
41
+
],
42
"properties": {
43
"ref": {
44
"type": "string",
···
55
"encoding": {
56
"type": "string",
57
"description": "Content encoding",
58
+
"enum": [
59
+
"utf-8",
60
+
"base64"
61
+
]
62
},
63
"size": {
64
"type": "integer",
···
71
"mimeType": {
72
"type": "string",
73
"description": "MIME type of the file"
74
+
},
75
+
"submodule": {
76
+
"type": "ref",
77
+
"ref": "#submodule",
78
+
"description": "Submodule information if path is a submodule"
79
},
80
"lastCommit": {
81
"type": "ref",
···
105
},
106
"lastCommit": {
107
"type": "object",
108
+
"required": [
109
+
"hash",
110
+
"message",
111
+
"when"
112
+
],
113
"properties": {
114
"hash": {
115
"type": "string",
···
136
},
137
"signature": {
138
"type": "object",
139
+
"required": [
140
+
"name",
141
+
"email",
142
+
"when"
143
+
],
144
"properties": {
145
"name": {
146
"type": "string",
···
154
"type": "string",
155
"format": "datetime",
156
"description": "Author timestamp"
157
+
}
158
+
}
159
+
},
160
+
"submodule": {
161
+
"type": "object",
162
+
"required": [
163
+
"name",
164
+
"url"
165
+
],
166
+
"properties": {
167
+
"name": {
168
+
"type": "string",
169
+
"description": "Submodule name"
170
+
},
171
+
"url": {
172
+
"type": "string",
173
+
"description": "Submodule repository URL"
174
+
},
175
+
"branch": {
176
+
"type": "string",
177
+
"description": "Branch to track in the submodule"
178
}
179
}
180
}
+15
lexicons/repo/repo.json
+15
lexicons/repo/repo.json
···
32
"minGraphemes": 1,
33
"maxGraphemes": 140
34
},
35
+
"website": {
36
+
"type": "string",
37
+
"format": "uri",
38
+
"description": "Any URI related to the repo"
39
+
},
40
+
"topics": {
41
+
"type": "array",
42
+
"description": "Topics related to the repo",
43
+
"items": {
44
+
"type": "string",
45
+
"minLength": 1,
46
+
"maxLength": 50
47
+
},
48
+
"maxLength": 50
49
+
},
50
"source": {
51
"type": "string",
52
"format": "uri",
+1
-9
lexicons/repo/tree.json
+1
-9
lexicons/repo/tree.json
···
91
},
92
"treeEntry": {
93
"type": "object",
94
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
95
"properties": {
96
"name": {
97
"type": "string",
···
104
"size": {
105
"type": "integer",
106
"description": "File size in bytes"
107
-
},
108
-
"is_file": {
109
-
"type": "boolean",
110
-
"description": "Whether this entry is a file"
111
-
},
112
-
"is_subtree": {
113
-
"type": "boolean",
114
-
"description": "Whether this entry is a directory/subtree"
115
},
116
"last_commit": {
117
"type": "ref",
+2
-2
nix/gomod2nix.toml
+2
-2
nix/gomod2nix.toml
···
109
version = "v0.0.0-20241210005130-ea96859b93d1"
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
111
[mod."github.com/bmatcuk/doublestar/v4"]
112
-
version = "v4.7.1"
113
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
114
[mod."github.com/carlmjohnson/versioninfo"]
115
version = "v0.22.5"
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
109
version = "v0.0.0-20241210005130-ea96859b93d1"
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
111
[mod."github.com/bmatcuk/doublestar/v4"]
112
+
version = "v4.9.1"
113
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
114
[mod."github.com/carlmjohnson/versioninfo"]
115
version = "v0.22.5"
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
+285
-18
nix/modules/appview.nix
+285
-18
nix/modules/appview.nix
···
3
lib,
4
...
5
}: let
6
-
cfg = config.services.tangled-appview;
7
in
8
with lib; {
9
options = {
10
-
services.tangled-appview = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
14
description = "Enable tangled appview";
15
};
16
package = mkOption {
17
type = types.package;
18
description = "Package to use for the appview";
19
};
20
port = mkOption {
21
-
type = types.int;
22
default = 3000;
23
description = "Port to run the appview on";
24
};
25
-
cookie_secret = mkOption {
26
type = types.str;
27
-
default = "00000000000000000000000000000000";
28
-
description = "Cookie secret";
29
};
30
environmentFile = mkOption {
31
type = with types; nullOr path;
32
default = null;
33
-
example = "/etc/tangled-appview.env";
34
description = ''
35
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
37
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
-
passed to the service without makeing them world readable in the
39
-
nix store.
40
-
41
'';
42
};
43
};
44
};
45
46
config = mkIf cfg.enable {
47
-
systemd.services.tangled-appview = {
48
description = "tangled appview service";
49
wantedBy = ["multi-user.target"];
50
51
serviceConfig = {
52
-
ListenStream = "0.0.0.0:${toString cfg.port}";
53
ExecStart = "${cfg.package}/bin/appview";
54
Restart = "always";
55
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
56
};
57
58
-
environment = {
59
-
TANGLED_DB_PATH = "appview.db";
60
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
-
};
62
};
63
};
64
}
···
3
lib,
4
...
5
}: let
6
+
cfg = config.services.tangled.appview;
7
in
8
with lib; {
9
options = {
10
+
services.tangled.appview = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
14
description = "Enable tangled appview";
15
};
16
+
17
package = mkOption {
18
type = types.package;
19
description = "Package to use for the appview";
20
};
21
+
22
+
# core configuration
23
port = mkOption {
24
+
type = types.port;
25
default = 3000;
26
description = "Port to run the appview on";
27
};
28
+
29
+
listenAddr = mkOption {
30
+
type = types.str;
31
+
default = "0.0.0.0:${toString cfg.port}";
32
+
description = "Listen address for the appview service";
33
+
};
34
+
35
+
dbPath = mkOption {
36
type = types.str;
37
+
default = "/var/lib/appview/appview.db";
38
+
description = "Path to the SQLite database file";
39
+
};
40
+
41
+
appviewHost = mkOption {
42
+
type = types.str;
43
+
default = "https://tangled.org";
44
+
example = "https://example.com";
45
+
description = "Public host URL for the appview instance";
46
+
};
47
+
48
+
appviewName = mkOption {
49
+
type = types.str;
50
+
default = "Tangled";
51
+
description = "Display name for the appview instance";
52
+
};
53
+
54
+
dev = mkOption {
55
+
type = types.bool;
56
+
default = false;
57
+
description = "Enable development mode";
58
+
};
59
+
60
+
disallowedNicknamesFile = mkOption {
61
+
type = types.nullOr types.path;
62
+
default = null;
63
+
description = "Path to file containing disallowed nicknames";
64
+
};
65
+
66
+
# redis configuration
67
+
redis = {
68
+
addr = mkOption {
69
+
type = types.str;
70
+
default = "localhost:6379";
71
+
description = "Redis server address";
72
+
};
73
+
74
+
db = mkOption {
75
+
type = types.int;
76
+
default = 0;
77
+
description = "Redis database number";
78
+
};
79
+
};
80
+
81
+
# jetstream configuration
82
+
jetstream = {
83
+
endpoint = mkOption {
84
+
type = types.str;
85
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
86
+
description = "Jetstream WebSocket endpoint";
87
+
};
88
+
};
89
+
90
+
# knotstream consumer configuration
91
+
knotstream = {
92
+
retryInterval = mkOption {
93
+
type = types.str;
94
+
default = "60s";
95
+
description = "Initial retry interval for knotstream consumer";
96
+
};
97
+
98
+
maxRetryInterval = mkOption {
99
+
type = types.str;
100
+
default = "120m";
101
+
description = "Maximum retry interval for knotstream consumer";
102
+
};
103
+
104
+
connectionTimeout = mkOption {
105
+
type = types.str;
106
+
default = "5s";
107
+
description = "Connection timeout for knotstream consumer";
108
+
};
109
+
110
+
workerCount = mkOption {
111
+
type = types.int;
112
+
default = 64;
113
+
description = "Number of workers for knotstream consumer";
114
+
};
115
+
116
+
queueSize = mkOption {
117
+
type = types.int;
118
+
default = 100;
119
+
description = "Queue size for knotstream consumer";
120
+
};
121
+
};
122
+
123
+
# spindlestream consumer configuration
124
+
spindlestream = {
125
+
retryInterval = mkOption {
126
+
type = types.str;
127
+
default = "60s";
128
+
description = "Initial retry interval for spindlestream consumer";
129
+
};
130
+
131
+
maxRetryInterval = mkOption {
132
+
type = types.str;
133
+
default = "120m";
134
+
description = "Maximum retry interval for spindlestream consumer";
135
+
};
136
+
137
+
connectionTimeout = mkOption {
138
+
type = types.str;
139
+
default = "5s";
140
+
description = "Connection timeout for spindlestream consumer";
141
+
};
142
+
143
+
workerCount = mkOption {
144
+
type = types.int;
145
+
default = 64;
146
+
description = "Number of workers for spindlestream consumer";
147
+
};
148
+
149
+
queueSize = mkOption {
150
+
type = types.int;
151
+
default = 100;
152
+
description = "Queue size for spindlestream consumer";
153
+
};
154
+
};
155
+
156
+
# resend configuration
157
+
resend = {
158
+
sentFrom = mkOption {
159
+
type = types.str;
160
+
default = "noreply@notifs.tangled.sh";
161
+
description = "Email address to send notifications from";
162
+
};
163
+
};
164
+
165
+
# posthog configuration
166
+
posthog = {
167
+
endpoint = mkOption {
168
+
type = types.str;
169
+
default = "https://eu.i.posthog.com";
170
+
description = "PostHog API endpoint";
171
+
};
172
+
};
173
+
174
+
# camo configuration
175
+
camo = {
176
+
host = mkOption {
177
+
type = types.str;
178
+
default = "https://camo.tangled.sh";
179
+
description = "Camo proxy host URL";
180
+
};
181
};
182
+
183
+
# avatar configuration
184
+
avatar = {
185
+
host = mkOption {
186
+
type = types.str;
187
+
default = "https://avatar.tangled.sh";
188
+
description = "Avatar service host URL";
189
+
};
190
+
};
191
+
192
+
plc = {
193
+
url = mkOption {
194
+
type = types.str;
195
+
default = "https://plc.directory";
196
+
description = "PLC directory URL";
197
+
};
198
+
};
199
+
200
+
pds = {
201
+
host = mkOption {
202
+
type = types.str;
203
+
default = "https://tngl.sh";
204
+
description = "PDS host URL";
205
+
};
206
+
};
207
+
208
+
label = {
209
+
defaults = mkOption {
210
+
type = types.listOf types.str;
211
+
default = [
212
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
213
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
214
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
215
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
216
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
217
+
];
218
+
description = "Default label definitions";
219
+
};
220
+
221
+
goodFirstIssue = mkOption {
222
+
type = types.str;
223
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
224
+
description = "Good first issue label definition";
225
+
};
226
+
};
227
+
228
environmentFile = mkOption {
229
type = with types; nullOr path;
230
default = null;
231
+
example = "/etc/appview.env";
232
description = ''
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
234
235
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
236
+
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
237
+
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
238
+
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
239
+
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
240
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
241
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
242
+
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
243
+
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
244
+
without making them world readable in the nix store.
245
'';
246
};
247
};
248
};
249
250
config = mkIf cfg.enable {
251
+
services.redis.servers.appview = {
252
+
enable = true;
253
+
port = 6379;
254
+
};
255
+
256
+
systemd.services.appview = {
257
description = "tangled appview service";
258
wantedBy = ["multi-user.target"];
259
+
after = ["redis-appview.service" "network-online.target"];
260
+
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
262
263
serviceConfig = {
264
+
Type = "simple";
265
ExecStart = "${cfg.package}/bin/appview";
266
Restart = "always";
267
+
RestartSec = "10s";
268
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
269
+
270
+
# state directory
271
+
StateDirectory = "appview";
272
+
WorkingDirectory = "/var/lib/appview";
273
+
274
+
# security hardening
275
+
NoNewPrivileges = true;
276
+
PrivateTmp = true;
277
+
ProtectSystem = "strict";
278
+
ProtectHome = true;
279
+
ReadWritePaths = ["/var/lib/appview"];
280
};
281
282
+
environment =
283
+
{
284
+
TANGLED_DB_PATH = cfg.dbPath;
285
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
286
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
287
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
288
+
TANGLED_DEV =
289
+
if cfg.dev
290
+
then "true"
291
+
else "false";
292
+
}
293
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
294
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
295
+
}
296
+
// {
297
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
298
+
TANGLED_REDIS_DB = toString cfg.redis.db;
299
+
300
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
301
+
302
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
303
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
304
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
305
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
306
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
307
+
308
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
309
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
310
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
311
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
312
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
313
+
314
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
315
+
316
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
317
+
318
+
TANGLED_CAMO_HOST = cfg.camo.host;
319
+
320
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
321
+
322
+
TANGLED_PLC_URL = cfg.plc.url;
323
+
324
+
TANGLED_PDS_HOST = cfg.pds.host;
325
+
326
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
327
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
328
+
};
329
};
330
};
331
}
+74
-4
nix/modules/knot.nix
+74
-4
nix/modules/knot.nix
···
4
lib,
5
...
6
}: let
7
-
cfg = config.services.tangled-knot;
8
in
9
with lib; {
10
options = {
11
-
services.tangled-knot = {
12
enable = mkOption {
13
type = types.bool;
14
default = false;
···
51
description = "Path where repositories are scanned from";
52
};
53
54
mainBranch = mkOption {
55
type = types.str;
56
default = "main";
57
description = "Default branch name for repositories";
58
};
59
};
60
···
111
description = "Hostname for the server (required)";
112
};
113
114
dev = mkOption {
115
type = types.bool;
116
default = false;
···
178
mkdir -p "${cfg.stateDir}/.config/git"
179
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
[user]
181
-
name = Git User
182
-
email = git@example.com
183
[receive]
184
advertisePushOptions = true
185
EOF
186
${setMotd}
187
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
193
WorkingDirectory = cfg.stateDir;
194
Environment = [
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
196
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
197
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
198
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
199
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
202
"KNOT_SERVER_OWNER=${cfg.server.owner}"
203
];
204
ExecStart = "${cfg.package}/bin/knot server";
205
Restart = "always";
···
4
lib,
5
...
6
}: let
7
+
cfg = config.services.tangled.knot;
8
in
9
with lib; {
10
options = {
11
+
services.tangled.knot = {
12
enable = mkOption {
13
type = types.bool;
14
default = false;
···
51
description = "Path where repositories are scanned from";
52
};
53
54
+
readme = mkOption {
55
+
type = types.listOf types.str;
56
+
default = [
57
+
"README.md"
58
+
"readme.md"
59
+
"README"
60
+
"readme"
61
+
"README.markdown"
62
+
"readme.markdown"
63
+
"README.txt"
64
+
"readme.txt"
65
+
"README.rst"
66
+
"readme.rst"
67
+
"README.org"
68
+
"readme.org"
69
+
"README.asciidoc"
70
+
"readme.asciidoc"
71
+
];
72
+
description = "List of README filenames to look for (in priority order)";
73
+
};
74
+
75
mainBranch = mkOption {
76
type = types.str;
77
default = "main";
78
description = "Default branch name for repositories";
79
+
};
80
+
};
81
+
82
+
git = {
83
+
userName = mkOption {
84
+
type = types.str;
85
+
default = "Tangled";
86
+
description = "Git user name used as committer";
87
+
};
88
+
89
+
userEmail = mkOption {
90
+
type = types.str;
91
+
default = "noreply@tangled.org";
92
+
description = "Git user email used as committer";
93
};
94
};
95
···
146
description = "Hostname for the server (required)";
147
};
148
149
+
plcUrl = mkOption {
150
+
type = types.str;
151
+
default = "https://plc.directory";
152
+
description = "atproto PLC directory";
153
+
};
154
+
155
+
jetstreamEndpoint = mkOption {
156
+
type = types.str;
157
+
default = "wss://jetstream1.us-west.bsky.network/subscribe";
158
+
description = "Jetstream endpoint to subscribe to";
159
+
};
160
+
161
+
logDids = mkOption {
162
+
type = types.bool;
163
+
default = true;
164
+
description = "Enable logging of DIDs";
165
+
};
166
+
167
dev = mkOption {
168
type = types.bool;
169
default = false;
···
231
mkdir -p "${cfg.stateDir}/.config/git"
232
cat > "${cfg.stateDir}/.config/git/config" << EOF
233
[user]
234
+
name = ${cfg.git.userName}
235
+
email = ${cfg.git.userEmail}
236
[receive]
237
advertisePushOptions = true
238
+
[uploadpack]
239
+
allowFilter = true
240
EOF
241
${setMotd}
242
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
248
WorkingDirectory = cfg.stateDir;
249
Environment = [
250
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
252
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
255
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
256
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
257
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
258
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
259
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
260
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
261
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
262
"KNOT_SERVER_OWNER=${cfg.server.owner}"
263
+
"KNOT_SERVER_LOG_DIDS=${
264
+
if cfg.server.logDids
265
+
then "true"
266
+
else "false"
267
+
}"
268
+
"KNOT_SERVER_DEV=${
269
+
if cfg.server.dev
270
+
then "true"
271
+
else "false"
272
+
}"
273
];
274
ExecStart = "${cfg.package}/bin/knot server";
275
Restart = "always";
+10
-3
nix/modules/spindle.nix
+10
-3
nix/modules/spindle.nix
···
3
lib,
4
...
5
}: let
6
-
cfg = config.services.tangled-spindle;
7
in
8
with lib; {
9
options = {
10
-
services.tangled-spindle = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
···
35
type = types.str;
36
example = "my.spindle.com";
37
description = "Hostname for the server (required)";
38
};
39
40
jetstreamEndpoint = mkOption {
···
119
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
120
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
121
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
122
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
123
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
124
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
···
3
lib,
4
...
5
}: let
6
+
cfg = config.services.tangled.spindle;
7
in
8
with lib; {
9
options = {
10
+
services.tangled.spindle = {
11
enable = mkOption {
12
type = types.bool;
13
default = false;
···
35
type = types.str;
36
example = "my.spindle.com";
37
description = "Hostname for the server (required)";
38
+
};
39
+
40
+
plcUrl = mkOption {
41
+
type = types.str;
42
+
default = "https://plc.directory";
43
+
description = "atproto PLC directory";
44
};
45
46
jetstreamEndpoint = mkOption {
···
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
126
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
128
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
132
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
+2
nix/pkgs/appview-static-files.nix
···
5
lucide-src,
6
inter-fonts-src,
7
ibm-plex-mono-src,
8
sqlite-lib,
9
tailwindcss,
10
src,
···
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
27
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
# for whatever reason (produces broken css), so we are doing this instead
29
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
···
5
lucide-src,
6
inter-fonts-src,
7
ibm-plex-mono-src,
8
+
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
src,
···
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
# for whatever reason (produces broken css), so we are doing this instead
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+1
-1
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+21
-8
nix/vm.nix
+21
-8
nix/vm.nix
···
10
if var == ""
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
else var;
13
in
14
nixpkgs.lib.nixosSystem {
15
inherit system;
···
73
time.timeZone = "Europe/London";
74
services.getty.autologinUser = "root";
75
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
-
services.tangled-knot = {
77
enable = true;
78
motd = "Welcome to the development knot!\n";
79
server = {
80
owner = envVar "TANGLED_VM_KNOT_OWNER";
81
-
hostname = "localhost:6000";
82
listenAddr = "0.0.0.0:6000";
83
};
84
};
85
-
services.tangled-spindle = {
86
enable = true;
87
server = {
88
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
89
-
hostname = "localhost:6555";
90
listenAddr = "0.0.0.0:6555";
91
dev = true;
92
queueSize = 100;
···
99
users = {
100
# So we don't have to deal with permission clashing between
101
# blank disk VMs and existing state
102
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
104
105
# TODO: separate spindle user
106
};
···
120
serviceConfig.PermissionsStartOnly = true;
121
};
122
in {
123
-
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
124
-
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
125
};
126
})
127
];
···
10
if var == ""
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
else var;
13
+
envVarOr = name: default: let
14
+
var = builtins.getEnv name;
15
+
in
16
+
if var != ""
17
+
then var
18
+
else default;
19
+
20
+
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
21
+
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
22
in
23
nixpkgs.lib.nixosSystem {
24
inherit system;
···
82
time.timeZone = "Europe/London";
83
services.getty.autologinUser = "root";
84
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
85
+
services.tangled.knot = {
86
enable = true;
87
motd = "Welcome to the development knot!\n";
88
server = {
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
+
plcUrl = plcUrl;
92
+
jetstreamEndpoint = jetstream;
93
listenAddr = "0.0.0.0:6000";
94
};
95
};
96
+
services.tangled.spindle = {
97
enable = true;
98
server = {
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
+
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
103
listenAddr = "0.0.0.0:6555";
104
dev = true;
105
queueSize = 100;
···
112
users = {
113
# So we don't have to deal with permission clashing between
114
# blank disk VMs and existing state
115
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
117
118
# TODO: separate spindle user
119
};
···
133
serviceConfig.PermissionsStartOnly = true;
134
};
135
in {
136
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
137
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
138
};
139
})
140
];
+1
spindle/config/config.go
+1
spindle/config/config.go
···
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
Hostname string `env:"HOSTNAME, required"`
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
Dev bool `env:"DEV, default=false"`
17
Owner string `env:"OWNER, required"`
18
Secrets Secrets `env:",prefix=SECRETS_"`
···
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
Hostname string `env:"HOSTNAME, required"`
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
Dev bool `env:"DEV, default=false"`
18
Owner string `env:"OWNER, required"`
19
Secrets Secrets `env:",prefix=SECRETS_"`
+1
-1
spindle/engines/nixery/engine.go
+1
-1
spindle/engines/nixery/engine.go
-73
spindle/engines/nixery/setup_steps.go
-73
spindle/engines/nixery/setup_steps.go
···
2
3
import (
4
"fmt"
5
-
"path"
6
"strings"
7
-
8
-
"tangled.org/core/api/tangled"
9
-
"tangled.org/core/workflow"
10
)
11
12
func nixConfStep() Step {
···
17
command: setupCmd,
18
name: "Configure Nix",
19
}
20
-
}
21
-
22
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
23
-
// to the beginning of the workflow's step list if cloning is not skipped.
24
-
//
25
-
// the steps to do here are:
26
-
// - git init
27
-
// - git remote add origin <url>
28
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
29
-
// - git checkout FETCH_HEAD
30
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
31
-
if twf.Clone.Skip {
32
-
return Step{}
33
-
}
34
-
35
-
var commands []string
36
-
37
-
// initialize git repo in workspace
38
-
commands = append(commands, "git init")
39
-
40
-
// add repo as git remote
41
-
scheme := "https://"
42
-
if dev {
43
-
scheme = "http://"
44
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
45
-
}
46
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
47
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
48
-
49
-
// run git fetch
50
-
{
51
-
var fetchArgs []string
52
-
53
-
// default clone depth is 1
54
-
depth := 1
55
-
if twf.Clone.Depth > 1 {
56
-
depth = int(twf.Clone.Depth)
57
-
}
58
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
59
-
60
-
// optionally recurse submodules
61
-
if twf.Clone.Submodules {
62
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
63
-
}
64
-
65
-
// set remote to fetch from
66
-
fetchArgs = append(fetchArgs, "origin")
67
-
68
-
// set revision to checkout
69
-
switch workflow.TriggerKind(tr.Kind) {
70
-
case workflow.TriggerKindManual:
71
-
// TODO: unimplemented
72
-
case workflow.TriggerKindPush:
73
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
74
-
case workflow.TriggerKindPullRequest:
75
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
76
-
}
77
-
78
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
79
-
}
80
-
81
-
// run git checkout
82
-
commands = append(commands, "git checkout FETCH_HEAD")
83
-
84
-
cloneStep := Step{
85
-
command: strings.Join(commands, "\n"),
86
-
name: "Clone repository into workspace",
87
-
}
88
-
return cloneStep
89
}
90
91
// dependencyStep processes dependencies defined in the workflow.
+3
-7
spindle/ingester.go
+3
-7
spindle/ingester.go
···
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
···
142
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
143
var err error
144
did := e.Did
145
-
resolver := idresolver.DefaultResolver()
146
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
···
190
}
191
192
// add collaborators to rbac
193
-
owner, err := resolver.ResolveIdent(ctx, did)
194
if err != nil || owner.Handle.IsInvalidHandle() {
195
return err
196
}
···
225
return err
226
}
227
228
-
resolver := idresolver.DefaultResolver()
229
-
230
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
231
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
return err
233
}
···
240
241
// TODO: get rid of this entirely
242
// resolve this aturi to extract the repo record
243
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
244
if err != nil || owner.Handle.IsInvalidHandle() {
245
return fmt.Errorf("failed to resolve handle: %w", err)
246
}
···
9
10
"tangled.org/core/api/tangled"
11
"tangled.org/core/eventconsumer"
12
"tangled.org/core/rbac"
13
"tangled.org/core/spindle/db"
14
···
141
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
142
var err error
143
did := e.Did
144
145
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
146
···
188
}
189
190
// add collaborators to rbac
191
+
owner, err := s.res.ResolveIdent(ctx, did)
192
if err != nil || owner.Handle.IsInvalidHandle() {
193
return err
194
}
···
223
return err
224
}
225
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
227
if err != nil || subjectId.Handle.IsInvalidHandle() {
228
return err
229
}
···
236
237
// TODO: get rid of this entirely
238
// resolve this aturi to extract the repo record
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
240
if err != nil || owner.Handle.IsInvalidHandle() {
241
return fmt.Errorf("failed to resolve handle: %w", err)
242
}
+151
spindle/models/clone.go
+151
spindle/models/clone.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/workflow"
9
+
)
10
+
11
+
type CloneStep struct {
12
+
name string
13
+
kind StepKind
14
+
commands []string
15
+
}
16
+
17
+
func (s CloneStep) Name() string {
18
+
return s.name
19
+
}
20
+
21
+
func (s CloneStep) Commands() []string {
22
+
return s.commands
23
+
}
24
+
25
+
func (s CloneStep) Command() string {
26
+
return strings.Join(s.commands, "\n")
27
+
}
28
+
29
+
func (s CloneStep) Kind() StepKind {
30
+
return s.kind
31
+
}
32
+
33
+
// BuildCloneStep generates git clone commands.
34
+
// The caller must ensure the current working directory is set to the desired
35
+
// workspace directory before executing these commands.
36
+
//
37
+
// The generated commands are:
38
+
// - git init
39
+
// - git remote add origin <url>
40
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
41
+
// - git checkout FETCH_HEAD
42
+
//
43
+
// Supports all trigger types (push, PR, manual) and clone options.
44
+
func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) CloneStep {
45
+
if twf.Clone != nil && twf.Clone.Skip {
46
+
return CloneStep{}
47
+
}
48
+
49
+
commitSHA, err := extractCommitSHA(tr)
50
+
if err != nil {
51
+
return CloneStep{
52
+
kind: StepKindSystem,
53
+
name: "Clone repository into workspace (error)",
54
+
commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())},
55
+
}
56
+
}
57
+
58
+
repoURL := buildRepoURL(tr, dev)
59
+
60
+
var cloneOpts tangled.Pipeline_CloneOpts
61
+
if twf.Clone != nil {
62
+
cloneOpts = *twf.Clone
63
+
}
64
+
fetchArgs := buildFetchArgs(cloneOpts, commitSHA)
65
+
66
+
return CloneStep{
67
+
kind: StepKindSystem,
68
+
name: "Clone repository into workspace",
69
+
commands: []string{
70
+
"git init",
71
+
fmt.Sprintf("git remote add origin %s", repoURL),
72
+
fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")),
73
+
"git checkout FETCH_HEAD",
74
+
},
75
+
}
76
+
}
77
+
78
+
// extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type
79
+
func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) {
80
+
switch workflow.TriggerKind(tr.Kind) {
81
+
case workflow.TriggerKindPush:
82
+
if tr.Push == nil {
83
+
return "", fmt.Errorf("push trigger metadata is nil")
84
+
}
85
+
return tr.Push.NewSha, nil
86
+
87
+
case workflow.TriggerKindPullRequest:
88
+
if tr.PullRequest == nil {
89
+
return "", fmt.Errorf("pull request trigger metadata is nil")
90
+
}
91
+
return tr.PullRequest.SourceSha, nil
92
+
93
+
case workflow.TriggerKindManual:
94
+
// Manual triggers don't have an explicit SHA in the metadata
95
+
// For now, return empty string - could be enhanced to fetch from default branch
96
+
// TODO: Implement manual trigger SHA resolution (fetch default branch HEAD)
97
+
return "", nil
98
+
99
+
default:
100
+
return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind)
101
+
}
102
+
}
103
+
104
+
// buildRepoURL constructs the repository URL from trigger metadata
105
+
func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string {
106
+
if tr.Repo == nil {
107
+
return ""
108
+
}
109
+
110
+
// Determine protocol
111
+
scheme := "https://"
112
+
if devMode {
113
+
scheme = "http://"
114
+
}
115
+
116
+
// Get host from knot
117
+
host := tr.Repo.Knot
118
+
119
+
// In dev mode, replace localhost with host.docker.internal for Docker networking
120
+
if devMode && strings.Contains(host, "localhost") {
121
+
host = strings.ReplaceAll(host, "localhost", "host.docker.internal")
122
+
}
123
+
124
+
// Build URL: {scheme}{knot}/{did}/{repo}
125
+
return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo)
126
+
}
127
+
128
+
// buildFetchArgs constructs the arguments for git fetch based on clone options
129
+
func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string {
130
+
args := []string{}
131
+
132
+
// Set fetch depth (default to 1 for shallow clone)
133
+
depth := clone.Depth
134
+
if depth == 0 {
135
+
depth = 1
136
+
}
137
+
args = append(args, fmt.Sprintf("--depth=%d", depth))
138
+
139
+
// Add submodules if requested
140
+
if clone.Submodules {
141
+
args = append(args, "--recurse-submodules=yes")
142
+
}
143
+
144
+
// Add remote and SHA
145
+
args = append(args, "origin")
146
+
if sha != "" {
147
+
args = append(args, sha)
148
+
}
149
+
150
+
return args
151
+
}
+371
spindle/models/clone_test.go
+371
spindle/models/clone_test.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"strings"
5
+
"testing"
6
+
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/workflow"
9
+
)
10
+
11
+
func TestBuildCloneStep_PushTrigger(t *testing.T) {
12
+
twf := tangled.Pipeline_Workflow{
13
+
Clone: &tangled.Pipeline_CloneOpts{
14
+
Depth: 1,
15
+
Submodules: false,
16
+
Skip: false,
17
+
},
18
+
}
19
+
tr := tangled.Pipeline_TriggerMetadata{
20
+
Kind: string(workflow.TriggerKindPush),
21
+
Push: &tangled.Pipeline_PushTriggerData{
22
+
NewSha: "abc123",
23
+
OldSha: "def456",
24
+
Ref: "refs/heads/main",
25
+
},
26
+
Repo: &tangled.Pipeline_TriggerRepo{
27
+
Knot: "example.com",
28
+
Did: "did:plc:user123",
29
+
Repo: "my-repo",
30
+
},
31
+
}
32
+
33
+
step := BuildCloneStep(twf, tr, false)
34
+
35
+
if step.Kind() != StepKindSystem {
36
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
37
+
}
38
+
39
+
if step.Name() != "Clone repository into workspace" {
40
+
t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name())
41
+
}
42
+
43
+
commands := step.Commands()
44
+
if len(commands) != 4 {
45
+
t.Errorf("Expected 4 commands, got %d", len(commands))
46
+
}
47
+
48
+
// Verify commands contain expected git operations
49
+
allCmds := strings.Join(commands, " ")
50
+
if !strings.Contains(allCmds, "git init") {
51
+
t.Error("Commands should contain 'git init'")
52
+
}
53
+
if !strings.Contains(allCmds, "git remote add origin") {
54
+
t.Error("Commands should contain 'git remote add origin'")
55
+
}
56
+
if !strings.Contains(allCmds, "git fetch") {
57
+
t.Error("Commands should contain 'git fetch'")
58
+
}
59
+
if !strings.Contains(allCmds, "abc123") {
60
+
t.Error("Commands should contain commit SHA")
61
+
}
62
+
if !strings.Contains(allCmds, "git checkout FETCH_HEAD") {
63
+
t.Error("Commands should contain 'git checkout FETCH_HEAD'")
64
+
}
65
+
if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") {
66
+
t.Error("Commands should contain expected repo URL")
67
+
}
68
+
}
69
+
70
+
func TestBuildCloneStep_PullRequestTrigger(t *testing.T) {
71
+
twf := tangled.Pipeline_Workflow{
72
+
Clone: &tangled.Pipeline_CloneOpts{
73
+
Depth: 1,
74
+
Skip: false,
75
+
},
76
+
}
77
+
tr := tangled.Pipeline_TriggerMetadata{
78
+
Kind: string(workflow.TriggerKindPullRequest),
79
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
80
+
SourceSha: "pr-sha-789",
81
+
SourceBranch: "feature-branch",
82
+
TargetBranch: "main",
83
+
Action: "opened",
84
+
},
85
+
Repo: &tangled.Pipeline_TriggerRepo{
86
+
Knot: "example.com",
87
+
Did: "did:plc:user123",
88
+
Repo: "my-repo",
89
+
},
90
+
}
91
+
92
+
step := BuildCloneStep(twf, tr, false)
93
+
94
+
allCmds := strings.Join(step.Commands(), " ")
95
+
if !strings.Contains(allCmds, "pr-sha-789") {
96
+
t.Error("Commands should contain PR commit SHA")
97
+
}
98
+
}
99
+
100
+
func TestBuildCloneStep_ManualTrigger(t *testing.T) {
101
+
twf := tangled.Pipeline_Workflow{
102
+
Clone: &tangled.Pipeline_CloneOpts{
103
+
Depth: 1,
104
+
Skip: false,
105
+
},
106
+
}
107
+
tr := tangled.Pipeline_TriggerMetadata{
108
+
Kind: string(workflow.TriggerKindManual),
109
+
Manual: &tangled.Pipeline_ManualTriggerData{
110
+
Inputs: nil,
111
+
},
112
+
Repo: &tangled.Pipeline_TriggerRepo{
113
+
Knot: "example.com",
114
+
Did: "did:plc:user123",
115
+
Repo: "my-repo",
116
+
},
117
+
}
118
+
119
+
step := BuildCloneStep(twf, tr, false)
120
+
121
+
// Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA
122
+
allCmds := strings.Join(step.Commands(), " ")
123
+
// Should still have basic git commands
124
+
if !strings.Contains(allCmds, "git init") {
125
+
t.Error("Commands should contain 'git init'")
126
+
}
127
+
if !strings.Contains(allCmds, "git fetch") {
128
+
t.Error("Commands should contain 'git fetch'")
129
+
}
130
+
}
131
+
132
+
func TestBuildCloneStep_SkipFlag(t *testing.T) {
133
+
twf := tangled.Pipeline_Workflow{
134
+
Clone: &tangled.Pipeline_CloneOpts{
135
+
Skip: true,
136
+
},
137
+
}
138
+
tr := tangled.Pipeline_TriggerMetadata{
139
+
Kind: string(workflow.TriggerKindPush),
140
+
Push: &tangled.Pipeline_PushTriggerData{
141
+
NewSha: "abc123",
142
+
},
143
+
Repo: &tangled.Pipeline_TriggerRepo{
144
+
Knot: "example.com",
145
+
Did: "did:plc:user123",
146
+
Repo: "my-repo",
147
+
},
148
+
}
149
+
150
+
step := BuildCloneStep(twf, tr, false)
151
+
152
+
// Empty step when skip is true
153
+
if step.Name() != "" {
154
+
t.Error("Expected empty step name when Skip is true")
155
+
}
156
+
if len(step.Commands()) != 0 {
157
+
t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands()))
158
+
}
159
+
}
160
+
161
+
func TestBuildCloneStep_DevMode(t *testing.T) {
162
+
twf := tangled.Pipeline_Workflow{
163
+
Clone: &tangled.Pipeline_CloneOpts{
164
+
Depth: 1,
165
+
Skip: false,
166
+
},
167
+
}
168
+
tr := tangled.Pipeline_TriggerMetadata{
169
+
Kind: string(workflow.TriggerKindPush),
170
+
Push: &tangled.Pipeline_PushTriggerData{
171
+
NewSha: "abc123",
172
+
},
173
+
Repo: &tangled.Pipeline_TriggerRepo{
174
+
Knot: "localhost:3000",
175
+
Did: "did:plc:user123",
176
+
Repo: "my-repo",
177
+
},
178
+
}
179
+
180
+
step := BuildCloneStep(twf, tr, true)
181
+
182
+
// In dev mode, should use http:// and replace localhost with host.docker.internal
183
+
allCmds := strings.Join(step.Commands(), " ")
184
+
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
185
+
if !strings.Contains(allCmds, expectedURL) {
186
+
t.Errorf("Expected dev mode URL '%s' in commands", expectedURL)
187
+
}
188
+
}
189
+
190
+
func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) {
191
+
twf := tangled.Pipeline_Workflow{
192
+
Clone: &tangled.Pipeline_CloneOpts{
193
+
Depth: 10,
194
+
Submodules: true,
195
+
Skip: false,
196
+
},
197
+
}
198
+
tr := tangled.Pipeline_TriggerMetadata{
199
+
Kind: string(workflow.TriggerKindPush),
200
+
Push: &tangled.Pipeline_PushTriggerData{
201
+
NewSha: "abc123",
202
+
},
203
+
Repo: &tangled.Pipeline_TriggerRepo{
204
+
Knot: "example.com",
205
+
Did: "did:plc:user123",
206
+
Repo: "my-repo",
207
+
},
208
+
}
209
+
210
+
step := BuildCloneStep(twf, tr, false)
211
+
212
+
allCmds := strings.Join(step.Commands(), " ")
213
+
if !strings.Contains(allCmds, "--depth=10") {
214
+
t.Error("Commands should contain '--depth=10'")
215
+
}
216
+
217
+
if !strings.Contains(allCmds, "--recurse-submodules=yes") {
218
+
t.Error("Commands should contain '--recurse-submodules=yes'")
219
+
}
220
+
}
221
+
222
+
func TestBuildCloneStep_DefaultDepth(t *testing.T) {
223
+
twf := tangled.Pipeline_Workflow{
224
+
Clone: &tangled.Pipeline_CloneOpts{
225
+
Depth: 0, // Default should be 1
226
+
Skip: false,
227
+
},
228
+
}
229
+
tr := tangled.Pipeline_TriggerMetadata{
230
+
Kind: string(workflow.TriggerKindPush),
231
+
Push: &tangled.Pipeline_PushTriggerData{
232
+
NewSha: "abc123",
233
+
},
234
+
Repo: &tangled.Pipeline_TriggerRepo{
235
+
Knot: "example.com",
236
+
Did: "did:plc:user123",
237
+
Repo: "my-repo",
238
+
},
239
+
}
240
+
241
+
step := BuildCloneStep(twf, tr, false)
242
+
243
+
allCmds := strings.Join(step.Commands(), " ")
244
+
if !strings.Contains(allCmds, "--depth=1") {
245
+
t.Error("Commands should default to '--depth=1'")
246
+
}
247
+
}
248
+
249
+
func TestBuildCloneStep_NilPushData(t *testing.T) {
250
+
twf := tangled.Pipeline_Workflow{
251
+
Clone: &tangled.Pipeline_CloneOpts{
252
+
Depth: 1,
253
+
Skip: false,
254
+
},
255
+
}
256
+
tr := tangled.Pipeline_TriggerMetadata{
257
+
Kind: string(workflow.TriggerKindPush),
258
+
Push: nil, // Nil push data should create error step
259
+
Repo: &tangled.Pipeline_TriggerRepo{
260
+
Knot: "example.com",
261
+
Did: "did:plc:user123",
262
+
Repo: "my-repo",
263
+
},
264
+
}
265
+
266
+
step := BuildCloneStep(twf, tr, false)
267
+
268
+
// Should return an error step
269
+
if !strings.Contains(step.Name(), "error") {
270
+
t.Error("Expected error in step name when push data is nil")
271
+
}
272
+
273
+
allCmds := strings.Join(step.Commands(), " ")
274
+
if !strings.Contains(allCmds, "Failed to get clone info") {
275
+
t.Error("Commands should contain error message")
276
+
}
277
+
if !strings.Contains(allCmds, "exit 1") {
278
+
t.Error("Commands should exit with error")
279
+
}
280
+
}
281
+
282
+
func TestBuildCloneStep_NilPRData(t *testing.T) {
283
+
twf := tangled.Pipeline_Workflow{
284
+
Clone: &tangled.Pipeline_CloneOpts{
285
+
Depth: 1,
286
+
Skip: false,
287
+
},
288
+
}
289
+
tr := tangled.Pipeline_TriggerMetadata{
290
+
Kind: string(workflow.TriggerKindPullRequest),
291
+
PullRequest: nil, // Nil PR data should create error step
292
+
Repo: &tangled.Pipeline_TriggerRepo{
293
+
Knot: "example.com",
294
+
Did: "did:plc:user123",
295
+
Repo: "my-repo",
296
+
},
297
+
}
298
+
299
+
step := BuildCloneStep(twf, tr, false)
300
+
301
+
// Should return an error step
302
+
if !strings.Contains(step.Name(), "error") {
303
+
t.Error("Expected error in step name when pull request data is nil")
304
+
}
305
+
306
+
allCmds := strings.Join(step.Commands(), " ")
307
+
if !strings.Contains(allCmds, "Failed to get clone info") {
308
+
t.Error("Commands should contain error message")
309
+
}
310
+
}
311
+
312
+
func TestBuildCloneStep_UnknownTriggerKind(t *testing.T) {
313
+
twf := tangled.Pipeline_Workflow{
314
+
Clone: &tangled.Pipeline_CloneOpts{
315
+
Depth: 1,
316
+
Skip: false,
317
+
},
318
+
}
319
+
tr := tangled.Pipeline_TriggerMetadata{
320
+
Kind: "unknown_trigger",
321
+
Repo: &tangled.Pipeline_TriggerRepo{
322
+
Knot: "example.com",
323
+
Did: "did:plc:user123",
324
+
Repo: "my-repo",
325
+
},
326
+
}
327
+
328
+
step := BuildCloneStep(twf, tr, false)
329
+
330
+
// Should return an error step
331
+
if !strings.Contains(step.Name(), "error") {
332
+
t.Error("Expected error in step name for unknown trigger kind")
333
+
}
334
+
335
+
allCmds := strings.Join(step.Commands(), " ")
336
+
if !strings.Contains(allCmds, "unknown trigger kind") {
337
+
t.Error("Commands should contain error message about unknown trigger kind")
338
+
}
339
+
}
340
+
341
+
func TestBuildCloneStep_NilCloneOpts(t *testing.T) {
342
+
twf := tangled.Pipeline_Workflow{
343
+
Clone: nil, // Nil clone options should use defaults
344
+
}
345
+
tr := tangled.Pipeline_TriggerMetadata{
346
+
Kind: string(workflow.TriggerKindPush),
347
+
Push: &tangled.Pipeline_PushTriggerData{
348
+
NewSha: "abc123",
349
+
},
350
+
Repo: &tangled.Pipeline_TriggerRepo{
351
+
Knot: "example.com",
352
+
Did: "did:plc:user123",
353
+
Repo: "my-repo",
354
+
},
355
+
}
356
+
357
+
step := BuildCloneStep(twf, tr, false)
358
+
359
+
// Should still work with default options
360
+
if step.Kind() != StepKindSystem {
361
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
362
+
}
363
+
364
+
allCmds := strings.Join(step.Commands(), " ")
365
+
if !strings.Contains(allCmds, "--depth=1") {
366
+
t.Error("Commands should default to '--depth=1' when Clone is nil")
367
+
}
368
+
if !strings.Contains(allCmds, "git init") {
369
+
t.Error("Commands should contain 'git init'")
370
+
}
371
+
}
+15
-7
spindle/secrets/openbao.go
+15
-7
spindle/secrets/openbao.go
···
13
)
14
15
type OpenBaoManager struct {
16
-
client *vault.Client
17
-
mountPath string
18
-
logger *slog.Logger
19
}
20
21
type OpenBaoManagerOpt func(*OpenBaoManager)
···
26
}
27
}
28
29
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
// The proxy handles all authentication automatically via Auto-Auth
···
43
}
44
45
manager := &OpenBaoManager{
46
-
client: client,
47
-
mountPath: "spindle", // default KV v2 mount path
48
-
logger: logger,
49
}
50
51
for _, opt := range opts {
···
62
63
// testConnection verifies that we can connect to the proxy
64
func (v *OpenBaoManager) testConnection() error {
65
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66
defer cancel()
67
68
// try token self-lookup as a quick way to verify proxy works
···
13
)
14
15
type OpenBaoManager struct {
16
+
client *vault.Client
17
+
mountPath string
18
+
logger *slog.Logger
19
+
connectionTimeout time.Duration
20
}
21
22
type OpenBaoManagerOpt func(*OpenBaoManager)
···
27
}
28
}
29
30
+
func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
31
+
return func(v *OpenBaoManager) {
32
+
v.connectionTimeout = timeout
33
+
}
34
+
}
35
+
36
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
37
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
38
// The proxy handles all authentication automatically via Auto-Auth
···
50
}
51
52
manager := &OpenBaoManager{
53
+
client: client,
54
+
mountPath: "spindle", // default KV v2 mount path
55
+
logger: logger,
56
+
connectionTimeout: 10 * time.Second, // default connection timeout
57
}
58
59
for _, opt := range opts {
···
70
71
// testConnection verifies that we can connect to the proxy
72
func (v *OpenBaoManager) testConnection() error {
73
+
ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
74
defer cancel()
75
76
// try token self-lookup as a quick way to verify proxy works
+5
-2
spindle/secrets/openbao_test.go
+5
-2
spindle/secrets/openbao_test.go
···
152
for _, tt := range tests {
153
t.Run(tt.name, func(t *testing.T) {
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
156
157
if tt.expectError {
158
assert.Error(t, err)
···
596
597
// All these will fail because no real proxy is running
598
// but we can test that the configuration is properly accepted
599
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600
assert.Error(t, err) // Expected because no real proxy
601
assert.Nil(t, manager)
602
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
···
152
for _, tt := range tests {
153
t.Run(tt.name, func(t *testing.T) {
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
+
// Use shorter timeout for tests to avoid long waits
156
+
opts := append(tt.opts, WithConnectionTimeout(1*time.Second))
157
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...)
158
159
if tt.expectError {
160
assert.Error(t, err)
···
598
599
// All these will fail because no real proxy is running
600
// but we can test that the configuration is properly accepted
601
+
// Use shorter timeout for tests to avoid long waits
602
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second))
603
assert.Error(t, err) // Expected because no real proxy
604
assert.Nil(t, manager)
605
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+86
-41
spindle/server.go
+86
-41
spindle/server.go
···
49
vault secrets.Manager
50
}
51
52
-
func Run(ctx context.Context) error {
53
logger := log.FromContext(ctx)
54
-
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
60
d, err := db.Make(cfg.Server.DBPath)
61
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
63
}
64
65
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
}
69
e.E.EnableAutoSave(true)
70
···
74
switch cfg.Server.Secrets.Provider {
75
case "openbao":
76
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
}
79
vault, err = secrets.NewOpenBaoManager(
80
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
)
84
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
}
87
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
case "sqlite", "":
89
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
}
93
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
default:
95
-
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
96
-
}
97
-
98
-
nixeryEng, err := nixery.New(ctx, cfg)
99
-
if err != nil {
100
-
return err
101
}
102
103
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
110
}
111
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
114
}
115
jc.AddDid(cfg.Server.Owner)
116
117
// Check if the spindle knows about any Dids;
118
dids, err := d.GetAllDids()
119
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
121
}
122
for _, d := range dids {
123
jc.AddDid(d)
124
}
125
126
-
resolver := idresolver.DefaultResolver()
127
128
-
spindle := Spindle{
129
jc: jc,
130
e: e,
131
db: d,
132
l: logger,
133
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
135
jq: jq,
136
cfg: cfg,
137
res: resolver,
···
140
141
err = e.AddSpindle(rbacDomain)
142
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
144
}
145
err = spindle.configureOwner()
146
if err != nil {
147
-
return err
148
}
149
logger.Info("owner set", "did", cfg.Server.Owner)
150
-
151
-
// starts a job queue runner in the background
152
-
jq.Start()
153
-
defer jq.Stop()
154
-
155
-
// Stop vault token renewal if it implements Stopper
156
-
if stopper, ok := vault.(secrets.Stopper); ok {
157
-
defer stopper.Stop()
158
-
}
159
160
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
}
164
165
err = jc.StartJetstream(ctx, spindle.ingest())
166
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
168
}
169
170
// for each incoming sh.tangled.pipeline, we execute
···
177
ccfg.CursorStore = cursorStore
178
knownKnots, err := d.Knots()
179
if err != nil {
180
-
return err
181
}
182
for _, knot := range knownKnots {
183
logger.Info("adding source start", "knot", knot)
···
185
}
186
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
188
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
191
}()
192
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
195
196
-
return nil
197
}
198
199
func (s *Spindle) Router() http.Handler {
···
49
vault secrets.Manager
50
}
51
52
+
// New creates a new Spindle server with the provided configuration and engines.
53
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
54
logger := log.FromContext(ctx)
55
56
d, err := db.Make(cfg.Server.DBPath)
57
if err != nil {
58
+
return nil, fmt.Errorf("failed to setup db: %w", err)
59
}
60
61
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
62
if err != nil {
63
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
64
}
65
e.E.EnableAutoSave(true)
66
···
70
switch cfg.Server.Secrets.Provider {
71
case "openbao":
72
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
73
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
74
}
75
vault, err = secrets.NewOpenBaoManager(
76
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
78
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
79
)
80
if err != nil {
81
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
82
}
83
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
84
case "sqlite", "":
85
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
86
if err != nil {
87
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
88
}
89
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
90
default:
91
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
92
}
93
94
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
101
}
102
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
103
if err != nil {
104
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
105
}
106
jc.AddDid(cfg.Server.Owner)
107
108
// Check if the spindle knows about any Dids;
109
dids, err := d.GetAllDids()
110
if err != nil {
111
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
112
}
113
for _, d := range dids {
114
jc.AddDid(d)
115
}
116
117
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
118
119
+
spindle := &Spindle{
120
jc: jc,
121
e: e,
122
db: d,
123
l: logger,
124
n: &n,
125
+
engs: engines,
126
jq: jq,
127
cfg: cfg,
128
res: resolver,
···
131
132
err = e.AddSpindle(rbacDomain)
133
if err != nil {
134
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
135
}
136
err = spindle.configureOwner()
137
if err != nil {
138
+
return nil, err
139
}
140
logger.Info("owner set", "did", cfg.Server.Owner)
141
142
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
143
if err != nil {
144
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
145
}
146
147
err = jc.StartJetstream(ctx, spindle.ingest())
148
if err != nil {
149
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
150
}
151
152
// for each incoming sh.tangled.pipeline, we execute
···
159
ccfg.CursorStore = cursorStore
160
knownKnots, err := d.Knots()
161
if err != nil {
162
+
return nil, err
163
}
164
for _, knot := range knownKnots {
165
logger.Info("adding source start", "knot", knot)
···
167
}
168
spindle.ks = eventconsumer.NewConsumer(*ccfg)
169
170
+
return spindle, nil
171
+
}
172
+
173
+
// DB returns the database instance.
174
+
func (s *Spindle) DB() *db.DB {
175
+
return s.db
176
+
}
177
+
178
+
// Queue returns the job queue instance.
179
+
func (s *Spindle) Queue() *queue.Queue {
180
+
return s.jq
181
+
}
182
+
183
+
// Engines returns the map of available engines.
184
+
func (s *Spindle) Engines() map[string]models.Engine {
185
+
return s.engs
186
+
}
187
+
188
+
// Vault returns the secrets manager instance.
189
+
func (s *Spindle) Vault() secrets.Manager {
190
+
return s.vault
191
+
}
192
+
193
+
// Notifier returns the notifier instance.
194
+
func (s *Spindle) Notifier() *notifier.Notifier {
195
+
return s.n
196
+
}
197
+
198
+
// Enforcer returns the RBAC enforcer instance.
199
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
200
+
return s.e
201
+
}
202
+
203
+
// Start starts the Spindle server (blocking).
204
+
func (s *Spindle) Start(ctx context.Context) error {
205
+
// starts a job queue runner in the background
206
+
s.jq.Start()
207
+
defer s.jq.Stop()
208
+
209
+
// Stop vault token renewal if it implements Stopper
210
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
211
+
defer stopper.Stop()
212
+
}
213
+
214
go func() {
215
+
s.l.Info("starting knot event consumer")
216
+
s.ks.Start(ctx)
217
}()
218
219
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
220
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
221
+
}
222
+
223
+
func Run(ctx context.Context) error {
224
+
cfg, err := config.Load(ctx)
225
+
if err != nil {
226
+
return fmt.Errorf("failed to load config: %w", err)
227
+
}
228
+
229
+
nixeryEng, err := nixery.New(ctx, cfg)
230
+
if err != nil {
231
+
return err
232
+
}
233
+
234
+
s, err := New(ctx, cfg, map[string]models.Engine{
235
+
"nixery": nixeryEng,
236
+
})
237
+
if err != nil {
238
+
return err
239
+
}
240
241
+
return s.Start(ctx)
242
}
243
244
func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
+5
spindle/stream.go
···
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
214
return fmt.Errorf("failed to write to websocket: %w", err)
215
}
216
+
case <-time.After(30 * time.Second):
217
+
// send a keep-alive
218
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
219
+
return fmt.Errorf("failed to write control: %w", err)
220
+
}
221
}
222
}
223
}
+22
-1
types/repo.go
+22
-1
types/repo.go
···
1
package types
2
3
import (
4
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
"github.com/go-git/go-git/v5/plumbing/object"
6
)
···
66
type Branch struct {
67
Reference `json:"reference"`
68
Commit *object.Commit `json:"commit,omitempty"`
69
-
IsDefault bool `json:"is_deafult,omitempty"`
70
}
71
72
type RepoTagsResponse struct {
···
1
package types
2
3
import (
4
+
"encoding/json"
5
+
6
"github.com/bluekeyes/go-gitdiff/gitdiff"
7
"github.com/go-git/go-git/v5/plumbing/object"
8
)
···
68
type Branch struct {
69
Reference `json:"reference"`
70
Commit *object.Commit `json:"commit,omitempty"`
71
+
IsDefault bool `json:"is_default,omitempty"`
72
+
}
73
+
74
+
func (b *Branch) UnmarshalJSON(data []byte) error {
75
+
aux := &struct {
76
+
Reference `json:"reference"`
77
+
Commit *object.Commit `json:"commit,omitempty"`
78
+
IsDefault bool `json:"is_default,omitempty"`
79
+
MispelledIsDefault bool `json:"is_deafult,omitempty"` // mispelled name
80
+
}{}
81
+
82
+
if err := json.Unmarshal(data, aux); err != nil {
83
+
return err
84
+
}
85
+
86
+
b.Reference = aux.Reference
87
+
b.Commit = aux.Commit
88
+
b.IsDefault = aux.IsDefault || aux.MispelledIsDefault // whichever was set
89
+
90
+
return nil
91
}
92
93
type RepoTagsResponse struct {
+88
-5
types/tree.go
+88
-5
types/tree.go
···
1
package types
2
3
import (
4
"time"
5
6
"github.com/go-git/go-git/v5/plumbing"
7
)
8
9
// A nicer git tree representation.
10
type NiceTree struct {
11
// Relative path
12
-
Name string `json:"name"`
13
-
Mode string `json:"mode"`
14
-
Size int64 `json:"size"`
15
-
IsFile bool `json:"is_file"`
16
-
IsSubtree bool `json:"is_subtree"`
17
18
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
19
}
20
21
type LastCommitInfo struct {
···
1
package types
2
3
import (
4
+
"fmt"
5
+
"os"
6
"time"
7
8
"github.com/go-git/go-git/v5/plumbing"
9
+
"github.com/go-git/go-git/v5/plumbing/filemode"
10
)
11
12
// A nicer git tree representation.
13
type NiceTree struct {
14
// Relative path
15
+
Name string `json:"name"`
16
+
Mode string `json:"mode"`
17
+
Size int64 `json:"size"`
18
19
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
20
+
}
21
+
22
+
func (t *NiceTree) FileMode() (filemode.FileMode, error) {
23
+
if numericMode, err := filemode.New(t.Mode); err == nil {
24
+
return numericMode, nil
25
+
}
26
+
27
+
// TODO: this is here for backwards compat, can be removed in future versions
28
+
osMode, err := parseModeString(t.Mode)
29
+
if err != nil {
30
+
return filemode.Empty, nil
31
+
}
32
+
33
+
conv, err := filemode.NewFromOSFileMode(osMode)
34
+
if err != nil {
35
+
return filemode.Empty, nil
36
+
}
37
+
38
+
return conv, nil
39
+
}
40
+
41
+
// ParseFileModeString parses a file mode string like "-rw-r--r--"
42
+
// and returns an os.FileMode
43
+
func parseModeString(modeStr string) (os.FileMode, error) {
44
+
if len(modeStr) != 10 {
45
+
return 0, fmt.Errorf("invalid mode string length: expected 10, got %d", len(modeStr))
46
+
}
47
+
48
+
var mode os.FileMode
49
+
50
+
// Parse file type (first character)
51
+
switch modeStr[0] {
52
+
case 'd':
53
+
mode |= os.ModeDir
54
+
case 'l':
55
+
mode |= os.ModeSymlink
56
+
case '-':
57
+
// regular file
58
+
default:
59
+
return 0, fmt.Errorf("unknown file type: %c", modeStr[0])
60
+
}
61
+
62
+
// parse permissions for owner, group, and other
63
+
perms := modeStr[1:]
64
+
shifts := []int{6, 3, 0} // bit shifts for owner, group, other
65
+
66
+
for i := range 3 {
67
+
offset := i * 3
68
+
shift := shifts[i]
69
+
70
+
if perms[offset] == 'r' {
71
+
mode |= os.FileMode(4 << shift)
72
+
}
73
+
if perms[offset+1] == 'w' {
74
+
mode |= os.FileMode(2 << shift)
75
+
}
76
+
if perms[offset+2] == 'x' {
77
+
mode |= os.FileMode(1 << shift)
78
+
}
79
+
}
80
+
81
+
return mode, nil
82
+
}
83
+
84
+
func (t *NiceTree) IsFile() bool {
85
+
m, err := t.FileMode()
86
+
87
+
if err != nil {
88
+
return false
89
+
}
90
+
91
+
return m.IsFile()
92
+
}
93
+
94
+
func (t *NiceTree) IsSubmodule() bool {
95
+
m, err := t.FileMode()
96
+
97
+
if err != nil {
98
+
return false
99
+
}
100
+
101
+
return m == filemode.Submodule
102
}
103
104
type LastCommitInfo struct {
+9
-1
workflow/compile.go
+9
-1
workflow/compile.go
···
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
cw := &tangled.Pipeline_Workflow{}
115
116
+
matched, err := w.Match(compiler.Trigger)
117
+
if err != nil {
118
+
compiler.Diagnostics.AddError(
119
+
w.Name,
120
+
fmt.Errorf("failed to execute workflow: %w", err),
121
+
)
122
+
return nil
123
+
}
124
+
if !matched {
125
compiler.Diagnostics.AddWarning(
126
w.Name,
127
WorkflowSkipped,
+125
workflow/compile_test.go
+125
workflow/compile_test.go
···
95
assert.Len(t, c.Diagnostics.Errors, 1)
96
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
97
}
98
+
99
+
func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) {
100
+
wf := Workflow{
101
+
Name: ".tangled/workflows/branch_and_tag.yml",
102
+
When: []Constraint{
103
+
{
104
+
Event: []string{"push"},
105
+
Branch: []string{"main", "develop"},
106
+
Tag: []string{"v*"},
107
+
},
108
+
},
109
+
Engine: "nixery",
110
+
}
111
+
112
+
tests := []struct {
113
+
name string
114
+
trigger tangled.Pipeline_TriggerMetadata
115
+
shouldMatch bool
116
+
expectedCount int
117
+
}{
118
+
{
119
+
name: "matches main branch",
120
+
trigger: tangled.Pipeline_TriggerMetadata{
121
+
Kind: string(TriggerKindPush),
122
+
Push: &tangled.Pipeline_PushTriggerData{
123
+
Ref: "refs/heads/main",
124
+
OldSha: strings.Repeat("0", 40),
125
+
NewSha: strings.Repeat("f", 40),
126
+
},
127
+
},
128
+
shouldMatch: true,
129
+
expectedCount: 1,
130
+
},
131
+
{
132
+
name: "matches develop branch",
133
+
trigger: tangled.Pipeline_TriggerMetadata{
134
+
Kind: string(TriggerKindPush),
135
+
Push: &tangled.Pipeline_PushTriggerData{
136
+
Ref: "refs/heads/develop",
137
+
OldSha: strings.Repeat("0", 40),
138
+
NewSha: strings.Repeat("f", 40),
139
+
},
140
+
},
141
+
shouldMatch: true,
142
+
expectedCount: 1,
143
+
},
144
+
{
145
+
name: "matches v* tag pattern",
146
+
trigger: tangled.Pipeline_TriggerMetadata{
147
+
Kind: string(TriggerKindPush),
148
+
Push: &tangled.Pipeline_PushTriggerData{
149
+
Ref: "refs/tags/v1.0.0",
150
+
OldSha: strings.Repeat("0", 40),
151
+
NewSha: strings.Repeat("f", 40),
152
+
},
153
+
},
154
+
shouldMatch: true,
155
+
expectedCount: 1,
156
+
},
157
+
{
158
+
name: "matches v* tag pattern with different version",
159
+
trigger: tangled.Pipeline_TriggerMetadata{
160
+
Kind: string(TriggerKindPush),
161
+
Push: &tangled.Pipeline_PushTriggerData{
162
+
Ref: "refs/tags/v2.5.3",
163
+
OldSha: strings.Repeat("0", 40),
164
+
NewSha: strings.Repeat("f", 40),
165
+
},
166
+
},
167
+
shouldMatch: true,
168
+
expectedCount: 1,
169
+
},
170
+
{
171
+
name: "does not match master branch",
172
+
trigger: tangled.Pipeline_TriggerMetadata{
173
+
Kind: string(TriggerKindPush),
174
+
Push: &tangled.Pipeline_PushTriggerData{
175
+
Ref: "refs/heads/master",
176
+
OldSha: strings.Repeat("0", 40),
177
+
NewSha: strings.Repeat("f", 40),
178
+
},
179
+
},
180
+
shouldMatch: false,
181
+
expectedCount: 0,
182
+
},
183
+
{
184
+
name: "does not match non-v tag",
185
+
trigger: tangled.Pipeline_TriggerMetadata{
186
+
Kind: string(TriggerKindPush),
187
+
Push: &tangled.Pipeline_PushTriggerData{
188
+
Ref: "refs/tags/release-1.0",
189
+
OldSha: strings.Repeat("0", 40),
190
+
NewSha: strings.Repeat("f", 40),
191
+
},
192
+
},
193
+
shouldMatch: false,
194
+
expectedCount: 0,
195
+
},
196
+
{
197
+
name: "does not match feature branch",
198
+
trigger: tangled.Pipeline_TriggerMetadata{
199
+
Kind: string(TriggerKindPush),
200
+
Push: &tangled.Pipeline_PushTriggerData{
201
+
Ref: "refs/heads/feature/new-feature",
202
+
OldSha: strings.Repeat("0", 40),
203
+
NewSha: strings.Repeat("f", 40),
204
+
},
205
+
},
206
+
shouldMatch: false,
207
+
expectedCount: 0,
208
+
},
209
+
}
210
+
211
+
for _, tt := range tests {
212
+
t.Run(tt.name, func(t *testing.T) {
213
+
c := Compiler{Trigger: tt.trigger}
214
+
cp := c.Compile([]Workflow{wf})
215
+
216
+
assert.Len(t, cp.Workflows, tt.expectedCount)
217
+
if tt.shouldMatch {
218
+
assert.Equal(t, wf.Name, cp.Workflows[0].Name)
219
+
}
220
+
})
221
+
}
222
+
}
+61
-19
workflow/def.go
+61
-19
workflow/def.go
···
8
9
"tangled.org/core/api/tangled"
10
11
"github.com/go-git/go-git/v5/plumbing"
12
"gopkg.in/yaml.v3"
13
)
···
33
34
Constraint struct {
35
Event StringList `yaml:"event"`
36
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
37
}
38
39
CloneOpts struct {
···
59
return strings.ReplaceAll(string(t), "_", " ")
60
}
61
62
func FromFile(name string, contents []byte) (Workflow, error) {
63
var wf Workflow
64
···
74
}
75
76
// if any of the constraints on a workflow is true, return true
77
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
78
// manual triggers always run the workflow
79
if trigger.Manual != nil {
80
-
return true
81
}
82
83
// if not manual, run through the constraint list and see if any one matches
84
for _, c := range w.When {
85
-
if c.Match(trigger) {
86
-
return true
87
}
88
}
89
90
// no constraints, always run this workflow
91
if len(w.When) == 0 {
92
-
return true
93
}
94
95
-
return false
96
}
97
98
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
99
match := true
100
101
// manual triggers always pass this constraint
102
if trigger.Manual != nil {
103
-
return true
104
}
105
106
// apply event constraints
···
108
109
// apply branch constraints for PRs
110
if trigger.PullRequest != nil {
111
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
112
}
113
114
// apply ref constraints for pushes
115
if trigger.Push != nil {
116
-
match = match && c.MatchRef(trigger.Push.Ref)
117
}
118
119
-
return match
120
-
}
121
-
122
-
func (c *Constraint) MatchBranch(branch string) bool {
123
-
return slices.Contains(c.Branch, branch)
124
}
125
126
-
func (c *Constraint) MatchRef(ref string) bool {
127
refName := plumbing.ReferenceName(ref)
128
if refName.IsBranch() {
129
-
return slices.Contains(c.Branch, refName.Short())
130
}
131
-
return false
132
}
133
134
func (c *Constraint) MatchEvent(event string) bool {
···
8
9
"tangled.org/core/api/tangled"
10
11
+
"github.com/bmatcuk/doublestar/v4"
12
"github.com/go-git/go-git/v5/plumbing"
13
"gopkg.in/yaml.v3"
14
)
···
34
35
Constraint struct {
36
Event StringList `yaml:"event"`
37
+
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
38
+
Tag StringList `yaml:"tag"` // optional; only applies to push events
39
}
40
41
CloneOpts struct {
···
61
return strings.ReplaceAll(string(t), "_", " ")
62
}
63
64
+
// matchesPattern checks if a name matches any of the given patterns.
65
+
// Patterns can be exact matches or glob patterns using * and **.
66
+
// * matches any sequence of non-separator characters
67
+
// ** matches any sequence of characters including separators
68
+
func matchesPattern(name string, patterns []string) (bool, error) {
69
+
for _, pattern := range patterns {
70
+
matched, err := doublestar.Match(pattern, name)
71
+
if err != nil {
72
+
return false, err
73
+
}
74
+
if matched {
75
+
return true, nil
76
+
}
77
+
}
78
+
return false, nil
79
+
}
80
+
81
func FromFile(name string, contents []byte) (Workflow, error) {
82
var wf Workflow
83
···
93
}
94
95
// if any of the constraints on a workflow is true, return true
96
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
97
// manual triggers always run the workflow
98
if trigger.Manual != nil {
99
+
return true, nil
100
}
101
102
// if not manual, run through the constraint list and see if any one matches
103
for _, c := range w.When {
104
+
matched, err := c.Match(trigger)
105
+
if err != nil {
106
+
return false, err
107
+
}
108
+
if matched {
109
+
return true, nil
110
}
111
}
112
113
// no constraints, always run this workflow
114
if len(w.When) == 0 {
115
+
return true, nil
116
}
117
118
+
return false, nil
119
}
120
121
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
122
match := true
123
124
// manual triggers always pass this constraint
125
if trigger.Manual != nil {
126
+
return true, nil
127
}
128
129
// apply event constraints
···
131
132
// apply branch constraints for PRs
133
if trigger.PullRequest != nil {
134
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
+
if err != nil {
136
+
return false, err
137
+
}
138
+
match = match && matched
139
}
140
141
// apply ref constraints for pushes
142
if trigger.Push != nil {
143
+
matched, err := c.MatchRef(trigger.Push.Ref)
144
+
if err != nil {
145
+
return false, err
146
+
}
147
+
match = match && matched
148
}
149
150
+
return match, nil
151
}
152
153
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
154
refName := plumbing.ReferenceName(ref)
155
+
shortName := refName.Short()
156
+
157
if refName.IsBranch() {
158
+
return c.MatchBranch(shortName)
159
}
160
+
161
+
if refName.IsTag() {
162
+
return c.MatchTag(shortName)
163
+
}
164
+
165
+
return false, nil
166
+
}
167
+
168
+
func (c *Constraint) MatchBranch(branch string) (bool, error) {
169
+
return matchesPattern(branch, c.Branch)
170
+
}
171
+
172
+
func (c *Constraint) MatchTag(tag string) (bool, error) {
173
+
return matchesPattern(tag, c.Tag)
174
}
175
176
func (c *Constraint) MatchEvent(event string) bool {
+284
-1
workflow/def_test.go
+284
-1
workflow/def_test.go
···
6
"github.com/stretchr/testify/assert"
7
)
8
9
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
10
yamlData := `
11
when:
12
- event: ["push", "pull_request"]
···
38
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
40
}
41
+
42
+
func TestUnmarshalWorkflowWithTags(t *testing.T) {
43
+
yamlData := `
44
+
when:
45
+
- event: ["push"]
46
+
tag: ["v*", "release-*"]`
47
+
48
+
wf, err := FromFile("test.yml", []byte(yamlData))
49
+
assert.NoError(t, err, "YAML should unmarshal without error")
50
+
51
+
assert.Len(t, wf.When, 1, "Should have one constraint")
52
+
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
53
+
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
54
+
}
55
+
56
+
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
57
+
yamlData := `
58
+
when:
59
+
- event: ["push"]
60
+
branch: ["main", "develop"]
61
+
tag: ["v*"]`
62
+
63
+
wf, err := FromFile("test.yml", []byte(yamlData))
64
+
assert.NoError(t, err, "YAML should unmarshal without error")
65
+
66
+
assert.Len(t, wf.When, 1, "Should have one constraint")
67
+
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
68
+
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
69
+
}
70
+
71
+
func TestMatchesPattern(t *testing.T) {
72
+
tests := []struct {
73
+
name string
74
+
input string
75
+
patterns []string
76
+
expected bool
77
+
}{
78
+
{"exact match", "main", []string{"main"}, true},
79
+
{"exact match in list", "develop", []string{"main", "develop"}, true},
80
+
{"no match", "feature", []string{"main", "develop"}, false},
81
+
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
82
+
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
83
+
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
84
+
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
85
+
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
86
+
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
87
+
{"double star no match", "feature/test", []string{"release/**"}, false},
88
+
{"no patterns matches nothing", "anything", []string{}, false},
89
+
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
90
+
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
91
+
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
92
+
}
93
+
94
+
for _, tt := range tests {
95
+
t.Run(tt.name, func(t *testing.T) {
96
+
result, _ := matchesPattern(tt.input, tt.patterns)
97
+
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
98
+
})
99
+
}
100
+
}
101
+
102
+
func TestConstraintMatchRef_Branches(t *testing.T) {
103
+
tests := []struct {
104
+
name string
105
+
constraint Constraint
106
+
ref string
107
+
expected bool
108
+
}{
109
+
{
110
+
name: "exact branch match",
111
+
constraint: Constraint{Branch: []string{"main"}},
112
+
ref: "refs/heads/main",
113
+
expected: true,
114
+
},
115
+
{
116
+
name: "branch glob match",
117
+
constraint: Constraint{Branch: []string{"feature-*"}},
118
+
ref: "refs/heads/feature-123",
119
+
expected: true,
120
+
},
121
+
{
122
+
name: "branch no match",
123
+
constraint: Constraint{Branch: []string{"main"}},
124
+
ref: "refs/heads/develop",
125
+
expected: false,
126
+
},
127
+
{
128
+
name: "no constraints matches nothing",
129
+
constraint: Constraint{},
130
+
ref: "refs/heads/anything",
131
+
expected: false,
132
+
},
133
+
}
134
+
135
+
for _, tt := range tests {
136
+
t.Run(tt.name, func(t *testing.T) {
137
+
result, _ := tt.constraint.MatchRef(tt.ref)
138
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
139
+
})
140
+
}
141
+
}
142
+
143
+
func TestConstraintMatchRef_Tags(t *testing.T) {
144
+
tests := []struct {
145
+
name string
146
+
constraint Constraint
147
+
ref string
148
+
expected bool
149
+
}{
150
+
{
151
+
name: "exact tag match",
152
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
153
+
ref: "refs/tags/v1.0.0",
154
+
expected: true,
155
+
},
156
+
{
157
+
name: "tag glob match",
158
+
constraint: Constraint{Tag: []string{"v*"}},
159
+
ref: "refs/tags/v1.2.3",
160
+
expected: true,
161
+
},
162
+
{
163
+
name: "tag glob with pattern",
164
+
constraint: Constraint{Tag: []string{"release-*"}},
165
+
ref: "refs/tags/release-2024",
166
+
expected: true,
167
+
},
168
+
{
169
+
name: "tag no match",
170
+
constraint: Constraint{Tag: []string{"v*"}},
171
+
ref: "refs/tags/release-1.0",
172
+
expected: false,
173
+
},
174
+
{
175
+
name: "tag not matched when only branch constraint",
176
+
constraint: Constraint{Branch: []string{"main"}},
177
+
ref: "refs/tags/v1.0.0",
178
+
expected: false,
179
+
},
180
+
}
181
+
182
+
for _, tt := range tests {
183
+
t.Run(tt.name, func(t *testing.T) {
184
+
result, _ := tt.constraint.MatchRef(tt.ref)
185
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
186
+
})
187
+
}
188
+
}
189
+
190
+
func TestConstraintMatchRef_Combined(t *testing.T) {
191
+
tests := []struct {
192
+
name string
193
+
constraint Constraint
194
+
ref string
195
+
expected bool
196
+
}{
197
+
{
198
+
name: "matches branch in combined constraint",
199
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
200
+
ref: "refs/heads/main",
201
+
expected: true,
202
+
},
203
+
{
204
+
name: "matches tag in combined constraint",
205
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
206
+
ref: "refs/tags/v1.0.0",
207
+
expected: true,
208
+
},
209
+
{
210
+
name: "no match in combined constraint",
211
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
212
+
ref: "refs/heads/develop",
213
+
expected: false,
214
+
},
215
+
{
216
+
name: "glob patterns in combined constraint - branch",
217
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
218
+
ref: "refs/heads/release-2024",
219
+
expected: true,
220
+
},
221
+
{
222
+
name: "glob patterns in combined constraint - tag",
223
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
224
+
ref: "refs/tags/v2.0.0",
225
+
expected: true,
226
+
},
227
+
}
228
+
229
+
for _, tt := range tests {
230
+
t.Run(tt.name, func(t *testing.T) {
231
+
result, _ := tt.constraint.MatchRef(tt.ref)
232
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
233
+
})
234
+
}
235
+
}
236
+
237
+
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
238
+
tests := []struct {
239
+
name string
240
+
constraint Constraint
241
+
branch string
242
+
expected bool
243
+
}{
244
+
{
245
+
name: "exact match",
246
+
constraint: Constraint{Branch: []string{"main"}},
247
+
branch: "main",
248
+
expected: true,
249
+
},
250
+
{
251
+
name: "glob match",
252
+
constraint: Constraint{Branch: []string{"feature-*"}},
253
+
branch: "feature-123",
254
+
expected: true,
255
+
},
256
+
{
257
+
name: "no match",
258
+
constraint: Constraint{Branch: []string{"main"}},
259
+
branch: "develop",
260
+
expected: false,
261
+
},
262
+
{
263
+
name: "multiple patterns with match",
264
+
constraint: Constraint{Branch: []string{"main", "release-*"}},
265
+
branch: "release-1.0",
266
+
expected: true,
267
+
},
268
+
}
269
+
270
+
for _, tt := range tests {
271
+
t.Run(tt.name, func(t *testing.T) {
272
+
result, _ := tt.constraint.MatchBranch(tt.branch)
273
+
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
274
+
})
275
+
}
276
+
}
277
+
278
+
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
279
+
tests := []struct {
280
+
name string
281
+
constraint Constraint
282
+
tag string
283
+
expected bool
284
+
}{
285
+
{
286
+
name: "exact match",
287
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
288
+
tag: "v1.0.0",
289
+
expected: true,
290
+
},
291
+
{
292
+
name: "glob match",
293
+
constraint: Constraint{Tag: []string{"v*"}},
294
+
tag: "v2.3.4",
295
+
expected: true,
296
+
},
297
+
{
298
+
name: "no match",
299
+
constraint: Constraint{Tag: []string{"v*"}},
300
+
tag: "release-1.0",
301
+
expected: false,
302
+
},
303
+
{
304
+
name: "multiple patterns with match",
305
+
constraint: Constraint{Tag: []string{"v*", "release-*"}},
306
+
tag: "release-2024",
307
+
expected: true,
308
+
},
309
+
{
310
+
name: "empty tag list matches nothing",
311
+
constraint: Constraint{Tag: []string{}},
312
+
tag: "v1.0.0",
313
+
expected: false,
314
+
},
315
+
}
316
+
317
+
for _, tt := range tests {
318
+
t.Run(tt.name, func(t *testing.T) {
319
+
result, _ := tt.constraint.MatchTag(tt.tag)
320
+
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
321
+
})
322
+
}
323
+
}