+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
1
root = "."
2
+
tmp_dir = "out"
5
3
6
-
exclude_regex = [".*_templ.go"]
7
-
include_ext = ["go", "templ", "html", "css"]
8
-
exclude_dir = ["target", "atrium", "nix"]
4
+
[build]
5
+
cmd = "go build -o out/appview.out cmd/appview/main.go"
6
+
bin = "out/appview.out"
7
+
8
+
include_ext = ["go"]
9
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
10
+
stop_on_error = true
+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
-2
api/tangled/actorprofile.go
+3
-2
api/tangled/actorprofile.go
···
27
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
-
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
32
33
}
+923
-29
api/tangled/cbor_gen.go
+923
-29
api/tangled/cbor_gen.go
···
26
26
}
27
27
28
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 7
29
+
fieldCount := 8
30
30
31
31
if t.Description == nil {
32
32
fieldCount--
···
41
41
}
42
42
43
43
if t.PinnedRepositories == nil {
44
+
fieldCount--
45
+
}
46
+
47
+
if t.Pronouns == nil {
44
48
fieldCount--
45
49
}
46
50
···
186
190
return err
187
191
}
188
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 {
189
225
return err
190
226
}
191
227
}
···
430
466
}
431
467
432
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)
433
490
}
434
491
}
435
492
// t.Description (string) (string)
···
5806
5863
}
5807
5864
5808
5865
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5866
+
fieldCount := 10
5810
5867
5811
5868
if t.Description == nil {
5812
5869
fieldCount--
···
5821
5878
}
5822
5879
5823
5880
if t.Spindle == nil {
5881
+
fieldCount--
5882
+
}
5883
+
5884
+
if t.Topics == nil {
5885
+
fieldCount--
5886
+
}
5887
+
5888
+
if t.Website == nil {
5824
5889
fieldCount--
5825
5890
}
5826
5891
···
5961
6026
}
5962
6027
}
5963
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
+
5964
6065
// t.Spindle (string) (string)
5965
6066
if t.Spindle != nil {
5966
6067
···
5993
6094
}
5994
6095
}
5995
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
+
5996
6129
// t.CreatedAt (string) (string)
5997
6130
if len("createdAt") > 1000000 {
5998
6131
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
6318
t.Source = (*string)(&sval)
6186
6319
}
6187
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
+
}
6188
6361
// t.Spindle (string) (string)
6189
6362
case "spindle":
6190
6363
···
6204
6377
}
6205
6378
6206
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)
6207
6401
}
6208
6402
}
6209
6403
// t.CreatedAt (string) (string)
···
6744
6938
}
6745
6939
6746
6940
cw := cbg.NewCborWriter(w)
6747
-
fieldCount := 5
6941
+
fieldCount := 7
6748
6942
6749
6943
if t.Body == nil {
6944
+
fieldCount--
6945
+
}
6946
+
6947
+
if t.Mentions == nil {
6948
+
fieldCount--
6949
+
}
6950
+
6951
+
if t.References == nil {
6750
6952
fieldCount--
6751
6953
}
6752
6954
···
6851
7053
return err
6852
7054
}
6853
7055
7056
+
// t.Mentions ([]string) (slice)
7057
+
if t.Mentions != nil {
7058
+
7059
+
if len("mentions") > 1000000 {
7060
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
7061
+
}
7062
+
7063
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
7064
+
return err
7065
+
}
7066
+
if _, err := cw.WriteString(string("mentions")); err != nil {
7067
+
return err
7068
+
}
7069
+
7070
+
if len(t.Mentions) > 8192 {
7071
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
7072
+
}
7073
+
7074
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
7075
+
return err
7076
+
}
7077
+
for _, v := range t.Mentions {
7078
+
if len(v) > 1000000 {
7079
+
return xerrors.Errorf("Value in field v was too long")
7080
+
}
7081
+
7082
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
7083
+
return err
7084
+
}
7085
+
if _, err := cw.WriteString(string(v)); err != nil {
7086
+
return err
7087
+
}
7088
+
7089
+
}
7090
+
}
7091
+
6854
7092
// t.CreatedAt (string) (string)
6855
7093
if len("createdAt") > 1000000 {
6856
7094
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6873
7111
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
6874
7112
return err
6875
7113
}
7114
+
7115
+
// t.References ([]string) (slice)
7116
+
if t.References != nil {
7117
+
7118
+
if len("references") > 1000000 {
7119
+
return xerrors.Errorf("Value in field \"references\" was too long")
7120
+
}
7121
+
7122
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
7123
+
return err
7124
+
}
7125
+
if _, err := cw.WriteString(string("references")); err != nil {
7126
+
return err
7127
+
}
7128
+
7129
+
if len(t.References) > 8192 {
7130
+
return xerrors.Errorf("Slice value in field t.References was too long")
7131
+
}
7132
+
7133
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
7134
+
return err
7135
+
}
7136
+
for _, v := range t.References {
7137
+
if len(v) > 1000000 {
7138
+
return xerrors.Errorf("Value in field v was too long")
7139
+
}
7140
+
7141
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
7142
+
return err
7143
+
}
7144
+
if _, err := cw.WriteString(string(v)); err != nil {
7145
+
return err
7146
+
}
7147
+
7148
+
}
7149
+
}
6876
7150
return nil
6877
7151
}
6878
7152
···
6901
7175
6902
7176
n := extra
6903
7177
6904
-
nameBuf := make([]byte, 9)
7178
+
nameBuf := make([]byte, 10)
6905
7179
for i := uint64(0); i < n; i++ {
6906
7180
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
6907
7181
if err != nil {
···
6971
7245
6972
7246
t.Title = string(sval)
6973
7247
}
7248
+
// t.Mentions ([]string) (slice)
7249
+
case "mentions":
7250
+
7251
+
maj, extra, err = cr.ReadHeader()
7252
+
if err != nil {
7253
+
return err
7254
+
}
7255
+
7256
+
if extra > 8192 {
7257
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
7258
+
}
7259
+
7260
+
if maj != cbg.MajArray {
7261
+
return fmt.Errorf("expected cbor array")
7262
+
}
7263
+
7264
+
if extra > 0 {
7265
+
t.Mentions = make([]string, extra)
7266
+
}
7267
+
7268
+
for i := 0; i < int(extra); i++ {
7269
+
{
7270
+
var maj byte
7271
+
var extra uint64
7272
+
var err error
7273
+
_ = maj
7274
+
_ = extra
7275
+
_ = err
7276
+
7277
+
{
7278
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7279
+
if err != nil {
7280
+
return err
7281
+
}
7282
+
7283
+
t.Mentions[i] = string(sval)
7284
+
}
7285
+
7286
+
}
7287
+
}
6974
7288
// t.CreatedAt (string) (string)
6975
7289
case "createdAt":
6976
7290
···
6982
7296
6983
7297
t.CreatedAt = string(sval)
6984
7298
}
7299
+
// t.References ([]string) (slice)
7300
+
case "references":
7301
+
7302
+
maj, extra, err = cr.ReadHeader()
7303
+
if err != nil {
7304
+
return err
7305
+
}
7306
+
7307
+
if extra > 8192 {
7308
+
return fmt.Errorf("t.References: array too large (%d)", extra)
7309
+
}
7310
+
7311
+
if maj != cbg.MajArray {
7312
+
return fmt.Errorf("expected cbor array")
7313
+
}
7314
+
7315
+
if extra > 0 {
7316
+
t.References = make([]string, extra)
7317
+
}
7318
+
7319
+
for i := 0; i < int(extra); i++ {
7320
+
{
7321
+
var maj byte
7322
+
var extra uint64
7323
+
var err error
7324
+
_ = maj
7325
+
_ = extra
7326
+
_ = err
7327
+
7328
+
{
7329
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7330
+
if err != nil {
7331
+
return err
7332
+
}
7333
+
7334
+
t.References[i] = string(sval)
7335
+
}
7336
+
7337
+
}
7338
+
}
6985
7339
6986
7340
default:
6987
7341
// Field doesn't exist on this type, so ignore it
···
7000
7354
}
7001
7355
7002
7356
cw := cbg.NewCborWriter(w)
7003
-
fieldCount := 5
7357
+
fieldCount := 7
7358
+
7359
+
if t.Mentions == nil {
7360
+
fieldCount--
7361
+
}
7362
+
7363
+
if t.References == nil {
7364
+
fieldCount--
7365
+
}
7004
7366
7005
7367
if t.ReplyTo == nil {
7006
7368
fieldCount--
···
7107
7469
}
7108
7470
}
7109
7471
7472
+
// t.Mentions ([]string) (slice)
7473
+
if t.Mentions != nil {
7474
+
7475
+
if len("mentions") > 1000000 {
7476
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
7477
+
}
7478
+
7479
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
7480
+
return err
7481
+
}
7482
+
if _, err := cw.WriteString(string("mentions")); err != nil {
7483
+
return err
7484
+
}
7485
+
7486
+
if len(t.Mentions) > 8192 {
7487
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
7488
+
}
7489
+
7490
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
7491
+
return err
7492
+
}
7493
+
for _, v := range t.Mentions {
7494
+
if len(v) > 1000000 {
7495
+
return xerrors.Errorf("Value in field v was too long")
7496
+
}
7497
+
7498
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
7499
+
return err
7500
+
}
7501
+
if _, err := cw.WriteString(string(v)); err != nil {
7502
+
return err
7503
+
}
7504
+
7505
+
}
7506
+
}
7507
+
7110
7508
// t.CreatedAt (string) (string)
7111
7509
if len("createdAt") > 1000000 {
7112
7510
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7129
7527
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7130
7528
return err
7131
7529
}
7530
+
7531
+
// t.References ([]string) (slice)
7532
+
if t.References != nil {
7533
+
7534
+
if len("references") > 1000000 {
7535
+
return xerrors.Errorf("Value in field \"references\" was too long")
7536
+
}
7537
+
7538
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
7539
+
return err
7540
+
}
7541
+
if _, err := cw.WriteString(string("references")); err != nil {
7542
+
return err
7543
+
}
7544
+
7545
+
if len(t.References) > 8192 {
7546
+
return xerrors.Errorf("Slice value in field t.References was too long")
7547
+
}
7548
+
7549
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
7550
+
return err
7551
+
}
7552
+
for _, v := range t.References {
7553
+
if len(v) > 1000000 {
7554
+
return xerrors.Errorf("Value in field v was too long")
7555
+
}
7556
+
7557
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
7558
+
return err
7559
+
}
7560
+
if _, err := cw.WriteString(string(v)); err != nil {
7561
+
return err
7562
+
}
7563
+
7564
+
}
7565
+
}
7132
7566
return nil
7133
7567
}
7134
7568
···
7157
7591
7158
7592
n := extra
7159
7593
7160
-
nameBuf := make([]byte, 9)
7594
+
nameBuf := make([]byte, 10)
7161
7595
for i := uint64(0); i < n; i++ {
7162
7596
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7163
7597
if err != nil {
···
7227
7661
t.ReplyTo = (*string)(&sval)
7228
7662
}
7229
7663
}
7664
+
// t.Mentions ([]string) (slice)
7665
+
case "mentions":
7666
+
7667
+
maj, extra, err = cr.ReadHeader()
7668
+
if err != nil {
7669
+
return err
7670
+
}
7671
+
7672
+
if extra > 8192 {
7673
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
7674
+
}
7675
+
7676
+
if maj != cbg.MajArray {
7677
+
return fmt.Errorf("expected cbor array")
7678
+
}
7679
+
7680
+
if extra > 0 {
7681
+
t.Mentions = make([]string, extra)
7682
+
}
7683
+
7684
+
for i := 0; i < int(extra); i++ {
7685
+
{
7686
+
var maj byte
7687
+
var extra uint64
7688
+
var err error
7689
+
_ = maj
7690
+
_ = extra
7691
+
_ = err
7692
+
7693
+
{
7694
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7695
+
if err != nil {
7696
+
return err
7697
+
}
7698
+
7699
+
t.Mentions[i] = string(sval)
7700
+
}
7701
+
7702
+
}
7703
+
}
7230
7704
// t.CreatedAt (string) (string)
7231
7705
case "createdAt":
7232
7706
···
7237
7711
}
7238
7712
7239
7713
t.CreatedAt = string(sval)
7714
+
}
7715
+
// t.References ([]string) (slice)
7716
+
case "references":
7717
+
7718
+
maj, extra, err = cr.ReadHeader()
7719
+
if err != nil {
7720
+
return err
7721
+
}
7722
+
7723
+
if extra > 8192 {
7724
+
return fmt.Errorf("t.References: array too large (%d)", extra)
7725
+
}
7726
+
7727
+
if maj != cbg.MajArray {
7728
+
return fmt.Errorf("expected cbor array")
7729
+
}
7730
+
7731
+
if extra > 0 {
7732
+
t.References = make([]string, extra)
7733
+
}
7734
+
7735
+
for i := 0; i < int(extra); i++ {
7736
+
{
7737
+
var maj byte
7738
+
var extra uint64
7739
+
var err error
7740
+
_ = maj
7741
+
_ = extra
7742
+
_ = err
7743
+
7744
+
{
7745
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7746
+
if err != nil {
7747
+
return err
7748
+
}
7749
+
7750
+
t.References[i] = string(sval)
7751
+
}
7752
+
7753
+
}
7240
7754
}
7241
7755
7242
7756
default:
···
7420
7934
}
7421
7935
7422
7936
cw := cbg.NewCborWriter(w)
7423
-
fieldCount := 7
7937
+
fieldCount := 10
7424
7938
7425
7939
if t.Body == nil {
7940
+
fieldCount--
7941
+
}
7942
+
7943
+
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.Patch == nil {
7948
+
fieldCount--
7949
+
}
7950
+
7951
+
if t.References == nil {
7426
7952
fieldCount--
7427
7953
}
7428
7954
···
7486
8012
}
7487
8013
7488
8014
// t.Patch (string) (string)
7489
-
if len("patch") > 1000000 {
7490
-
return xerrors.Errorf("Value in field \"patch\" was too long")
7491
-
}
8015
+
if t.Patch != nil {
7492
8016
7493
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
7494
-
return err
7495
-
}
7496
-
if _, err := cw.WriteString(string("patch")); err != nil {
7497
-
return err
7498
-
}
8017
+
if len("patch") > 1000000 {
8018
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8019
+
}
7499
8020
7500
-
if len(t.Patch) > 1000000 {
7501
-
return xerrors.Errorf("Value in field t.Patch was too long")
7502
-
}
8021
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8022
+
return err
8023
+
}
8024
+
if _, err := cw.WriteString(string("patch")); err != nil {
8025
+
return err
8026
+
}
7503
8027
7504
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
7505
-
return err
7506
-
}
7507
-
if _, err := cw.WriteString(string(t.Patch)); err != nil {
7508
-
return err
8028
+
if t.Patch == nil {
8029
+
if _, err := cw.Write(cbg.CborNull); err != nil {
8030
+
return err
8031
+
}
8032
+
} else {
8033
+
if len(*t.Patch) > 1000000 {
8034
+
return xerrors.Errorf("Value in field t.Patch was too long")
8035
+
}
8036
+
8037
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8038
+
return err
8039
+
}
8040
+
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8041
+
return err
8042
+
}
8043
+
}
7509
8044
}
7510
8045
7511
8046
// t.Title (string) (string)
···
7566
8101
return err
7567
8102
}
7568
8103
8104
+
// t.Mentions ([]string) (slice)
8105
+
if t.Mentions != nil {
8106
+
8107
+
if len("mentions") > 1000000 {
8108
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
8109
+
}
8110
+
8111
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
8112
+
return err
8113
+
}
8114
+
if _, err := cw.WriteString(string("mentions")); err != nil {
8115
+
return err
8116
+
}
8117
+
8118
+
if len(t.Mentions) > 8192 {
8119
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
8120
+
}
8121
+
8122
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
8123
+
return err
8124
+
}
8125
+
for _, v := range t.Mentions {
8126
+
if len(v) > 1000000 {
8127
+
return xerrors.Errorf("Value in field v was too long")
8128
+
}
8129
+
8130
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8131
+
return err
8132
+
}
8133
+
if _, err := cw.WriteString(string(v)); err != nil {
8134
+
return err
8135
+
}
8136
+
8137
+
}
8138
+
}
8139
+
7569
8140
// t.CreatedAt (string) (string)
7570
8141
if len("createdAt") > 1000000 {
7571
8142
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7588
8159
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7589
8160
return err
7590
8161
}
8162
+
8163
+
// t.PatchBlob (util.LexBlob) (struct)
8164
+
if len("patchBlob") > 1000000 {
8165
+
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8166
+
}
8167
+
8168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8169
+
return err
8170
+
}
8171
+
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8172
+
return err
8173
+
}
8174
+
8175
+
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8176
+
return err
8177
+
}
8178
+
8179
+
// t.References ([]string) (slice)
8180
+
if t.References != nil {
8181
+
8182
+
if len("references") > 1000000 {
8183
+
return xerrors.Errorf("Value in field \"references\" was too long")
8184
+
}
8185
+
8186
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
8187
+
return err
8188
+
}
8189
+
if _, err := cw.WriteString(string("references")); err != nil {
8190
+
return err
8191
+
}
8192
+
8193
+
if len(t.References) > 8192 {
8194
+
return xerrors.Errorf("Slice value in field t.References was too long")
8195
+
}
8196
+
8197
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
8198
+
return err
8199
+
}
8200
+
for _, v := range t.References {
8201
+
if len(v) > 1000000 {
8202
+
return xerrors.Errorf("Value in field v was too long")
8203
+
}
8204
+
8205
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8206
+
return err
8207
+
}
8208
+
if _, err := cw.WriteString(string(v)); err != nil {
8209
+
return err
8210
+
}
8211
+
8212
+
}
8213
+
}
7591
8214
return nil
7592
8215
}
7593
8216
···
7616
8239
7617
8240
n := extra
7618
8241
7619
-
nameBuf := make([]byte, 9)
8242
+
nameBuf := make([]byte, 10)
7620
8243
for i := uint64(0); i < n; i++ {
7621
8244
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7622
8245
if err != nil {
···
7668
8291
case "patch":
7669
8292
7670
8293
{
7671
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8294
+
b, err := cr.ReadByte()
7672
8295
if err != nil {
7673
8296
return err
7674
8297
}
8298
+
if b != cbg.CborNull[0] {
8299
+
if err := cr.UnreadByte(); err != nil {
8300
+
return err
8301
+
}
7675
8302
7676
-
t.Patch = string(sval)
8303
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8304
+
if err != nil {
8305
+
return err
8306
+
}
8307
+
8308
+
t.Patch = (*string)(&sval)
8309
+
}
7677
8310
}
7678
8311
// t.Title (string) (string)
7679
8312
case "title":
···
7726
8359
}
7727
8360
7728
8361
}
8362
+
// t.Mentions ([]string) (slice)
8363
+
case "mentions":
8364
+
8365
+
maj, extra, err = cr.ReadHeader()
8366
+
if err != nil {
8367
+
return err
8368
+
}
8369
+
8370
+
if extra > 8192 {
8371
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
8372
+
}
8373
+
8374
+
if maj != cbg.MajArray {
8375
+
return fmt.Errorf("expected cbor array")
8376
+
}
8377
+
8378
+
if extra > 0 {
8379
+
t.Mentions = make([]string, extra)
8380
+
}
8381
+
8382
+
for i := 0; i < int(extra); i++ {
8383
+
{
8384
+
var maj byte
8385
+
var extra uint64
8386
+
var err error
8387
+
_ = maj
8388
+
_ = extra
8389
+
_ = err
8390
+
8391
+
{
8392
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8393
+
if err != nil {
8394
+
return err
8395
+
}
8396
+
8397
+
t.Mentions[i] = string(sval)
8398
+
}
8399
+
8400
+
}
8401
+
}
7729
8402
// t.CreatedAt (string) (string)
7730
8403
case "createdAt":
7731
8404
···
7737
8410
7738
8411
t.CreatedAt = string(sval)
7739
8412
}
8413
+
// t.PatchBlob (util.LexBlob) (struct)
8414
+
case "patchBlob":
8415
+
8416
+
{
8417
+
8418
+
b, err := cr.ReadByte()
8419
+
if err != nil {
8420
+
return err
8421
+
}
8422
+
if b != cbg.CborNull[0] {
8423
+
if err := cr.UnreadByte(); err != nil {
8424
+
return err
8425
+
}
8426
+
t.PatchBlob = new(util.LexBlob)
8427
+
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8428
+
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8429
+
}
8430
+
}
8431
+
8432
+
}
8433
+
// t.References ([]string) (slice)
8434
+
case "references":
8435
+
8436
+
maj, extra, err = cr.ReadHeader()
8437
+
if err != nil {
8438
+
return err
8439
+
}
8440
+
8441
+
if extra > 8192 {
8442
+
return fmt.Errorf("t.References: array too large (%d)", extra)
8443
+
}
8444
+
8445
+
if maj != cbg.MajArray {
8446
+
return fmt.Errorf("expected cbor array")
8447
+
}
8448
+
8449
+
if extra > 0 {
8450
+
t.References = make([]string, extra)
8451
+
}
8452
+
8453
+
for i := 0; i < int(extra); i++ {
8454
+
{
8455
+
var maj byte
8456
+
var extra uint64
8457
+
var err error
8458
+
_ = maj
8459
+
_ = extra
8460
+
_ = err
8461
+
8462
+
{
8463
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8464
+
if err != nil {
8465
+
return err
8466
+
}
8467
+
8468
+
t.References[i] = string(sval)
8469
+
}
8470
+
8471
+
}
8472
+
}
7740
8473
7741
8474
default:
7742
8475
// Field doesn't exist on this type, so ignore it
···
7755
8488
}
7756
8489
7757
8490
cw := cbg.NewCborWriter(w)
8491
+
fieldCount := 6
7758
8492
7759
-
if _, err := cw.Write([]byte{164}); err != nil {
8493
+
if t.Mentions == nil {
8494
+
fieldCount--
8495
+
}
8496
+
8497
+
if t.References == nil {
8498
+
fieldCount--
8499
+
}
8500
+
8501
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
7760
8502
return err
7761
8503
}
7762
8504
···
7825
8567
return err
7826
8568
}
7827
8569
8570
+
// t.Mentions ([]string) (slice)
8571
+
if t.Mentions != nil {
8572
+
8573
+
if len("mentions") > 1000000 {
8574
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
8575
+
}
8576
+
8577
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
8578
+
return err
8579
+
}
8580
+
if _, err := cw.WriteString(string("mentions")); err != nil {
8581
+
return err
8582
+
}
8583
+
8584
+
if len(t.Mentions) > 8192 {
8585
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
8586
+
}
8587
+
8588
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
8589
+
return err
8590
+
}
8591
+
for _, v := range t.Mentions {
8592
+
if len(v) > 1000000 {
8593
+
return xerrors.Errorf("Value in field v was too long")
8594
+
}
8595
+
8596
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8597
+
return err
8598
+
}
8599
+
if _, err := cw.WriteString(string(v)); err != nil {
8600
+
return err
8601
+
}
8602
+
8603
+
}
8604
+
}
8605
+
7828
8606
// t.CreatedAt (string) (string)
7829
8607
if len("createdAt") > 1000000 {
7830
8608
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7847
8625
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7848
8626
return err
7849
8627
}
8628
+
8629
+
// t.References ([]string) (slice)
8630
+
if t.References != nil {
8631
+
8632
+
if len("references") > 1000000 {
8633
+
return xerrors.Errorf("Value in field \"references\" was too long")
8634
+
}
8635
+
8636
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
8637
+
return err
8638
+
}
8639
+
if _, err := cw.WriteString(string("references")); err != nil {
8640
+
return err
8641
+
}
8642
+
8643
+
if len(t.References) > 8192 {
8644
+
return xerrors.Errorf("Slice value in field t.References was too long")
8645
+
}
8646
+
8647
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
8648
+
return err
8649
+
}
8650
+
for _, v := range t.References {
8651
+
if len(v) > 1000000 {
8652
+
return xerrors.Errorf("Value in field v was too long")
8653
+
}
8654
+
8655
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8656
+
return err
8657
+
}
8658
+
if _, err := cw.WriteString(string(v)); err != nil {
8659
+
return err
8660
+
}
8661
+
8662
+
}
8663
+
}
7850
8664
return nil
7851
8665
}
7852
8666
···
7875
8689
7876
8690
n := extra
7877
8691
7878
-
nameBuf := make([]byte, 9)
8692
+
nameBuf := make([]byte, 10)
7879
8693
for i := uint64(0); i < n; i++ {
7880
8694
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7881
8695
if err != nil {
···
7924
8738
7925
8739
t.LexiconTypeID = string(sval)
7926
8740
}
8741
+
// t.Mentions ([]string) (slice)
8742
+
case "mentions":
8743
+
8744
+
maj, extra, err = cr.ReadHeader()
8745
+
if err != nil {
8746
+
return err
8747
+
}
8748
+
8749
+
if extra > 8192 {
8750
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
8751
+
}
8752
+
8753
+
if maj != cbg.MajArray {
8754
+
return fmt.Errorf("expected cbor array")
8755
+
}
8756
+
8757
+
if extra > 0 {
8758
+
t.Mentions = make([]string, extra)
8759
+
}
8760
+
8761
+
for i := 0; i < int(extra); i++ {
8762
+
{
8763
+
var maj byte
8764
+
var extra uint64
8765
+
var err error
8766
+
_ = maj
8767
+
_ = extra
8768
+
_ = err
8769
+
8770
+
{
8771
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8772
+
if err != nil {
8773
+
return err
8774
+
}
8775
+
8776
+
t.Mentions[i] = string(sval)
8777
+
}
8778
+
8779
+
}
8780
+
}
7927
8781
// t.CreatedAt (string) (string)
7928
8782
case "createdAt":
7929
8783
···
7934
8788
}
7935
8789
7936
8790
t.CreatedAt = string(sval)
8791
+
}
8792
+
// t.References ([]string) (slice)
8793
+
case "references":
8794
+
8795
+
maj, extra, err = cr.ReadHeader()
8796
+
if err != nil {
8797
+
return err
8798
+
}
8799
+
8800
+
if extra > 8192 {
8801
+
return fmt.Errorf("t.References: array too large (%d)", extra)
8802
+
}
8803
+
8804
+
if maj != cbg.MajArray {
8805
+
return fmt.Errorf("expected cbor array")
8806
+
}
8807
+
8808
+
if extra > 0 {
8809
+
t.References = make([]string, extra)
8810
+
}
8811
+
8812
+
for i := 0; i < int(extra); i++ {
8813
+
{
8814
+
var maj byte
8815
+
var extra uint64
8816
+
var err error
8817
+
_ = maj
8818
+
_ = extra
8819
+
_ = err
8820
+
8821
+
{
8822
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8823
+
if err != nil {
8824
+
return err
8825
+
}
8826
+
8827
+
t.References[i] = string(sval)
8828
+
}
8829
+
8830
+
}
7937
8831
}
7938
8832
7939
8833
default:
+7
-5
api/tangled/issuecomment.go
+7
-5
api/tangled/issuecomment.go
···
17
17
} //
18
18
// RECORDTYPE: RepoIssueComment
19
19
type RepoIssueComment struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
-
Body string `json:"body" cborgen:"body"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Issue string `json:"issue" cborgen:"issue"`
24
-
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Issue string `json:"issue" cborgen:"issue"`
24
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
25
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
+
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
25
27
}
+6
-4
api/tangled/pullcomment.go
+6
-4
api/tangled/pullcomment.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPullComment
19
19
type RepoPullComment struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
-
Body string `json:"body" cborgen:"body"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Pull string `json:"pull" cborgen:"pull"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
Pull string `json:"pull" cborgen:"pull"`
25
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
24
26
}
+13
-1
api/tangled/repoblob.go
+13
-1
api/tangled/repoblob.go
···
30
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
31
type RepoBlob_Output struct {
32
32
// content: File content (base64 encoded for binary files)
33
-
Content string `json:"content" cborgen:"content"`
33
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
34
34
// encoding: Content encoding
35
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
36
// isBinary: Whether the file is binary
···
44
44
Ref string `json:"ref" cborgen:"ref"`
45
45
// size: File size in bytes
46
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"`
47
49
}
48
50
49
51
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
54
56
Name string `json:"name" cborgen:"name"`
55
57
// when: Author timestamp
56
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"`
57
69
}
58
70
59
71
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+7
-5
api/tangled/repoissue.go
+7
-5
api/tangled/repoissue.go
···
17
17
} //
18
18
// RECORDTYPE: RepoIssue
19
19
type RepoIssue struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Repo string `json:"repo" cborgen:"repo"`
24
-
Title string `json:"title" cborgen:"title"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
25
+
Repo string `json:"repo" cborgen:"repo"`
26
+
Title string `json:"title" cborgen:"title"`
25
27
}
+12
-7
api/tangled/repopull.go
+12
-7
api/tangled/repopull.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPull
19
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Patch string `json:"patch" cborgen:"patch"`
24
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
25
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
26
-
Title string `json:"title" cborgen:"title"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
// patch: (deprecated) use patchBlob instead
25
+
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
+
// patchBlob: patch content
27
+
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
+
Title string `json:"title" cborgen:"title"`
27
32
}
28
33
29
34
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
-4
api/tangled/repotree.go
-4
api/tangled/repotree.go
···
47
47
48
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
49
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
50
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
55
51
// mode: File mode
56
52
Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
+4
api/tangled/tangledrepo.go
···
30
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
31
// spindle: CI runner to send jobs to and receive results from
32
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"`
33
37
}
+6
-45
appview/commitverify/verify.go
+6
-45
appview/commitverify/verify.go
···
3
3
import (
4
4
"log"
5
5
6
-
"github.com/go-git/go-git/v5/plumbing/object"
7
6
"tangled.org/core/appview/db"
8
7
"tangled.org/core/appview/models"
9
8
"tangled.org/core/crypto"
···
35
34
return ""
36
35
}
37
36
38
-
func GetVerifiedObjectCommits(e db.Execer, emailToDid map[string]string, commits []*object.Commit) (VerifiedCommits, error) {
39
-
ndCommits := []types.NiceDiff{}
40
-
for _, commit := range commits {
41
-
ndCommits = append(ndCommits, ObjectCommitToNiceDiff(commit))
42
-
}
43
-
return GetVerifiedCommits(e, emailToDid, ndCommits)
44
-
}
45
-
46
-
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
37
+
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.Commit) (VerifiedCommits, error) {
47
38
vcs := VerifiedCommits{}
48
39
49
40
didPubkeyCache := make(map[string][]models.PublicKey)
50
41
51
42
for _, commit := range ndCommits {
52
-
c := commit.Commit
53
-
54
-
committerEmail := c.Committer.Email
43
+
committerEmail := commit.Committer.Email
55
44
if did, exists := emailToDid[committerEmail]; exists {
56
45
// check if we've already fetched public keys for this did
57
46
pubKeys, ok := didPubkeyCache[did]
···
67
56
}
68
57
69
58
// try to verify with any associated pubkeys
59
+
payload := commit.Payload()
60
+
signature := commit.PGPSignature
70
61
for _, pk := range pubKeys {
71
-
if _, ok := crypto.VerifyCommitSignature(pk.Key, commit); ok {
62
+
if _, ok := crypto.VerifySignature([]byte(pk.Key), []byte(signature), []byte(payload)); ok {
72
63
73
64
fp, err := crypto.SSHFingerprint(pk.Key)
74
65
if err != nil {
75
66
log.Println("error computing ssh fingerprint:", err)
76
67
}
77
68
78
-
vc := verifiedCommit{fingerprint: fp, hash: c.This}
69
+
vc := verifiedCommit{fingerprint: fp, hash: commit.This}
79
70
vcs[vc] = struct{}{}
80
71
break
81
72
}
···
86
77
87
78
return vcs, nil
88
79
}
89
-
90
-
// ObjectCommitToNiceDiff is a compatibility function to convert a
91
-
// commit object into a NiceDiff structure.
92
-
func ObjectCommitToNiceDiff(c *object.Commit) types.NiceDiff {
93
-
var niceDiff types.NiceDiff
94
-
95
-
// set commit information
96
-
niceDiff.Commit.Message = c.Message
97
-
niceDiff.Commit.Author = c.Author
98
-
niceDiff.Commit.This = c.Hash.String()
99
-
niceDiff.Commit.Committer = c.Committer
100
-
niceDiff.Commit.Tree = c.TreeHash.String()
101
-
niceDiff.Commit.PGPSignature = c.PGPSignature
102
-
103
-
changeId, ok := c.ExtraHeaders["change-id"]
104
-
if ok {
105
-
niceDiff.Commit.ChangedId = string(changeId)
106
-
}
107
-
108
-
// set parent hash if available
109
-
if len(c.ParentHashes) > 0 {
110
-
niceDiff.Commit.Parent = c.ParentHashes[0].String()
111
-
}
112
-
113
-
// XXX: Stats and Diff fields are typically populated
114
-
// after fetching the actual diff information, which isn't
115
-
// directly available in the commit object itself.
116
-
117
-
return niceDiff
118
-
}
+11
appview/config/config.go
+11
appview/config/config.go
···
30
30
ClientKid string `env:"CLIENT_KID"`
31
31
}
32
32
33
+
type PlcConfig struct {
34
+
PLCURL string `env:"URL, default=https://plc.directory"`
35
+
}
36
+
33
37
type JetstreamConfig struct {
34
38
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
35
39
}
···
80
84
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
81
85
}
82
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
+
83
92
func (cfg RedisConfig) ToURL() string {
84
93
u := &url.URL{
85
94
Scheme: "redis",
···
105
114
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
106
115
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
107
116
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
108
118
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
109
119
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
110
121
}
111
122
112
123
func LoadConfig(ctx context.Context) (*Config, error) {
+3
-2
appview/db/artifact.go
+3
-2
appview/db/artifact.go
···
8
8
"github.com/go-git/go-git/v5/plumbing"
9
9
"github.com/ipfs/go-cid"
10
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
11
12
)
12
13
13
14
func AddArtifact(e Execer, artifact models.Artifact) error {
···
37
38
return err
38
39
}
39
40
40
-
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
+
func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) {
41
42
var artifacts []models.Artifact
42
43
43
44
var conditions []string
···
109
110
return artifacts, nil
110
111
}
111
112
112
-
func DeleteArtifact(e Execer, filters ...filter) error {
113
+
func DeleteArtifact(e Execer, filters ...orm.Filter) error {
113
114
var conditions []string
114
115
var args []any
115
116
for _, filter := range filters {
+4
-3
appview/db/collaborators.go
+4
-3
appview/db/collaborators.go
···
6
6
"time"
7
7
8
8
"tangled.org/core/appview/models"
9
+
"tangled.org/core/orm"
9
10
)
10
11
11
12
func AddCollaborator(e Execer, c models.Collaborator) error {
···
16
17
return err
17
18
}
18
19
19
-
func DeleteCollaborator(e Execer, filters ...filter) error {
20
+
func DeleteCollaborator(e Execer, filters ...orm.Filter) error {
20
21
var conditions []string
21
22
var args []any
22
23
for _, filter := range filters {
···
58
59
return nil, nil
59
60
}
60
61
61
-
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
62
+
return GetRepos(e, 0, orm.FilterIn("at_uri", repoAts))
62
63
}
63
64
64
-
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
+
func GetCollaborators(e Execer, filters ...orm.Filter) ([]models.Collaborator, error) {
65
66
var collaborators []models.Collaborator
66
67
var conditions []string
67
68
var args []any
+80
-132
appview/db/db.go
+80
-132
appview/db/db.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"fmt"
7
6
"log/slog"
8
-
"reflect"
9
7
"strings"
10
8
11
9
_ "github.com/mattn/go-sqlite3"
12
10
"tangled.org/core/log"
11
+
"tangled.org/core/orm"
13
12
)
14
13
15
14
type DB struct {
···
561
560
email_notifications integer not null default 0
562
561
);
563
562
563
+
create table if not exists reference_links (
564
+
id integer primary key autoincrement,
565
+
from_at text not null,
566
+
to_at text not null,
567
+
unique (from_at, to_at)
568
+
);
569
+
564
570
create table if not exists migrations (
565
571
id integer primary key autoincrement,
566
572
name text unique
···
569
575
-- indexes for better performance
570
576
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
571
577
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);
578
+
create index if not exists idx_references_from_at on reference_links(from_at);
579
+
create index if not exists idx_references_to_at on reference_links(to_at);
574
580
`)
575
581
if err != nil {
576
582
return nil, err
577
583
}
578
584
579
585
// run migrations
580
-
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
586
+
orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
581
587
tx.Exec(`
582
588
alter table repos add column description text check (length(description) <= 200);
583
589
`)
584
590
return nil
585
591
})
586
592
587
-
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
593
+
orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
588
594
// add unconstrained column
589
595
_, err := tx.Exec(`
590
596
alter table public_keys
···
607
613
return nil
608
614
})
609
615
610
-
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
616
+
orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
611
617
_, err := tx.Exec(`
612
618
alter table comments drop column comment_at;
613
619
alter table comments add column rkey text;
···
615
621
return err
616
622
})
617
623
618
-
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
624
+
orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
619
625
_, err := tx.Exec(`
620
626
alter table comments add column deleted text; -- timestamp
621
627
alter table comments add column edited text; -- timestamp
···
623
629
return err
624
630
})
625
631
626
-
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
632
+
orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
627
633
_, err := tx.Exec(`
628
634
alter table pulls add column source_branch text;
629
635
alter table pulls add column source_repo_at text;
···
632
638
return err
633
639
})
634
640
635
-
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
641
+
orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
636
642
_, err := tx.Exec(`
637
643
alter table repos add column source text;
638
644
`)
···
644
650
//
645
651
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
646
652
conn.ExecContext(ctx, "pragma foreign_keys = off;")
647
-
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
653
+
orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
648
654
_, err := tx.Exec(`
649
655
create table pulls_new (
650
656
-- identifiers
···
701
707
})
702
708
conn.ExecContext(ctx, "pragma foreign_keys = on;")
703
709
704
-
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
710
+
orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
705
711
tx.Exec(`
706
712
alter table repos add column spindle text;
707
713
`)
···
711
717
// drop all knot secrets, add unique constraint to knots
712
718
//
713
719
// knots will henceforth use service auth for signed requests
714
-
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
720
+
orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
715
721
_, err := tx.Exec(`
716
722
create table registrations_new (
717
723
id integer primary key autoincrement,
···
734
740
})
735
741
736
742
// recreate and add rkey + created columns with default constraint
737
-
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
743
+
orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
738
744
// create new table
739
745
// - repo_at instead of repo integer
740
746
// - rkey field
···
788
794
return err
789
795
})
790
796
791
-
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
797
+
orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
792
798
_, err := tx.Exec(`
793
799
alter table issues add column rkey text not null default '';
794
800
···
800
806
})
801
807
802
808
// repurpose the read-only column to "needs-upgrade"
803
-
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
809
+
orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
804
810
_, err := tx.Exec(`
805
811
alter table registrations rename column read_only to needs_upgrade;
806
812
`)
···
808
814
})
809
815
810
816
// require all knots to upgrade after the release of total xrpc
811
-
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
817
+
orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
812
818
_, err := tx.Exec(`
813
819
update registrations set needs_upgrade = 1;
814
820
`)
···
816
822
})
817
823
818
824
// require all knots to upgrade after the release of total xrpc
819
-
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
825
+
orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
820
826
_, err := tx.Exec(`
821
827
alter table spindles add column needs_upgrade integer not null default 0;
822
828
`)
···
834
840
//
835
841
// disable foreign-keys for the next migration
836
842
conn.ExecContext(ctx, "pragma foreign_keys = off;")
837
-
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
843
+
orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
838
844
_, err := tx.Exec(`
839
845
create table if not exists issues_new (
840
846
-- identifiers
···
904
910
// - new columns
905
911
// * column "reply_to" which can be any other comment
906
912
// * column "at-uri" which is a generated column
907
-
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
913
+
orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
908
914
_, err := tx.Exec(`
909
915
create table if not exists issue_comments (
910
916
-- identifiers
···
964
970
//
965
971
// disable foreign-keys for the next migration
966
972
conn.ExecContext(ctx, "pragma foreign_keys = off;")
967
-
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
973
+
orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
968
974
_, err := tx.Exec(`
969
975
create table if not exists pulls_new (
970
976
-- identifiers
···
1045
1051
//
1046
1052
// disable foreign-keys for the next migration
1047
1053
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1048
-
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1054
+
orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1049
1055
_, err := tx.Exec(`
1050
1056
create table if not exists pull_submissions_new (
1051
1057
-- identifiers
···
1099
1105
1100
1106
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
1107
// (but not on the pds record), because calculating the combined patch requires a git index
1102
-
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1108
+
orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
1109
_, err := tx.Exec(`
1104
1110
alter table pull_submissions add column combined text;
1105
1111
`)
1106
1112
return err
1107
1113
})
1108
1114
1109
-
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1115
+
orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1110
1116
_, err := tx.Exec(`
1111
1117
alter table profile add column pronouns text;
1112
1118
`)
1113
1119
return err
1114
1120
})
1115
1121
1116
-
return &DB{
1117
-
db,
1118
-
logger,
1119
-
}, nil
1120
-
}
1121
-
1122
-
type migrationFn = func(*sql.Tx) error
1123
-
1124
-
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1125
-
logger = logger.With("migration", name)
1126
-
1127
-
tx, err := c.BeginTx(context.Background(), nil)
1128
-
if err != nil {
1122
+
orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1123
+
_, err := tx.Exec(`
1124
+
alter table repos add column website text;
1125
+
alter table repos add column topics text;
1126
+
`)
1129
1127
return err
1130
-
}
1131
-
defer tx.Rollback()
1128
+
})
1132
1129
1133
-
var exists bool
1134
-
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
1135
-
if err != nil {
1130
+
orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1131
+
_, err := tx.Exec(`
1132
+
alter table notification_preferences add column user_mentioned integer not null default 1;
1133
+
`)
1136
1134
return err
1137
-
}
1138
-
1139
-
if !exists {
1140
-
// run migration
1141
-
err = migrationFn(tx)
1142
-
if err != nil {
1143
-
logger.Error("failed to run migration", "err", err)
1144
-
return err
1145
-
}
1146
-
1147
-
// mark migration as complete
1148
-
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1149
-
if err != nil {
1150
-
logger.Error("failed to mark migration as complete", "err", err)
1151
-
return err
1152
-
}
1153
-
1154
-
// commit the transaction
1155
-
if err := tx.Commit(); err != nil {
1156
-
return err
1157
-
}
1158
-
1159
-
logger.Info("migration applied successfully")
1160
-
} else {
1161
-
logger.Warn("skipped migration, already applied")
1162
-
}
1135
+
})
1163
1136
1164
-
return nil
1165
-
}
1137
+
// remove the foreign key constraints from stars.
1138
+
orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1139
+
_, err := tx.Exec(`
1140
+
create table stars_new (
1141
+
id integer primary key autoincrement,
1142
+
did text not null,
1143
+
rkey text not null,
1166
1144
1167
-
func (d *DB) Close() error {
1168
-
return d.DB.Close()
1169
-
}
1145
+
subject_at text not null,
1170
1146
1171
-
type filter struct {
1172
-
key string
1173
-
arg any
1174
-
cmp string
1175
-
}
1147
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1148
+
unique(did, rkey),
1149
+
unique(did, subject_at)
1150
+
);
1176
1151
1177
-
func newFilter(key, cmp string, arg any) filter {
1178
-
return filter{
1179
-
key: key,
1180
-
arg: arg,
1181
-
cmp: cmp,
1182
-
}
1183
-
}
1184
-
1185
-
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
1186
-
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
1187
-
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
1188
-
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
1189
-
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
1190
-
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
1191
-
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1192
-
func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) }
1193
-
func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) }
1194
-
func FilterContains(key string, arg any) filter {
1195
-
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
1196
-
}
1197
-
1198
-
func (f filter) Condition() string {
1199
-
rv := reflect.ValueOf(f.arg)
1200
-
kind := rv.Kind()
1201
-
1202
-
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
1203
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1204
-
if rv.Len() == 0 {
1205
-
// always false
1206
-
return "1 = 0"
1207
-
}
1152
+
insert into stars_new (
1153
+
id,
1154
+
did,
1155
+
rkey,
1156
+
subject_at,
1157
+
created
1158
+
)
1159
+
select
1160
+
id,
1161
+
starred_by_did,
1162
+
rkey,
1163
+
repo_at,
1164
+
created
1165
+
from stars;
1208
1166
1209
-
placeholders := make([]string, rv.Len())
1210
-
for i := range placeholders {
1211
-
placeholders[i] = "?"
1212
-
}
1167
+
drop table stars;
1168
+
alter table stars_new rename to stars;
1213
1169
1214
-
return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
1215
-
}
1170
+
create index if not exists idx_stars_created on stars(created);
1171
+
create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
1172
+
`)
1173
+
return err
1174
+
})
1216
1175
1217
-
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
1176
+
return &DB{
1177
+
db,
1178
+
logger,
1179
+
}, nil
1218
1180
}
1219
1181
1220
-
func (f filter) Arg() []any {
1221
-
rv := reflect.ValueOf(f.arg)
1222
-
kind := rv.Kind()
1223
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1224
-
if rv.Len() == 0 {
1225
-
return nil
1226
-
}
1227
-
1228
-
out := make([]any, rv.Len())
1229
-
for i := range rv.Len() {
1230
-
out[i] = rv.Index(i).Interface()
1231
-
}
1232
-
return out
1233
-
}
1234
-
1235
-
return []any{f.arg}
1182
+
func (d *DB) Close() error {
1183
+
return d.DB.Close()
1236
1184
}
+6
-3
appview/db/follow.go
+6
-3
appview/db/follow.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
13
func AddFollow(e Execer, follow *models.Follow) error {
···
134
135
return result, nil
135
136
}
136
137
137
-
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, error) {
138
139
var follows []models.Follow
139
140
140
141
var conditions []string
···
166
167
if err != nil {
167
168
return nil, err
168
169
}
170
+
defer rows.Close()
171
+
169
172
for rows.Next() {
170
173
var follow models.Follow
171
174
var followedAt string
···
191
194
}
192
195
193
196
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
194
-
return GetFollows(e, 0, FilterEq("subject_did", did))
197
+
return GetFollows(e, 0, orm.FilterEq("subject_did", did))
195
198
}
196
199
197
200
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
198
-
return GetFollows(e, 0, FilterEq("user_did", did))
201
+
return GetFollows(e, 0, orm.FilterEq("user_did", did))
199
202
}
200
203
201
204
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
+93
-36
appview/db/issues.go
+93
-36
appview/db/issues.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/api/tangled"
13
14
"tangled.org/core/appview/models"
14
15
"tangled.org/core/appview/pagination"
16
+
"tangled.org/core/orm"
15
17
)
16
18
17
19
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
···
26
28
27
29
issues, err := GetIssues(
28
30
tx,
29
-
FilterEq("did", issue.Did),
30
-
FilterEq("rkey", issue.Rkey),
31
+
orm.FilterEq("did", issue.Did),
32
+
orm.FilterEq("rkey", issue.Rkey),
31
33
)
32
34
switch {
33
35
case err != nil:
···
69
71
returning rowid, issue_id
70
72
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
71
73
72
-
return row.Scan(&issue.Id, &issue.IssueId)
74
+
err = row.Scan(&issue.Id, &issue.IssueId)
75
+
if err != nil {
76
+
return fmt.Errorf("scan row: %w", err)
77
+
}
78
+
79
+
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
80
+
return fmt.Errorf("put reference_links: %w", err)
81
+
}
82
+
return nil
73
83
}
74
84
75
85
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
···
79
89
set title = ?, body = ?, edited = ?
80
90
where did = ? and rkey = ?
81
91
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
82
-
return err
92
+
if err != nil {
93
+
return err
94
+
}
95
+
96
+
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
97
+
return fmt.Errorf("put reference_links: %w", err)
98
+
}
99
+
return nil
83
100
}
84
101
85
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
102
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) {
86
103
issueMap := make(map[string]*models.Issue) // at-uri -> issue
87
104
88
105
var conditions []string
···
98
115
whereClause = " where " + strings.Join(conditions, " and ")
99
116
}
100
117
101
-
pLower := FilterGte("row_num", page.Offset+1)
102
-
pUpper := FilterLte("row_num", page.Offset+page.Limit)
118
+
pLower := orm.FilterGte("row_num", page.Offset+1)
119
+
pUpper := orm.FilterLte("row_num", page.Offset+page.Limit)
103
120
104
121
pageClause := ""
105
122
if page.Limit > 0 {
···
189
206
repoAts = append(repoAts, string(issue.RepoAt))
190
207
}
191
208
192
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
209
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts))
193
210
if err != nil {
194
211
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
195
212
}
···
212
229
// collect comments
213
230
issueAts := slices.Collect(maps.Keys(issueMap))
214
231
215
-
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
232
+
comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts))
216
233
if err != nil {
217
234
return nil, fmt.Errorf("failed to query comments: %w", err)
218
235
}
···
224
241
}
225
242
226
243
// collect allLabels for each issue
227
-
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
244
+
allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts))
228
245
if err != nil {
229
246
return nil, fmt.Errorf("failed to query labels: %w", err)
230
247
}
···
234
251
}
235
252
}
236
253
254
+
// collect references for each issue
255
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", issueAts))
256
+
if err != nil {
257
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
258
+
}
259
+
for issueAt, references := range allReferencs {
260
+
if issue, ok := issueMap[issueAt.String()]; ok {
261
+
issue.References = references
262
+
}
263
+
}
264
+
237
265
var issues []models.Issue
238
266
for _, i := range issueMap {
239
267
issues = append(issues, *i)
···
250
278
issues, err := GetIssuesPaginated(
251
279
e,
252
280
pagination.Page{},
253
-
FilterEq("repo_at", repoAt),
254
-
FilterEq("issue_id", issueId),
281
+
orm.FilterEq("repo_at", repoAt),
282
+
orm.FilterEq("issue_id", issueId),
255
283
)
256
284
if err != nil {
257
285
return nil, err
···
263
291
return &issues[0], nil
264
292
}
265
293
266
-
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
294
+
func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) {
267
295
return GetIssuesPaginated(e, pagination.Page{}, filters...)
268
296
}
269
297
···
271
299
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
272
300
var ids []int64
273
301
274
-
var filters []filter
302
+
var filters []orm.Filter
275
303
openValue := 0
276
304
if opts.IsOpen {
277
305
openValue = 1
278
306
}
279
-
filters = append(filters, FilterEq("open", openValue))
307
+
filters = append(filters, orm.FilterEq("open", openValue))
280
308
if opts.RepoAt != "" {
281
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
309
+
filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt))
282
310
}
283
311
284
312
var conditions []string
···
323
351
return ids, nil
324
352
}
325
353
326
-
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
327
-
result, err := e.Exec(
354
+
func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
355
+
result, err := tx.Exec(
328
356
`insert into issue_comments (
329
357
did,
330
358
rkey,
···
363
391
return 0, err
364
392
}
365
393
394
+
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
395
+
return 0, fmt.Errorf("put reference_links: %w", err)
396
+
}
397
+
366
398
return id, nil
367
399
}
368
400
369
-
func DeleteIssueComments(e Execer, filters ...filter) error {
401
+
func DeleteIssueComments(e Execer, filters ...orm.Filter) error {
370
402
var conditions []string
371
403
var args []any
372
404
for _, filter := range filters {
···
385
417
return err
386
418
}
387
419
388
-
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
389
-
var comments []models.IssueComment
420
+
func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) {
421
+
commentMap := make(map[string]*models.IssueComment)
390
422
391
423
var conditions []string
392
424
var args []any
···
420
452
if err != nil {
421
453
return nil, err
422
454
}
455
+
defer rows.Close()
423
456
424
457
for rows.Next() {
425
458
var comment models.IssueComment
···
465
498
comment.ReplyTo = &replyTo.V
466
499
}
467
500
468
-
comments = append(comments, comment)
501
+
atUri := comment.AtUri().String()
502
+
commentMap[atUri] = &comment
469
503
}
470
504
471
505
if err = rows.Err(); err != nil {
472
506
return nil, err
473
507
}
474
508
509
+
// collect references for each comments
510
+
commentAts := slices.Collect(maps.Keys(commentMap))
511
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
512
+
if err != nil {
513
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
514
+
}
515
+
for commentAt, references := range allReferencs {
516
+
if comment, ok := commentMap[commentAt.String()]; ok {
517
+
comment.References = references
518
+
}
519
+
}
520
+
521
+
var comments []models.IssueComment
522
+
for _, c := range commentMap {
523
+
comments = append(comments, *c)
524
+
}
525
+
526
+
sort.Slice(comments, func(i, j int) bool {
527
+
return comments[i].Created.After(comments[j].Created)
528
+
})
529
+
475
530
return comments, nil
476
531
}
477
532
478
-
func DeleteIssues(e Execer, filters ...filter) error {
479
-
var conditions []string
480
-
var args []any
481
-
for _, filter := range filters {
482
-
conditions = append(conditions, filter.Condition())
483
-
args = append(args, filter.Arg()...)
533
+
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
534
+
_, err := tx.Exec(
535
+
`delete from issues
536
+
where did = ? and rkey = ?`,
537
+
did,
538
+
rkey,
539
+
)
540
+
if err != nil {
541
+
return fmt.Errorf("delete issue: %w", err)
484
542
}
485
543
486
-
whereClause := ""
487
-
if conditions != nil {
488
-
whereClause = " where " + strings.Join(conditions, " and ")
544
+
uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey))
545
+
err = deleteReferences(tx, uri)
546
+
if err != nil {
547
+
return fmt.Errorf("delete reference_links: %w", err)
489
548
}
490
549
491
-
query := fmt.Sprintf(`delete from issues %s`, whereClause)
492
-
_, err := e.Exec(query, args...)
493
-
return err
550
+
return nil
494
551
}
495
552
496
-
func CloseIssues(e Execer, filters ...filter) error {
553
+
func CloseIssues(e Execer, filters ...orm.Filter) error {
497
554
var conditions []string
498
555
var args []any
499
556
for _, filter := range filters {
···
511
568
return err
512
569
}
513
570
514
-
func ReopenIssues(e Execer, filters ...filter) error {
571
+
func ReopenIssues(e Execer, filters ...orm.Filter) error {
515
572
var conditions []string
516
573
var args []any
517
574
for _, filter := range filters {
+8
-7
appview/db/label.go
+8
-7
appview/db/label.go
···
10
10
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"tangled.org/core/appview/models"
13
+
"tangled.org/core/orm"
13
14
)
14
15
15
16
// no updating type for now
···
59
60
return id, nil
60
61
}
61
62
62
-
func DeleteLabelDefinition(e Execer, filters ...filter) error {
63
+
func DeleteLabelDefinition(e Execer, filters ...orm.Filter) error {
63
64
var conditions []string
64
65
var args []any
65
66
for _, filter := range filters {
···
75
76
return err
76
77
}
77
78
78
-
func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) {
79
+
func GetLabelDefinitions(e Execer, filters ...orm.Filter) ([]models.LabelDefinition, error) {
79
80
var labelDefinitions []models.LabelDefinition
80
81
var conditions []string
81
82
var args []any
···
167
168
}
168
169
169
170
// helper to get exactly one label def
170
-
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
171
+
func GetLabelDefinition(e Execer, filters ...orm.Filter) (*models.LabelDefinition, error) {
171
172
labels, err := GetLabelDefinitions(e, filters...)
172
173
if err != nil {
173
174
return nil, err
···
227
228
return id, nil
228
229
}
229
230
230
-
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
+
func GetLabelOps(e Execer, filters ...orm.Filter) ([]models.LabelOp, error) {
231
232
var labelOps []models.LabelOp
232
233
var conditions []string
233
234
var args []any
···
302
303
}
303
304
304
305
// get labels for a given list of subject URIs
305
-
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
306
+
func GetLabels(e Execer, filters ...orm.Filter) (map[syntax.ATURI]models.LabelState, error) {
306
307
ops, err := GetLabelOps(e, filters...)
307
308
if err != nil {
308
309
return nil, err
···
322
323
}
323
324
labelAts := slices.Collect(maps.Keys(labelAtSet))
324
325
325
-
actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts))
326
+
actx, err := NewLabelApplicationCtx(e, orm.FilterIn("at_uri", labelAts))
326
327
if err != nil {
327
328
return nil, err
328
329
}
···
338
339
return results, nil
339
340
}
340
341
341
-
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
342
+
func NewLabelApplicationCtx(e Execer, filters ...orm.Filter) (*models.LabelApplicationCtx, error) {
342
343
labels, err := GetLabelDefinitions(e, filters...)
343
344
if err != nil {
344
345
return nil, err
+6
-5
appview/db/language.go
+6
-5
appview/db/language.go
···
7
7
8
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
13
+
func GetRepoLanguages(e Execer, filters ...orm.Filter) ([]models.RepoLanguage, error) {
13
14
var conditions []string
14
15
var args []any
15
16
for _, filter := range filters {
···
27
28
whereClause,
28
29
)
29
30
rows, err := e.Query(query, args...)
30
-
31
31
if err != nil {
32
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
33
33
}
34
+
defer rows.Close()
34
35
35
36
var langs []models.RepoLanguage
36
37
for rows.Next() {
···
85
86
return nil
86
87
}
87
88
88
-
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
func DeleteRepoLanguages(e Execer, filters ...orm.Filter) error {
89
90
var conditions []string
90
91
var args []any
91
92
for _, filter := range filters {
···
107
108
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
109
err := DeleteRepoLanguages(
109
110
tx,
110
-
FilterEq("repo_at", repoAt),
111
-
FilterEq("ref", ref),
111
+
orm.FilterEq("repo_at", repoAt),
112
+
orm.FilterEq("ref", ref),
112
113
)
113
114
if err != nil {
114
115
return fmt.Errorf("failed to delete existing languages: %w", err)
+29
-18
appview/db/notifications.go
+29
-18
appview/db/notifications.go
···
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"tangled.org/core/appview/models"
13
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
func CreateNotification(e Execer, notification *models.Notification) error {
···
44
45
}
45
46
46
47
// GetNotificationsPaginated retrieves notifications with filters and pagination
47
-
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
48
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) {
48
49
var conditions []string
49
50
var args []any
50
51
···
113
114
}
114
115
115
116
// GetNotificationsWithEntities retrieves notifications with their related entities
116
-
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
117
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) {
117
118
var conditions []string
118
119
var args []any
119
120
···
134
135
select
135
136
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
136
137
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
+
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
139
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
140
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
141
from notifications n
···
163
164
var issue models.Issue
164
165
var pull models.Pull
165
166
var rId, iId, pId sql.NullInt64
166
-
var rDid, rName, rDescription sql.NullString
167
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
167
168
var iDid sql.NullString
168
169
var iIssueId sql.NullInt64
169
170
var iTitle sql.NullString
···
176
177
err := rows.Scan(
177
178
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
178
179
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
179
-
&rId, &rDid, &rName, &rDescription,
180
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
180
181
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
181
182
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
182
183
)
···
204
205
if rDescription.Valid {
205
206
repo.Description = rDescription.String
206
207
}
208
+
if rWebsite.Valid {
209
+
repo.Website = rWebsite.String
210
+
}
211
+
if rTopicStr.Valid {
212
+
repo.Topics = strings.Fields(rTopicStr.String)
213
+
}
207
214
nwe.Repo = &repo
208
215
}
209
216
···
250
257
}
251
258
252
259
// GetNotifications retrieves notifications with filters
253
-
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
260
+
func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) {
254
261
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
255
262
}
256
263
257
-
func CountNotifications(e Execer, filters ...filter) (int64, error) {
264
+
func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) {
258
265
var conditions []string
259
266
var args []any
260
267
for _, filter := range filters {
···
279
286
}
280
287
281
288
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
282
-
idFilter := FilterEq("id", notificationID)
283
-
recipientFilter := FilterEq("recipient_did", userDID)
289
+
idFilter := orm.FilterEq("id", notificationID)
290
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
284
291
285
292
query := fmt.Sprintf(`
286
293
UPDATE notifications
···
308
315
}
309
316
310
317
func MarkAllNotificationsRead(e Execer, userDID string) error {
311
-
recipientFilter := FilterEq("recipient_did", userDID)
312
-
readFilter := FilterEq("read", 0)
318
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
319
+
readFilter := orm.FilterEq("read", 0)
313
320
314
321
query := fmt.Sprintf(`
315
322
UPDATE notifications
···
328
335
}
329
336
330
337
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
331
-
idFilter := FilterEq("id", notificationID)
332
-
recipientFilter := FilterEq("recipient_did", userDID)
338
+
idFilter := orm.FilterEq("id", notificationID)
339
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
333
340
334
341
query := fmt.Sprintf(`
335
342
DELETE FROM notifications
···
356
363
}
357
364
358
365
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
359
-
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
366
+
prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid))
360
367
if err != nil {
361
368
return nil, err
362
369
}
···
369
376
return p, nil
370
377
}
371
378
372
-
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
+
func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) {
373
380
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
374
381
375
382
var conditions []string
···
394
401
pull_created,
395
402
pull_commented,
396
403
followed,
404
+
user_mentioned,
397
405
pull_merged,
398
406
issue_closed,
399
407
email_notifications
···
419
427
&prefs.PullCreated,
420
428
&prefs.PullCommented,
421
429
&prefs.Followed,
430
+
&prefs.UserMentioned,
422
431
&prefs.PullMerged,
423
432
&prefs.IssueClosed,
424
433
&prefs.EmailNotifications,
···
440
449
query := `
441
450
INSERT OR REPLACE INTO notification_preferences
442
451
(user_did, repo_starred, issue_created, issue_commented, pull_created,
443
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
444
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
452
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
453
+
email_notifications)
454
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
445
455
`
446
456
447
457
result, err := d.DB.ExecContext(ctx, query,
···
452
462
prefs.PullCreated,
453
463
prefs.PullCommented,
454
464
prefs.Followed,
465
+
prefs.UserMentioned,
455
466
prefs.PullMerged,
456
467
prefs.IssueClosed,
457
468
prefs.EmailNotifications,
···
473
484
474
485
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
475
486
cutoff := time.Now().Add(-olderThan)
476
-
createdFilter := FilterLte("created", cutoff)
487
+
createdFilter := orm.FilterLte("created", cutoff)
477
488
478
489
query := fmt.Sprintf(`
479
490
DELETE FROM notifications
+9
-6
appview/db/pipeline.go
+9
-6
appview/db/pipeline.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
+
func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) {
13
14
var pipelines []models.Pipeline
14
15
15
16
var conditions []string
···
168
169
169
170
// this is a mega query, but the most useful one:
170
171
// 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
+
func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) {
172
173
var conditions []string
173
174
var args []any
174
175
for _, filter := range filters {
175
-
filter.key = "p." + filter.key // the table is aliased in the query to `p`
176
+
filter.Key = "p." + filter.Key // the table is aliased in the query to `p`
176
177
conditions = append(conditions, filter.Condition())
177
178
args = append(args, filter.Arg()...)
178
179
}
···
205
206
join
206
207
triggers t ON p.trigger_id = t.id
207
208
%s
208
-
`, whereClause)
209
+
order by p.created desc
210
+
limit %d
211
+
`, whereClause, limit)
209
212
210
213
rows, err := e.Query(query, args...)
211
214
if err != nil {
···
262
265
conditions = nil
263
266
args = nil
264
267
for _, p := range pipelines {
265
-
knotFilter := FilterEq("pipeline_knot", p.Knot)
266
-
rkeyFilter := FilterEq("pipeline_rkey", p.Rkey)
268
+
knotFilter := orm.FilterEq("pipeline_knot", p.Knot)
269
+
rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey)
267
270
conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition()))
268
271
args = append(args, p.Knot)
269
272
args = append(args, p.Rkey)
+29
-16
appview/db/profile.go
+29
-16
appview/db/profile.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
const TimeframeMonths = 7
···
19
20
timeline := models.ProfileTimeline{
20
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
21
22
}
22
-
currentMonth := time.Now().Month()
23
+
now := time.Now()
23
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
24
25
25
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
29
30
30
31
// group pulls by month
31
32
for _, pull := range pulls {
32
-
pullMonth := pull.Created.Month()
33
+
monthsAgo := monthsBetween(pull.Created, now)
33
34
34
-
if currentMonth-pullMonth >= TimeframeMonths {
35
+
if monthsAgo >= TimeframeMonths {
35
36
// shouldn't happen; but times are weird
36
37
continue
37
38
}
38
39
39
-
idx := currentMonth - pullMonth
40
+
idx := monthsAgo
40
41
items := &timeline.ByMonth[idx].PullEvents.Items
41
42
42
43
*items = append(*items, &pull)
···
44
45
45
46
issues, err := GetIssues(
46
47
e,
47
-
FilterEq("did", forDid),
48
-
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
48
+
orm.FilterEq("did", forDid),
49
+
orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
49
50
)
50
51
if err != nil {
51
52
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
52
53
}
53
54
54
55
for _, issue := range issues {
55
-
issueMonth := issue.Created.Month()
56
+
monthsAgo := monthsBetween(issue.Created, now)
56
57
57
-
if currentMonth-issueMonth >= TimeframeMonths {
58
+
if monthsAgo >= TimeframeMonths {
58
59
// shouldn't happen; but times are weird
59
60
continue
60
61
}
61
62
62
-
idx := currentMonth - issueMonth
63
+
idx := monthsAgo
63
64
items := &timeline.ByMonth[idx].IssueEvents.Items
64
65
65
66
*items = append(*items, &issue)
66
67
}
67
68
68
-
repos, err := GetRepos(e, 0, FilterEq("did", forDid))
69
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid))
69
70
if err != nil {
70
71
return nil, fmt.Errorf("error getting all repos by did: %w", err)
71
72
}
···
76
77
if repo.Source != "" {
77
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
78
79
if err != nil {
79
-
return nil, err
80
+
// the source repo was not found, skip this bit
81
+
log.Println("profile", "err", err)
80
82
}
81
83
}
82
84
83
-
repoMonth := repo.Created.Month()
85
+
monthsAgo := monthsBetween(repo.Created, now)
84
86
85
-
if currentMonth-repoMonth >= TimeframeMonths {
87
+
if monthsAgo >= TimeframeMonths {
86
88
// shouldn't happen; but times are weird
87
89
continue
88
90
}
89
91
90
-
idx := currentMonth - repoMonth
92
+
idx := monthsAgo
91
93
92
94
items := &timeline.ByMonth[idx].RepoEvents
93
95
*items = append(*items, models.RepoEvent{
···
99
101
return &timeline, nil
100
102
}
101
103
104
+
func monthsBetween(from, to time.Time) int {
105
+
years := to.Year() - from.Year()
106
+
months := int(to.Month() - from.Month())
107
+
return years*12 + months
108
+
}
109
+
102
110
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
103
111
defer tx.Rollback()
104
112
···
199
207
return tx.Commit()
200
208
}
201
209
202
-
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
210
+
func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
203
211
var conditions []string
204
212
var args []any
205
213
for _, filter := range filters {
···
229
237
if err != nil {
230
238
return nil, err
231
239
}
240
+
defer rows.Close()
232
241
233
242
profileMap := make(map[string]*models.Profile)
234
243
for rows.Next() {
···
269
278
if err != nil {
270
279
return nil, err
271
280
}
281
+
defer rows.Close()
282
+
272
283
idxs := make(map[string]int)
273
284
for did := range profileMap {
274
285
idxs[did] = 0
···
289
300
if err != nil {
290
301
return nil, err
291
302
}
303
+
defer rows.Close()
304
+
292
305
idxs = make(map[string]int)
293
306
for did := range profileMap {
294
307
idxs[did] = 0
···
441
454
}
442
455
443
456
// ensure all pinned repos are either own repos or collaborating repos
444
-
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
457
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did))
445
458
if err != nil {
446
459
log.Printf("getting repos for %s: %s", profile.Did, err)
447
460
}
+73
-28
appview/db/pulls.go
+73
-28
appview/db/pulls.go
···
13
13
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"tangled.org/core/appview/models"
16
+
"tangled.org/core/orm"
16
17
)
17
18
18
19
func NewPull(tx *sql.Tx, pull *models.Pull) error {
···
92
93
_, err = tx.Exec(`
93
94
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
95
values (?, ?, ?, ?, ?)
95
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
-
return err
96
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
97
+
if err != nil {
98
+
return err
99
+
}
100
+
101
+
if err := putReferences(tx, pull.AtUri(), pull.References); err != nil {
102
+
return fmt.Errorf("put reference_links: %w", err)
103
+
}
104
+
105
+
return nil
97
106
}
98
107
99
108
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
···
101
110
if err != nil {
102
111
return "", err
103
112
}
104
-
return pull.PullAt(), err
113
+
return pull.AtUri(), err
105
114
}
106
115
107
116
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
110
119
return pullId - 1, err
111
120
}
112
121
113
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
122
+
func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) {
114
123
pulls := make(map[syntax.ATURI]*models.Pull)
115
124
116
125
var conditions []string
···
214
223
pull.ParentChangeId = parentChangeId.String
215
224
}
216
225
217
-
pulls[pull.PullAt()] = &pull
226
+
pulls[pull.AtUri()] = &pull
218
227
}
219
228
220
229
var pullAts []syntax.ATURI
221
230
for _, p := range pulls {
222
-
pullAts = append(pullAts, p.PullAt())
231
+
pullAts = append(pullAts, p.AtUri())
223
232
}
224
-
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
233
+
submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts))
225
234
if err != nil {
226
235
return nil, fmt.Errorf("failed to get submissions: %w", err)
227
236
}
···
233
242
}
234
243
235
244
// collect allLabels for each issue
236
-
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
245
+
allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts))
237
246
if err != nil {
238
247
return nil, fmt.Errorf("failed to query labels: %w", err)
239
248
}
···
250
259
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
260
}
252
261
}
253
-
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
262
+
sourceRepos, err := GetRepos(e, 0, orm.FilterIn("at_uri", sourceAts))
254
263
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
264
return nil, fmt.Errorf("failed to get source repos: %w", err)
256
265
}
···
266
275
}
267
276
}
268
277
278
+
allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts))
279
+
if err != nil {
280
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
281
+
}
282
+
for pullAt, references := range allReferences {
283
+
if pull, ok := pulls[pullAt]; ok {
284
+
pull.References = references
285
+
}
286
+
}
287
+
269
288
orderedByPullId := []*models.Pull{}
270
289
for _, p := range pulls {
271
290
orderedByPullId = append(orderedByPullId, p)
···
277
296
return orderedByPullId, nil
278
297
}
279
298
280
-
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
299
+
func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) {
281
300
return GetPullsWithLimit(e, 0, filters...)
282
301
}
283
302
284
303
func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) {
285
304
var ids []int64
286
305
287
-
var filters []filter
288
-
filters = append(filters, FilterEq("state", opts.State))
306
+
var filters []orm.Filter
307
+
filters = append(filters, orm.FilterEq("state", opts.State))
289
308
if opts.RepoAt != "" {
290
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
309
+
filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt))
291
310
}
292
311
293
312
var conditions []string
···
343
362
}
344
363
345
364
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
346
-
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
365
+
pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId))
347
366
if err != nil {
348
367
return nil, err
349
368
}
···
355
374
}
356
375
357
376
// mapping from pull -> pull submissions
358
-
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
377
+
func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
359
378
var conditions []string
360
379
var args []any
361
380
for _, filter := range filters {
···
430
449
431
450
// Get comments for all submissions using GetPullComments
432
451
submissionIds := slices.Collect(maps.Keys(submissionMap))
433
-
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
452
+
comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds))
434
453
if err != nil {
435
-
return nil, err
454
+
return nil, fmt.Errorf("failed to get pull comments: %w", err)
436
455
}
437
456
for _, comment := range comments {
438
457
if submission, ok := submissionMap[comment.SubmissionId]; ok {
···
456
475
return m, nil
457
476
}
458
477
459
-
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
478
+
func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) {
460
479
var conditions []string
461
480
var args []any
462
481
for _, filter := range filters {
···
492
511
}
493
512
defer rows.Close()
494
513
495
-
var comments []models.PullComment
514
+
commentMap := make(map[string]*models.PullComment)
496
515
for rows.Next() {
497
516
var comment models.PullComment
498
517
var createdAt string
···
514
533
comment.Created = t
515
534
}
516
535
517
-
comments = append(comments, comment)
536
+
atUri := comment.AtUri().String()
537
+
commentMap[atUri] = &comment
518
538
}
519
539
520
540
if err := rows.Err(); err != nil {
521
541
return nil, err
522
542
}
523
543
544
+
// collect references for each comments
545
+
commentAts := slices.Collect(maps.Keys(commentMap))
546
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
547
+
if err != nil {
548
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
549
+
}
550
+
for commentAt, references := range allReferencs {
551
+
if comment, ok := commentMap[commentAt.String()]; ok {
552
+
comment.References = references
553
+
}
554
+
}
555
+
556
+
var comments []models.PullComment
557
+
for _, c := range commentMap {
558
+
comments = append(comments, *c)
559
+
}
560
+
561
+
sort.Slice(comments, func(i, j int) bool {
562
+
return comments[i].Created.Before(comments[j].Created)
563
+
})
564
+
524
565
return comments, nil
525
566
}
526
567
···
600
641
return pulls, nil
601
642
}
602
643
603
-
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
644
+
func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) {
604
645
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
605
-
res, err := e.Exec(
646
+
res, err := tx.Exec(
606
647
query,
607
648
comment.OwnerDid,
608
649
comment.RepoAt,
···
618
659
i, err := res.LastInsertId()
619
660
if err != nil {
620
661
return 0, err
662
+
}
663
+
664
+
if err := putReferences(tx, comment.AtUri(), comment.References); err != nil {
665
+
return 0, fmt.Errorf("put reference_links: %w", err)
621
666
}
622
667
623
668
return i, nil
···
664
709
return err
665
710
}
666
711
667
-
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error {
712
+
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error {
668
713
var conditions []string
669
714
var args []any
670
715
···
688
733
689
734
// Only used when stacking to update contents in the event of a rebase (the interdiff should be empty).
690
735
// otherwise submissions are immutable
691
-
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error {
736
+
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error {
692
737
var conditions []string
693
738
var args []any
694
739
···
746
791
func GetStack(e Execer, stackId string) (models.Stack, error) {
747
792
unorderedPulls, err := GetPulls(
748
793
e,
749
-
FilterEq("stack_id", stackId),
750
-
FilterNotEq("state", models.PullDeleted),
794
+
orm.FilterEq("stack_id", stackId),
795
+
orm.FilterNotEq("state", models.PullDeleted),
751
796
)
752
797
if err != nil {
753
798
return nil, err
···
791
836
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
792
837
pulls, err := GetPulls(
793
838
e,
794
-
FilterEq("stack_id", stackId),
795
-
FilterEq("state", models.PullDeleted),
839
+
orm.FilterEq("stack_id", stackId),
840
+
orm.FilterEq("state", models.PullDeleted),
796
841
)
797
842
if err != nil {
798
843
return nil, err
+3
-2
appview/db/punchcard.go
+3
-2
appview/db/punchcard.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
13
// this adds to the existing count
···
20
21
return err
21
22
}
22
23
23
-
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
+
func MakePunchcard(e Execer, filters ...orm.Filter) (*models.Punchcard, error) {
24
25
punchcard := &models.Punchcard{}
25
26
now := time.Now()
26
27
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
···
77
78
punch.Count = int(count.Int64)
78
79
}
79
80
80
-
punchcard.Punches[punch.Date.YearDay()] = punch
81
+
punchcard.Punches[punch.Date.YearDay()-1] = punch
81
82
punchcard.Total += punch.Count
82
83
}
83
84
+463
appview/db/reference.go
+463
appview/db/reference.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"strings"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
12
+
)
13
+
14
+
// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs.
15
+
// It will ignore missing refLinks.
16
+
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
17
+
var (
18
+
issueRefs []models.ReferenceLink
19
+
pullRefs []models.ReferenceLink
20
+
)
21
+
for _, ref := range refLinks {
22
+
switch ref.Kind {
23
+
case models.RefKindIssue:
24
+
issueRefs = append(issueRefs, ref)
25
+
case models.RefKindPull:
26
+
pullRefs = append(pullRefs, ref)
27
+
}
28
+
}
29
+
issueUris, err := findIssueReferences(e, issueRefs)
30
+
if err != nil {
31
+
return nil, fmt.Errorf("find issue references: %w", err)
32
+
}
33
+
pullUris, err := findPullReferences(e, pullRefs)
34
+
if err != nil {
35
+
return nil, fmt.Errorf("find pull references: %w", err)
36
+
}
37
+
38
+
return append(issueUris, pullUris...), nil
39
+
}
40
+
41
+
func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
42
+
if len(refLinks) == 0 {
43
+
return nil, nil
44
+
}
45
+
vals := make([]string, len(refLinks))
46
+
args := make([]any, 0, len(refLinks)*4)
47
+
for i, ref := range refLinks {
48
+
vals[i] = "(?, ?, ?, ?)"
49
+
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
50
+
}
51
+
query := fmt.Sprintf(
52
+
`with input(owner_did, name, issue_id, comment_id) as (
53
+
values %s
54
+
)
55
+
select
56
+
i.did, i.rkey,
57
+
c.did, c.rkey
58
+
from input inp
59
+
join repos r
60
+
on r.did = inp.owner_did
61
+
and r.name = inp.name
62
+
join issues i
63
+
on i.repo_at = r.at_uri
64
+
and i.issue_id = inp.issue_id
65
+
left join issue_comments c
66
+
on inp.comment_id is not null
67
+
and c.issue_at = i.at_uri
68
+
and c.id = inp.comment_id
69
+
`,
70
+
strings.Join(vals, ","),
71
+
)
72
+
rows, err := e.Query(query, args...)
73
+
if err != nil {
74
+
return nil, err
75
+
}
76
+
defer rows.Close()
77
+
78
+
var uris []syntax.ATURI
79
+
80
+
for rows.Next() {
81
+
// Scan rows
82
+
var issueOwner, issueRkey string
83
+
var commentOwner, commentRkey sql.NullString
84
+
var uri syntax.ATURI
85
+
if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil {
86
+
return nil, err
87
+
}
88
+
if commentOwner.Valid && commentRkey.Valid {
89
+
uri = syntax.ATURI(fmt.Sprintf(
90
+
"at://%s/%s/%s",
91
+
commentOwner.String,
92
+
tangled.RepoIssueCommentNSID,
93
+
commentRkey.String,
94
+
))
95
+
} else {
96
+
uri = syntax.ATURI(fmt.Sprintf(
97
+
"at://%s/%s/%s",
98
+
issueOwner,
99
+
tangled.RepoIssueNSID,
100
+
issueRkey,
101
+
))
102
+
}
103
+
uris = append(uris, uri)
104
+
}
105
+
if err := rows.Err(); err != nil {
106
+
return nil, fmt.Errorf("iterate rows: %w", err)
107
+
}
108
+
109
+
return uris, nil
110
+
}
111
+
112
+
func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
113
+
if len(refLinks) == 0 {
114
+
return nil, nil
115
+
}
116
+
vals := make([]string, len(refLinks))
117
+
args := make([]any, 0, len(refLinks)*4)
118
+
for i, ref := range refLinks {
119
+
vals[i] = "(?, ?, ?, ?)"
120
+
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
121
+
}
122
+
query := fmt.Sprintf(
123
+
`with input(owner_did, name, pull_id, comment_id) as (
124
+
values %s
125
+
)
126
+
select
127
+
p.owner_did, p.rkey,
128
+
c.comment_at
129
+
from input inp
130
+
join repos r
131
+
on r.did = inp.owner_did
132
+
and r.name = inp.name
133
+
join pulls p
134
+
on p.repo_at = r.at_uri
135
+
and p.pull_id = inp.pull_id
136
+
left join pull_comments c
137
+
on inp.comment_id is not null
138
+
and c.repo_at = r.at_uri and c.pull_id = p.pull_id
139
+
and c.id = inp.comment_id
140
+
`,
141
+
strings.Join(vals, ","),
142
+
)
143
+
rows, err := e.Query(query, args...)
144
+
if err != nil {
145
+
return nil, err
146
+
}
147
+
defer rows.Close()
148
+
149
+
var uris []syntax.ATURI
150
+
151
+
for rows.Next() {
152
+
// Scan rows
153
+
var pullOwner, pullRkey string
154
+
var commentUri sql.NullString
155
+
var uri syntax.ATURI
156
+
if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil {
157
+
return nil, err
158
+
}
159
+
if commentUri.Valid {
160
+
// no-op
161
+
uri = syntax.ATURI(commentUri.String)
162
+
} else {
163
+
uri = syntax.ATURI(fmt.Sprintf(
164
+
"at://%s/%s/%s",
165
+
pullOwner,
166
+
tangled.RepoPullNSID,
167
+
pullRkey,
168
+
))
169
+
}
170
+
uris = append(uris, uri)
171
+
}
172
+
return uris, nil
173
+
}
174
+
175
+
func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error {
176
+
err := deleteReferences(tx, fromAt)
177
+
if err != nil {
178
+
return fmt.Errorf("delete old reference_links: %w", err)
179
+
}
180
+
if len(references) == 0 {
181
+
return nil
182
+
}
183
+
184
+
values := make([]string, 0, len(references))
185
+
args := make([]any, 0, len(references)*2)
186
+
for _, ref := range references {
187
+
values = append(values, "(?, ?)")
188
+
args = append(args, fromAt, ref)
189
+
}
190
+
_, err = tx.Exec(
191
+
fmt.Sprintf(
192
+
`insert into reference_links (from_at, to_at)
193
+
values %s`,
194
+
strings.Join(values, ","),
195
+
),
196
+
args...,
197
+
)
198
+
if err != nil {
199
+
return fmt.Errorf("insert new reference_links: %w", err)
200
+
}
201
+
return nil
202
+
}
203
+
204
+
func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error {
205
+
_, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt)
206
+
return err
207
+
}
208
+
209
+
func GetReferencesAll(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]syntax.ATURI, error) {
210
+
var (
211
+
conditions []string
212
+
args []any
213
+
)
214
+
for _, filter := range filters {
215
+
conditions = append(conditions, filter.Condition())
216
+
args = append(args, filter.Arg()...)
217
+
}
218
+
219
+
whereClause := ""
220
+
if conditions != nil {
221
+
whereClause = " where " + strings.Join(conditions, " and ")
222
+
}
223
+
224
+
rows, err := e.Query(
225
+
fmt.Sprintf(
226
+
`select from_at, to_at from reference_links %s`,
227
+
whereClause,
228
+
),
229
+
args...,
230
+
)
231
+
if err != nil {
232
+
return nil, fmt.Errorf("query reference_links: %w", err)
233
+
}
234
+
defer rows.Close()
235
+
236
+
result := make(map[syntax.ATURI][]syntax.ATURI)
237
+
238
+
for rows.Next() {
239
+
var from, to syntax.ATURI
240
+
if err := rows.Scan(&from, &to); err != nil {
241
+
return nil, fmt.Errorf("scan row: %w", err)
242
+
}
243
+
244
+
result[from] = append(result[from], to)
245
+
}
246
+
if err := rows.Err(); err != nil {
247
+
return nil, fmt.Errorf("iterate rows: %w", err)
248
+
}
249
+
250
+
return result, nil
251
+
}
252
+
253
+
func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) {
254
+
rows, err := e.Query(
255
+
`select from_at from reference_links
256
+
where to_at = ?`,
257
+
target,
258
+
)
259
+
if err != nil {
260
+
return nil, fmt.Errorf("query backlinks: %w", err)
261
+
}
262
+
defer rows.Close()
263
+
264
+
var (
265
+
backlinks []models.RichReferenceLink
266
+
backlinksMap = make(map[string][]syntax.ATURI)
267
+
)
268
+
for rows.Next() {
269
+
var from syntax.ATURI
270
+
if err := rows.Scan(&from); err != nil {
271
+
return nil, fmt.Errorf("scan row: %w", err)
272
+
}
273
+
nsid := from.Collection().String()
274
+
backlinksMap[nsid] = append(backlinksMap[nsid], from)
275
+
}
276
+
if err := rows.Err(); err != nil {
277
+
return nil, fmt.Errorf("iterate rows: %w", err)
278
+
}
279
+
280
+
var ls []models.RichReferenceLink
281
+
ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID])
282
+
if err != nil {
283
+
return nil, fmt.Errorf("get issue backlinks: %w", err)
284
+
}
285
+
backlinks = append(backlinks, ls...)
286
+
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID])
287
+
if err != nil {
288
+
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
289
+
}
290
+
backlinks = append(backlinks, ls...)
291
+
ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID])
292
+
if err != nil {
293
+
return nil, fmt.Errorf("get pull backlinks: %w", err)
294
+
}
295
+
backlinks = append(backlinks, ls...)
296
+
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID])
297
+
if err != nil {
298
+
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
299
+
}
300
+
backlinks = append(backlinks, ls...)
301
+
302
+
return backlinks, nil
303
+
}
304
+
305
+
func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
306
+
if len(aturis) == 0 {
307
+
return nil, nil
308
+
}
309
+
vals := make([]string, len(aturis))
310
+
args := make([]any, 0, len(aturis)*2)
311
+
for i, aturi := range aturis {
312
+
vals[i] = "(?, ?)"
313
+
did := aturi.Authority().String()
314
+
rkey := aturi.RecordKey().String()
315
+
args = append(args, did, rkey)
316
+
}
317
+
rows, err := e.Query(
318
+
fmt.Sprintf(
319
+
`select r.did, r.name, i.issue_id, i.title, i.open
320
+
from issues i
321
+
join repos r
322
+
on r.at_uri = i.repo_at
323
+
where (i.did, i.rkey) in (%s)`,
324
+
strings.Join(vals, ","),
325
+
),
326
+
args...,
327
+
)
328
+
if err != nil {
329
+
return nil, err
330
+
}
331
+
defer rows.Close()
332
+
var refLinks []models.RichReferenceLink
333
+
for rows.Next() {
334
+
var l models.RichReferenceLink
335
+
l.Kind = models.RefKindIssue
336
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
337
+
return nil, err
338
+
}
339
+
refLinks = append(refLinks, l)
340
+
}
341
+
if err := rows.Err(); err != nil {
342
+
return nil, fmt.Errorf("iterate rows: %w", err)
343
+
}
344
+
return refLinks, nil
345
+
}
346
+
347
+
func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
348
+
if len(aturis) == 0 {
349
+
return nil, nil
350
+
}
351
+
filter := orm.FilterIn("c.at_uri", aturis)
352
+
rows, err := e.Query(
353
+
fmt.Sprintf(
354
+
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
355
+
from issue_comments c
356
+
join issues i
357
+
on i.at_uri = c.issue_at
358
+
join repos r
359
+
on r.at_uri = i.repo_at
360
+
where %s`,
361
+
filter.Condition(),
362
+
),
363
+
filter.Arg()...,
364
+
)
365
+
if err != nil {
366
+
return nil, err
367
+
}
368
+
defer rows.Close()
369
+
var refLinks []models.RichReferenceLink
370
+
for rows.Next() {
371
+
var l models.RichReferenceLink
372
+
l.Kind = models.RefKindIssue
373
+
l.CommentId = new(int)
374
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
375
+
return nil, err
376
+
}
377
+
refLinks = append(refLinks, l)
378
+
}
379
+
if err := rows.Err(); err != nil {
380
+
return nil, fmt.Errorf("iterate rows: %w", err)
381
+
}
382
+
return refLinks, nil
383
+
}
384
+
385
+
func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
386
+
if len(aturis) == 0 {
387
+
return nil, nil
388
+
}
389
+
vals := make([]string, len(aturis))
390
+
args := make([]any, 0, len(aturis)*2)
391
+
for i, aturi := range aturis {
392
+
vals[i] = "(?, ?)"
393
+
did := aturi.Authority().String()
394
+
rkey := aturi.RecordKey().String()
395
+
args = append(args, did, rkey)
396
+
}
397
+
rows, err := e.Query(
398
+
fmt.Sprintf(
399
+
`select r.did, r.name, p.pull_id, p.title, p.state
400
+
from pulls p
401
+
join repos r
402
+
on r.at_uri = p.repo_at
403
+
where (p.owner_did, p.rkey) in (%s)`,
404
+
strings.Join(vals, ","),
405
+
),
406
+
args...,
407
+
)
408
+
if err != nil {
409
+
return nil, err
410
+
}
411
+
defer rows.Close()
412
+
var refLinks []models.RichReferenceLink
413
+
for rows.Next() {
414
+
var l models.RichReferenceLink
415
+
l.Kind = models.RefKindPull
416
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
417
+
return nil, err
418
+
}
419
+
refLinks = append(refLinks, l)
420
+
}
421
+
if err := rows.Err(); err != nil {
422
+
return nil, fmt.Errorf("iterate rows: %w", err)
423
+
}
424
+
return refLinks, nil
425
+
}
426
+
427
+
func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
428
+
if len(aturis) == 0 {
429
+
return nil, nil
430
+
}
431
+
filter := orm.FilterIn("c.comment_at", aturis)
432
+
rows, err := e.Query(
433
+
fmt.Sprintf(
434
+
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
435
+
from repos r
436
+
join pulls p
437
+
on r.at_uri = p.repo_at
438
+
join pull_comments c
439
+
on r.at_uri = c.repo_at and p.pull_id = c.pull_id
440
+
where %s`,
441
+
filter.Condition(),
442
+
),
443
+
filter.Arg()...,
444
+
)
445
+
if err != nil {
446
+
return nil, err
447
+
}
448
+
defer rows.Close()
449
+
var refLinks []models.RichReferenceLink
450
+
for rows.Next() {
451
+
var l models.RichReferenceLink
452
+
l.Kind = models.RefKindPull
453
+
l.CommentId = new(int)
454
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
455
+
return nil, err
456
+
}
457
+
refLinks = append(refLinks, l)
458
+
}
459
+
if err := rows.Err(); err != nil {
460
+
return nil, fmt.Errorf("iterate rows: %w", err)
461
+
}
462
+
return refLinks, nil
463
+
}
+5
-3
appview/db/registration.go
+5
-3
appview/db/registration.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
+
func GetRegistrations(e Execer, filters ...orm.Filter) ([]models.Registration, error) {
13
14
var registrations []models.Registration
14
15
15
16
var conditions []string
···
37
38
if err != nil {
38
39
return nil, err
39
40
}
41
+
defer rows.Close()
40
42
41
43
for rows.Next() {
42
44
var createdAt string
···
69
71
return registrations, nil
70
72
}
71
73
72
-
func MarkRegistered(e Execer, filters ...filter) error {
74
+
func MarkRegistered(e Execer, filters ...orm.Filter) error {
73
75
var conditions []string
74
76
var args []any
75
77
for _, filter := range filters {
···
94
96
return err
95
97
}
96
98
97
-
func DeleteKnot(e Execer, filters ...filter) error {
99
+
func DeleteKnot(e Execer, filters ...orm.Filter) error {
98
100
var conditions []string
99
101
var args []any
100
102
for _, filter := range filters {
+82
-49
appview/db/repos.go
+82
-49
appview/db/repos.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.org/core/api/tangled"
15
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
16
15
)
17
16
18
-
type Repo struct {
19
-
Id int64
20
-
Did string
21
-
Name string
22
-
Knot string
23
-
Rkey string
24
-
Created time.Time
25
-
Description string
26
-
Spindle string
27
-
28
-
// optionally, populate this when querying for reverse mappings
29
-
RepoStats *models.RepoStats
30
-
31
-
// optional
32
-
Source string
33
-
}
34
-
35
-
func (r Repo) RepoAt() syntax.ATURI {
36
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
37
-
}
38
-
39
-
func (r Repo) DidSlashRepo() string {
40
-
p, _ := securejoin.SecureJoin(r.Did, r.Name)
41
-
return p
42
-
}
43
-
44
-
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
17
+
func GetRepos(e Execer, limit int, filters ...orm.Filter) ([]models.Repo, error) {
45
18
repoMap := make(map[syntax.ATURI]*models.Repo)
46
19
47
20
var conditions []string
···
70
43
rkey,
71
44
created,
72
45
description,
46
+
website,
47
+
topics,
73
48
source,
74
49
spindle
75
50
from
···
81
56
limitClause,
82
57
)
83
58
rows, err := e.Query(repoQuery, args...)
84
-
85
59
if err != nil {
86
60
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
87
61
}
62
+
defer rows.Close()
88
63
89
64
for rows.Next() {
90
65
var repo models.Repo
91
66
var createdAt string
92
-
var description, source, spindle sql.NullString
67
+
var description, website, topicStr, source, spindle sql.NullString
93
68
94
69
err := rows.Scan(
95
70
&repo.Id,
···
99
74
&repo.Rkey,
100
75
&createdAt,
101
76
&description,
77
+
&website,
78
+
&topicStr,
102
79
&source,
103
80
&spindle,
104
81
)
···
112
89
if description.Valid {
113
90
repo.Description = description.String
114
91
}
92
+
if website.Valid {
93
+
repo.Website = website.String
94
+
}
95
+
if topicStr.Valid {
96
+
repo.Topics = strings.Fields(topicStr.String)
97
+
}
115
98
if source.Valid {
116
99
repo.Source = source.String
117
100
}
···
145
128
if err != nil {
146
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
147
130
}
131
+
defer rows.Close()
132
+
148
133
for rows.Next() {
149
134
var repoat, labelat string
150
135
if err := rows.Scan(&repoat, &labelat); err != nil {
···
173
158
from repo_languages
174
159
where repo_at in (%s)
175
160
and is_default_ref = 1
161
+
and language <> ''
176
162
)
177
163
where rn = 1
178
164
`,
···
182
168
if err != nil {
183
169
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
184
170
}
171
+
defer rows.Close()
172
+
185
173
for rows.Next() {
186
174
var repoat, lang string
187
175
if err := rows.Scan(&repoat, &lang); err != nil {
···
198
186
199
187
starCountQuery := fmt.Sprintf(
200
188
`select
201
-
repo_at, count(1)
189
+
subject_at, count(1)
202
190
from stars
203
-
where repo_at in (%s)
204
-
group by repo_at`,
191
+
where subject_at in (%s)
192
+
group by subject_at`,
205
193
inClause,
206
194
)
207
195
rows, err = e.Query(starCountQuery, args...)
208
196
if err != nil {
209
197
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
210
198
}
199
+
defer rows.Close()
200
+
211
201
for rows.Next() {
212
202
var repoat string
213
203
var count int
···
237
227
if err != nil {
238
228
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
239
229
}
230
+
defer rows.Close()
231
+
240
232
for rows.Next() {
241
233
var repoat string
242
234
var open, closed int
···
278
270
if err != nil {
279
271
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
280
272
}
273
+
defer rows.Close()
274
+
281
275
for rows.Next() {
282
276
var repoat string
283
277
var open, merged, closed, deleted int
···
312
306
}
313
307
314
308
// helper to get exactly one repo
315
-
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
309
+
func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) {
316
310
repos, err := GetRepos(e, 0, filters...)
317
311
if err != nil {
318
312
return nil, err
···
329
323
return &repos[0], nil
330
324
}
331
325
332
-
func CountRepos(e Execer, filters ...filter) (int64, error) {
326
+
func CountRepos(e Execer, filters ...orm.Filter) (int64, error) {
333
327
var conditions []string
334
328
var args []any
335
329
for _, filter := range filters {
···
356
350
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
351
var repo models.Repo
358
352
var nullableDescription sql.NullString
353
+
var nullableWebsite sql.NullString
354
+
var nullableTopicStr sql.NullString
359
355
360
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
356
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
361
357
362
358
var createdAt string
363
-
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
359
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
364
360
return nil, err
365
361
}
366
362
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
368
364
369
365
if nullableDescription.Valid {
370
366
repo.Description = nullableDescription.String
371
-
} else {
372
-
repo.Description = ""
367
+
}
368
+
if nullableWebsite.Valid {
369
+
repo.Website = nullableWebsite.String
370
+
}
371
+
if nullableTopicStr.Valid {
372
+
repo.Topics = strings.Fields(nullableTopicStr.String)
373
373
}
374
374
375
375
return &repo, nil
376
376
}
377
377
378
+
func PutRepo(tx *sql.Tx, repo models.Repo) error {
379
+
_, err := tx.Exec(
380
+
`update repos
381
+
set knot = ?, description = ?, website = ?, topics = ?
382
+
where did = ? and rkey = ?
383
+
`,
384
+
repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey,
385
+
)
386
+
return err
387
+
}
388
+
378
389
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
390
_, err := tx.Exec(
380
391
`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,
392
+
(did, name, knot, rkey, at_uri, description, website, topics, source)
393
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
394
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source,
384
395
)
385
396
if err != nil {
386
397
return fmt.Errorf("failed to insert repo: %w", err)
···
412
423
return nullableSource.String, nil
413
424
}
414
425
426
+
func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) {
427
+
source, err := GetRepoSource(e, repoAt)
428
+
if source == "" || errors.Is(err, sql.ErrNoRows) {
429
+
return nil, nil
430
+
}
431
+
if err != nil {
432
+
return nil, err
433
+
}
434
+
return GetRepoByAtUri(e, source)
435
+
}
436
+
415
437
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
416
438
var repos []models.Repo
417
439
418
440
rows, err := e.Query(
419
-
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
441
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
420
442
from repos r
421
443
left join collaborators c on r.at_uri = c.repo_at
422
444
where (r.did = ? or c.subject_did = ?)
···
434
456
var repo models.Repo
435
457
var createdAt string
436
458
var nullableDescription sql.NullString
459
+
var nullableWebsite sql.NullString
437
460
var nullableSource sql.NullString
438
461
439
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
462
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
440
463
if err != nil {
441
464
return nil, err
442
465
}
···
470
493
var repo models.Repo
471
494
var createdAt string
472
495
var nullableDescription sql.NullString
496
+
var nullableWebsite sql.NullString
497
+
var nullableTopicStr sql.NullString
473
498
var nullableSource sql.NullString
474
499
475
500
row := e.QueryRow(
476
-
`select id, did, name, knot, rkey, description, created, source
501
+
`select id, did, name, knot, rkey, description, website, topics, created, source
477
502
from repos
478
503
where did = ? and name = ? and source is not null and source != ''`,
479
504
did, name,
480
505
)
481
506
482
-
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
507
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
483
508
if err != nil {
484
509
return nil, err
485
510
}
···
488
513
repo.Description = nullableDescription.String
489
514
}
490
515
516
+
if nullableWebsite.Valid {
517
+
repo.Website = nullableWebsite.String
518
+
}
519
+
520
+
if nullableTopicStr.Valid {
521
+
repo.Topics = strings.Fields(nullableTopicStr.String)
522
+
}
523
+
491
524
if nullableSource.Valid {
492
525
repo.Source = nullableSource.String
493
526
}
···
521
554
return err
522
555
}
523
556
524
-
func UnsubscribeLabel(e Execer, filters ...filter) error {
557
+
func UnsubscribeLabel(e Execer, filters ...orm.Filter) error {
525
558
var conditions []string
526
559
var args []any
527
560
for _, filter := range filters {
···
539
572
return err
540
573
}
541
574
542
-
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
575
+
func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) {
543
576
var conditions []string
544
577
var args []any
545
578
for _, filter := range filters {
+6
-5
appview/db/spindle.go
+6
-5
appview/db/spindle.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
+
func GetSpindles(e Execer, filters ...orm.Filter) ([]models.Spindle, error) {
13
14
var spindles []models.Spindle
14
15
15
16
var conditions []string
···
91
92
return err
92
93
}
93
94
94
-
func VerifySpindle(e Execer, filters ...filter) (int64, error) {
95
+
func VerifySpindle(e Execer, filters ...orm.Filter) (int64, error) {
95
96
var conditions []string
96
97
var args []any
97
98
for _, filter := range filters {
···
114
115
return res.RowsAffected()
115
116
}
116
117
117
-
func DeleteSpindle(e Execer, filters ...filter) error {
118
+
func DeleteSpindle(e Execer, filters ...orm.Filter) error {
118
119
var conditions []string
119
120
var args []any
120
121
for _, filter := range filters {
···
144
145
return err
145
146
}
146
147
147
-
func RemoveSpindleMember(e Execer, filters ...filter) error {
148
+
func RemoveSpindleMember(e Execer, filters ...orm.Filter) error {
148
149
var conditions []string
149
150
var args []any
150
151
for _, filter := range filters {
···
163
164
return err
164
165
}
165
166
166
-
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
+
func GetSpindleMembers(e Execer, filters ...orm.Filter) ([]models.SpindleMember, error) {
167
168
var members []models.SpindleMember
168
169
169
170
var conditions []string
+44
-102
appview/db/star.go
+44
-102
appview/db/star.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
func AddStar(e Execer, star *models.Star) error {
17
-
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
18
+
query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)`
18
19
_, err := e.Exec(
19
20
query,
20
-
star.StarredByDid,
21
+
star.Did,
21
22
star.RepoAt.String(),
22
23
star.Rkey,
23
24
)
···
25
26
}
26
27
27
28
// Get a star record
28
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
29
+
func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) {
29
30
query := `
30
-
select starred_by_did, repo_at, created, rkey
31
+
select did, subject_at, created, rkey
31
32
from stars
32
-
where starred_by_did = ? and repo_at = ?`
33
-
row := e.QueryRow(query, starredByDid, repoAt)
33
+
where did = ? and subject_at = ?`
34
+
row := e.QueryRow(query, did, subjectAt)
34
35
35
36
var star models.Star
36
37
var created string
37
-
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
38
+
err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
38
39
if err != nil {
39
40
return nil, err
40
41
}
···
51
52
}
52
53
53
54
// 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)
55
+
func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error {
56
+
_, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt)
56
57
return err
57
58
}
58
59
59
60
// 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)
61
+
func DeleteStarByRkey(e Execer, did string, rkey string) error {
62
+
_, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey)
62
63
return err
63
64
}
64
65
65
-
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
66
+
func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) {
66
67
stars := 0
67
68
err := e.QueryRow(
68
-
`select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
69
+
`select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars)
69
70
if err != nil {
70
71
return 0, err
71
72
}
···
89
90
}
90
91
91
92
query := fmt.Sprintf(`
92
-
SELECT repo_at
93
+
SELECT subject_at
93
94
FROM stars
94
-
WHERE starred_by_did = ? AND repo_at IN (%s)
95
+
WHERE did = ? AND subject_at IN (%s)
95
96
`, strings.Join(placeholders, ","))
96
97
97
98
rows, err := e.Query(query, args...)
···
118
119
return result, nil
119
120
}
120
121
121
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
122
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
122
+
func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool {
123
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt})
123
124
if err != nil {
124
125
return false
125
126
}
126
-
return statuses[repoAt.String()]
127
+
return statuses[subjectAt.String()]
127
128
}
128
129
129
130
// 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)
131
+
func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) {
132
+
return getStarStatuses(e, userDid, subjectAts)
132
133
}
133
-
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
134
+
135
+
// GetRepoStars return a list of stars each holding target repository.
136
+
// If there isn't known repo with starred at-uri, those stars will be ignored.
137
+
func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) {
134
138
var conditions []string
135
139
var args []any
136
140
for _, filter := range filters {
···
149
153
}
150
154
151
155
repoQuery := fmt.Sprintf(
152
-
`select starred_by_did, repo_at, created, rkey
156
+
`select did, subject_at, created, rkey
153
157
from stars
154
158
%s
155
159
order by created desc
···
161
165
if err != nil {
162
166
return nil, err
163
167
}
168
+
defer rows.Close()
164
169
165
170
starMap := make(map[string][]models.Star)
166
171
for rows.Next() {
167
172
var star models.Star
168
173
var created string
169
-
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
174
+
err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
170
175
if err != nil {
171
176
return nil, err
172
177
}
···
192
197
return nil, nil
193
198
}
194
199
195
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
200
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args))
196
201
if err != nil {
197
202
return nil, err
198
203
}
199
204
205
+
var repoStars []models.RepoStar
200
206
for _, r := range repos {
201
207
if stars, ok := starMap[string(r.RepoAt())]; ok {
202
-
for i := range stars {
203
-
stars[i].Repo = &r
208
+
for _, star := range stars {
209
+
repoStars = append(repoStars, models.RepoStar{
210
+
Star: star,
211
+
Repo: &r,
212
+
})
204
213
}
205
214
}
206
215
}
207
216
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 {
217
+
slices.SortFunc(repoStars, func(a, b models.RepoStar) int {
214
218
if a.Created.After(b.Created) {
215
219
return -1
216
220
}
···
220
224
return 0
221
225
})
222
226
223
-
return stars, nil
227
+
return repoStars, nil
224
228
}
225
229
226
-
func CountStars(e Execer, filters ...filter) (int64, error) {
230
+
func CountStars(e Execer, filters ...orm.Filter) (int64, error) {
227
231
var conditions []string
228
232
var args []any
229
233
for _, filter := range filters {
···
247
251
return count, nil
248
252
}
249
253
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
254
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
313
255
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
314
256
// first, get the top repo URIs by star count from the last week
315
257
query := `
316
258
with recent_starred_repos as (
317
-
select distinct repo_at
259
+
select distinct subject_at
318
260
from stars
319
261
where created >= datetime('now', '-7 days')
320
262
),
321
263
repo_star_counts as (
322
264
select
323
-
s.repo_at,
265
+
s.subject_at,
324
266
count(*) as stars_gained_last_week
325
267
from stars s
326
-
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
268
+
join recent_starred_repos rsr on s.subject_at = rsr.subject_at
327
269
where s.created >= datetime('now', '-7 days')
328
-
group by s.repo_at
270
+
group by s.subject_at
329
271
)
330
-
select rsc.repo_at
272
+
select rsc.subject_at
331
273
from repo_star_counts rsc
332
274
order by rsc.stars_gained_last_week desc
333
275
limit 8
···
358
300
}
359
301
360
302
// get full repo data
361
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
303
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris))
362
304
if err != nil {
363
305
return nil, err
364
306
}
+4
-3
appview/db/strings.go
+4
-3
appview/db/strings.go
···
8
8
"time"
9
9
10
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
11
12
)
12
13
13
14
func AddString(e Execer, s models.String) error {
···
44
45
return err
45
46
}
46
47
47
-
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
+
func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) {
48
49
var all []models.String
49
50
50
51
var conditions []string
···
127
128
return all, nil
128
129
}
129
130
130
-
func CountStrings(e Execer, filters ...filter) (int64, error) {
131
+
func CountStrings(e Execer, filters ...orm.Filter) (int64, error) {
131
132
var conditions []string
132
133
var args []any
133
134
for _, filter := range filters {
···
151
152
return count, nil
152
153
}
153
154
154
-
func DeleteString(e Execer, filters ...filter) error {
155
+
func DeleteString(e Execer, filters ...orm.Filter) error {
155
156
var conditions []string
156
157
var args []any
157
158
for _, filter := range filters {
+11
-20
appview/db/timeline.go
+11
-20
appview/db/timeline.go
···
5
5
6
6
"github.com/bluesky-social/indigo/atproto/syntax"
7
7
"tangled.org/core/appview/models"
8
+
"tangled.org/core/orm"
8
9
)
9
10
10
11
// TODO: this gathers heterogenous events from different sources and aggregates
···
84
85
}
85
86
86
87
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
-
filters := make([]filter, 0)
88
+
filters := make([]orm.Filter, 0)
88
89
if userIsFollowing != nil {
89
-
filters = append(filters, FilterIn("did", userIsFollowing))
90
+
filters = append(filters, orm.FilterIn("did", userIsFollowing))
90
91
}
91
92
92
93
repos, err := GetRepos(e, limit, filters...)
···
104
105
105
106
var origRepos []models.Repo
106
107
if args != nil {
107
-
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
108
+
origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args))
108
109
}
109
110
if err != nil {
110
111
return nil, err
···
144
145
}
145
146
146
147
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
-
filters := make([]filter, 0)
148
+
filters := make([]orm.Filter, 0)
148
149
if userIsFollowing != nil {
149
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
+
filters = append(filters, orm.FilterIn("did", userIsFollowing))
150
151
}
151
152
152
-
stars, err := GetStars(e, limit, filters...)
153
+
stars, err := GetRepoStars(e, limit, filters...)
153
154
if err != nil {
154
155
return nil, err
155
156
}
156
157
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
158
var repos []models.Repo
168
159
for _, s := range stars {
169
160
repos = append(repos, *s.Repo)
···
179
170
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
180
171
181
172
events = append(events, models.TimelineEvent{
182
-
Star: &s,
173
+
RepoStar: &s,
183
174
EventAt: s.Created,
184
175
IsStarred: isStarred,
185
176
StarCount: starCount,
···
190
181
}
191
182
192
183
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
193
-
filters := make([]filter, 0)
184
+
filters := make([]orm.Filter, 0)
194
185
if userIsFollowing != nil {
195
-
filters = append(filters, FilterIn("user_did", userIsFollowing))
186
+
filters = append(filters, orm.FilterIn("user_did", userIsFollowing))
196
187
}
197
188
198
189
follows, err := GetFollows(e, limit, filters...)
···
209
200
return nil, nil
210
201
}
211
202
212
-
profiles, err := GetProfiles(e, FilterIn("did", subjects))
203
+
profiles, err := GetProfiles(e, orm.FilterIn("did", subjects))
213
204
if err != nil {
214
205
return nil, err
215
206
}
+7
-12
appview/email/email.go
+7
-12
appview/email/email.go
···
3
3
import (
4
4
"fmt"
5
5
"net"
6
-
"regexp"
6
+
"net/mail"
7
7
"strings"
8
8
9
9
"github.com/resend/resend-go/v2"
···
34
34
}
35
35
36
36
func IsValidEmail(email string) bool {
37
-
// Basic length check
38
-
if len(email) < 3 || len(email) > 254 {
37
+
// Reject whitespace (ParseAddress normalizes it away)
38
+
if strings.ContainsAny(email, " \t\n\r") {
39
39
return false
40
40
}
41
41
42
-
// Regular expression for email validation (RFC 5322 compliant)
43
-
pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
44
-
45
-
// Compile regex
46
-
regex := regexp.MustCompile(pattern)
47
-
48
-
// Check if email matches regex pattern
49
-
if !regex.MatchString(email) {
42
+
// Use stdlib RFC 5322 parser
43
+
addr, err := mail.ParseAddress(email)
44
+
if err != nil {
50
45
return false
51
46
}
52
47
53
48
// Split email into local and domain parts
54
-
parts := strings.Split(email, "@")
49
+
parts := strings.Split(addr.Address, "@")
55
50
domain := parts[1]
56
51
57
52
mx, err := net.LookupMX(domain)
+53
appview/email/email_test.go
+53
appview/email/email_test.go
···
1
+
package email
2
+
3
+
import (
4
+
"testing"
5
+
)
6
+
7
+
func TestIsValidEmail(t *testing.T) {
8
+
tests := []struct {
9
+
name string
10
+
email string
11
+
want bool
12
+
}{
13
+
// Valid emails using RFC 2606 reserved domains
14
+
{"standard email", "user@example.com", true},
15
+
{"single char local", "a@example.com", true},
16
+
{"dot in middle", "first.last@example.com", true},
17
+
{"multiple dots", "a.b.c@example.com", true},
18
+
{"plus tag", "user+tag@example.com", true},
19
+
{"numbers", "user123@example.com", true},
20
+
{"example.org", "user@example.org", true},
21
+
{"example.net", "user@example.net", true},
22
+
23
+
// Invalid format - rejected by mail.ParseAddress
24
+
{"empty string", "", false},
25
+
{"no at sign", "userexample.com", false},
26
+
{"no domain", "user@", false},
27
+
{"no local part", "@example.com", false},
28
+
{"double at", "user@@example.com", false},
29
+
{"just at sign", "@", false},
30
+
{"leading dot", ".user@example.com", false},
31
+
{"trailing dot", "user.@example.com", false},
32
+
{"consecutive dots", "user..name@example.com", false},
33
+
34
+
// Whitespace - rejected before parsing
35
+
{"space in local", "user @example.com", false},
36
+
{"space in domain", "user@ example.com", false},
37
+
{"tab", "user\t@example.com", false},
38
+
{"newline", "user\n@example.com", false},
39
+
40
+
// MX lookup - using RFC 2606 reserved TLDs (guaranteed no MX)
41
+
{"invalid TLD", "user@example.invalid", false},
42
+
{"test TLD", "user@mail.test", false},
43
+
}
44
+
45
+
for _, tt := range tests {
46
+
t.Run(tt.name, func(t *testing.T) {
47
+
got := IsValidEmail(tt.email)
48
+
if got != tt.want {
49
+
t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want)
50
+
}
51
+
})
52
+
}
53
+
}
+3
-1
appview/indexer/issues/indexer.go
+3
-1
appview/indexer/issues/indexer.go
···
56
56
log.Fatalln("failed to populate issue indexer", err)
57
57
}
58
58
}
59
-
l.Info("Initialized the issue indexer")
59
+
60
+
count, _ := ix.indexer.DocCount()
61
+
l.Info("Initialized the issue indexer", "docCount", count)
60
62
}
61
63
62
64
func generateIssueIndexMapping() (mapping.IndexMapping, error) {
+4
-3
appview/indexer/notifier.go
+4
-3
appview/indexer/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
7
"tangled.org/core/appview/models"
7
8
"tangled.org/core/appview/notify"
8
9
"tangled.org/core/log"
···
10
11
11
12
var _ notify.Notifier = &Indexer{}
12
13
13
-
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) {
14
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
14
15
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
15
16
l.Debug("indexing new issue")
16
17
err := ix.Issues.Index(ctx, *issue)
···
19
20
}
20
21
}
21
22
22
-
func (ix *Indexer) NewIssueState(ctx context.Context, issue *models.Issue) {
23
+
func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
23
24
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
24
25
l.Debug("updating an issue")
25
26
err := ix.Issues.Index(ctx, *issue)
···
46
47
}
47
48
}
48
49
49
-
func (ix *Indexer) NewPullState(ctx context.Context, pull *models.Pull) {
50
+
func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
50
51
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
51
52
l.Debug("updating a pr")
52
53
err := ix.Pulls.Index(ctx, pull)
+3
-1
appview/indexer/pulls/indexer.go
+3
-1
appview/indexer/pulls/indexer.go
···
55
55
log.Fatalln("failed to populate pull indexer", err)
56
56
}
57
57
}
58
-
l.Info("Initialized the pull indexer")
58
+
59
+
count, _ := ix.indexer.DocCount()
60
+
l.Info("Initialized the pull indexer", "docCount", count)
59
61
}
60
62
61
63
func generatePullIndexMapping() (mapping.IndexMapping, error) {
+50
-32
appview/ingester.go
+50
-32
appview/ingester.go
···
21
21
"tangled.org/core/appview/serververify"
22
22
"tangled.org/core/appview/validator"
23
23
"tangled.org/core/idresolver"
24
+
"tangled.org/core/orm"
24
25
"tangled.org/core/rbac"
25
26
)
26
27
···
121
122
return err
122
123
}
123
124
err = db.AddStar(i.Db, &models.Star{
124
-
StarredByDid: did,
125
-
RepoAt: subjectUri,
126
-
Rkey: e.Commit.RKey,
125
+
Did: did,
126
+
RepoAt: subjectUri,
127
+
Rkey: e.Commit.RKey,
127
128
})
128
129
case jmodels.CommitOperationDelete:
129
130
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
···
253
254
254
255
err = db.AddArtifact(i.Db, artifact)
255
256
case jmodels.CommitOperationDelete:
256
-
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
257
+
err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey))
257
258
}
258
259
259
260
if err != nil {
···
350
351
351
352
err = db.UpsertProfile(tx, &profile)
352
353
case jmodels.CommitOperationDelete:
353
-
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
354
+
err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey))
354
355
}
355
356
356
357
if err != nil {
···
424
425
// get record from db first
425
426
members, err := db.GetSpindleMembers(
426
427
ddb,
427
-
db.FilterEq("did", did),
428
-
db.FilterEq("rkey", rkey),
428
+
orm.FilterEq("did", did),
429
+
orm.FilterEq("rkey", rkey),
429
430
)
430
431
if err != nil || len(members) != 1 {
431
432
return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members))
···
440
441
// remove record by rkey && update enforcer
441
442
if err = db.RemoveSpindleMember(
442
443
tx,
443
-
db.FilterEq("did", did),
444
-
db.FilterEq("rkey", rkey),
444
+
orm.FilterEq("did", did),
445
+
orm.FilterEq("rkey", rkey),
445
446
); err != nil {
446
447
return fmt.Errorf("failed to remove from db: %w", err)
447
448
}
···
523
524
// get record from db first
524
525
spindles, err := db.GetSpindles(
525
526
ddb,
526
-
db.FilterEq("owner", did),
527
-
db.FilterEq("instance", instance),
527
+
orm.FilterEq("owner", did),
528
+
orm.FilterEq("instance", instance),
528
529
)
529
530
if err != nil || len(spindles) != 1 {
530
531
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
···
543
544
// remove spindle members first
544
545
err = db.RemoveSpindleMember(
545
546
tx,
546
-
db.FilterEq("owner", did),
547
-
db.FilterEq("instance", instance),
547
+
orm.FilterEq("owner", did),
548
+
orm.FilterEq("instance", instance),
548
549
)
549
550
if err != nil {
550
551
return err
···
552
553
553
554
err = db.DeleteSpindle(
554
555
tx,
555
-
db.FilterEq("owner", did),
556
-
db.FilterEq("instance", instance),
556
+
orm.FilterEq("owner", did),
557
+
orm.FilterEq("instance", instance),
557
558
)
558
559
if err != nil {
559
560
return err
···
621
622
case jmodels.CommitOperationDelete:
622
623
if err := db.DeleteString(
623
624
ddb,
624
-
db.FilterEq("did", did),
625
-
db.FilterEq("rkey", rkey),
625
+
orm.FilterEq("did", did),
626
+
orm.FilterEq("rkey", rkey),
626
627
); err != nil {
627
628
l.Error("failed to delete", "err", err)
628
629
return fmt.Errorf("failed to delete string record: %w", err)
···
740
741
// get record from db first
741
742
registrations, err := db.GetRegistrations(
742
743
ddb,
743
-
db.FilterEq("domain", domain),
744
-
db.FilterEq("did", did),
744
+
orm.FilterEq("domain", domain),
745
+
orm.FilterEq("did", did),
745
746
)
746
747
if err != nil {
747
748
return fmt.Errorf("failed to get registration: %w", err)
···
762
763
763
764
err = db.DeleteKnot(
764
765
tx,
765
-
db.FilterEq("did", did),
766
-
db.FilterEq("domain", domain),
766
+
orm.FilterEq("did", did),
767
+
orm.FilterEq("domain", domain),
767
768
)
768
769
if err != nil {
769
770
return err
···
841
842
return nil
842
843
843
844
case jmodels.CommitOperationDelete:
845
+
tx, err := ddb.BeginTx(ctx, nil)
846
+
if err != nil {
847
+
l.Error("failed to begin transaction", "err", err)
848
+
return err
849
+
}
850
+
defer tx.Rollback()
851
+
844
852
if err := db.DeleteIssues(
845
-
ddb,
846
-
db.FilterEq("did", did),
847
-
db.FilterEq("rkey", rkey),
853
+
tx,
854
+
did,
855
+
rkey,
848
856
); err != nil {
849
857
l.Error("failed to delete", "err", err)
850
858
return fmt.Errorf("failed to delete issue record: %w", err)
859
+
}
860
+
if err := tx.Commit(); err != nil {
861
+
l.Error("failed to commit txn", "err", err)
862
+
return err
851
863
}
852
864
853
865
return nil
···
888
900
return fmt.Errorf("failed to validate comment: %w", err)
889
901
}
890
902
891
-
_, err = db.AddIssueComment(ddb, *comment)
903
+
tx, err := ddb.Begin()
904
+
if err != nil {
905
+
return fmt.Errorf("failed to start transaction: %w", err)
906
+
}
907
+
defer tx.Rollback()
908
+
909
+
_, err = db.AddIssueComment(tx, *comment)
892
910
if err != nil {
893
911
return fmt.Errorf("failed to create issue comment: %w", err)
894
912
}
895
913
896
-
return nil
914
+
return tx.Commit()
897
915
898
916
case jmodels.CommitOperationDelete:
899
917
if err := db.DeleteIssueComments(
900
918
ddb,
901
-
db.FilterEq("did", did),
902
-
db.FilterEq("rkey", rkey),
919
+
orm.FilterEq("did", did),
920
+
orm.FilterEq("rkey", rkey),
903
921
); err != nil {
904
922
return fmt.Errorf("failed to delete issue comment record: %w", err)
905
923
}
···
952
970
case jmodels.CommitOperationDelete:
953
971
if err := db.DeleteLabelDefinition(
954
972
ddb,
955
-
db.FilterEq("did", did),
956
-
db.FilterEq("rkey", rkey),
973
+
orm.FilterEq("did", did),
974
+
orm.FilterEq("rkey", rkey),
957
975
); err != nil {
958
976
return fmt.Errorf("failed to delete labeldef record: %w", err)
959
977
}
···
993
1011
var repo *models.Repo
994
1012
switch collection {
995
1013
case tangled.RepoIssueNSID:
996
-
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
1014
+
i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject))
997
1015
if err != nil || len(i) != 1 {
998
1016
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
999
1017
}
···
1002
1020
return fmt.Errorf("unsupport label subject: %s", collection)
1003
1021
}
1004
1022
1005
-
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1023
+
actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels))
1006
1024
if err != nil {
1007
1025
return fmt.Errorf("failed to build label application ctx: %w", err)
1008
1026
}
+180
-133
appview/issues/issues.go
+180
-133
appview/issues/issues.go
···
7
7
"fmt"
8
8
"log/slog"
9
9
"net/http"
10
-
"slices"
11
10
"time"
12
11
13
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
20
19
"tangled.org/core/appview/config"
21
20
"tangled.org/core/appview/db"
22
21
issues_indexer "tangled.org/core/appview/indexer/issues"
22
+
"tangled.org/core/appview/mentions"
23
23
"tangled.org/core/appview/models"
24
24
"tangled.org/core/appview/notify"
25
25
"tangled.org/core/appview/oauth"
26
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/repoinfo"
27
28
"tangled.org/core/appview/pagination"
28
29
"tangled.org/core/appview/reporesolver"
29
30
"tangled.org/core/appview/validator"
30
31
"tangled.org/core/idresolver"
32
+
"tangled.org/core/orm"
33
+
"tangled.org/core/rbac"
31
34
"tangled.org/core/tid"
32
35
)
33
36
34
37
type Issues struct {
35
-
oauth *oauth.OAuth
36
-
repoResolver *reporesolver.RepoResolver
37
-
pages *pages.Pages
38
-
idResolver *idresolver.Resolver
39
-
db *db.DB
40
-
config *config.Config
41
-
notifier notify.Notifier
42
-
logger *slog.Logger
43
-
validator *validator.Validator
44
-
indexer *issues_indexer.Indexer
38
+
oauth *oauth.OAuth
39
+
repoResolver *reporesolver.RepoResolver
40
+
enforcer *rbac.Enforcer
41
+
pages *pages.Pages
42
+
idResolver *idresolver.Resolver
43
+
mentionsResolver *mentions.Resolver
44
+
db *db.DB
45
+
config *config.Config
46
+
notifier notify.Notifier
47
+
logger *slog.Logger
48
+
validator *validator.Validator
49
+
indexer *issues_indexer.Indexer
45
50
}
46
51
47
52
func New(
48
53
oauth *oauth.OAuth,
49
54
repoResolver *reporesolver.RepoResolver,
55
+
enforcer *rbac.Enforcer,
50
56
pages *pages.Pages,
51
57
idResolver *idresolver.Resolver,
58
+
mentionsResolver *mentions.Resolver,
52
59
db *db.DB,
53
60
config *config.Config,
54
61
notifier notify.Notifier,
···
57
64
logger *slog.Logger,
58
65
) *Issues {
59
66
return &Issues{
60
-
oauth: oauth,
61
-
repoResolver: repoResolver,
62
-
pages: pages,
63
-
idResolver: idResolver,
64
-
db: db,
65
-
config: config,
66
-
notifier: notifier,
67
-
logger: logger,
68
-
validator: validator,
69
-
indexer: indexer,
67
+
oauth: oauth,
68
+
repoResolver: repoResolver,
69
+
enforcer: enforcer,
70
+
pages: pages,
71
+
idResolver: idResolver,
72
+
mentionsResolver: mentionsResolver,
73
+
db: db,
74
+
config: config,
75
+
notifier: notifier,
76
+
logger: logger,
77
+
validator: validator,
78
+
indexer: indexer,
70
79
}
71
80
}
72
81
···
96
105
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
97
106
}
98
107
108
+
backlinks, err := db.GetBacklinks(rp.db, issue.AtUri())
109
+
if err != nil {
110
+
l.Error("failed to fetch backlinks", "err", err)
111
+
rp.pages.Error503(w)
112
+
return
113
+
}
114
+
99
115
labelDefs, err := db.GetLabelDefinitions(
100
116
rp.db,
101
-
db.FilterIn("at_uri", f.Repo.Labels),
102
-
db.FilterContains("scope", tangled.RepoIssueNSID),
117
+
orm.FilterIn("at_uri", f.Labels),
118
+
orm.FilterContains("scope", tangled.RepoIssueNSID),
103
119
)
104
120
if err != nil {
105
121
l.Error("failed to fetch labels", "err", err)
···
114
130
115
131
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
116
132
LoggedInUser: user,
117
-
RepoInfo: f.RepoInfo(user),
133
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
118
134
Issue: issue,
119
135
CommentList: issue.CommentList(),
136
+
Backlinks: backlinks,
120
137
OrderedReactionKinds: models.OrderedReactionKinds,
121
138
Reactions: reactionMap,
122
139
UserReacted: userReactions,
···
127
144
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
128
145
l := rp.logger.With("handler", "EditIssue")
129
146
user := rp.oauth.GetUser(r)
130
-
f, err := rp.repoResolver.Resolve(r)
131
-
if err != nil {
132
-
l.Error("failed to get repo and knot", "err", err)
133
-
return
134
-
}
135
147
136
148
issue, ok := r.Context().Value("issue").(*models.Issue)
137
149
if !ok {
···
144
156
case http.MethodGet:
145
157
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
146
158
LoggedInUser: user,
147
-
RepoInfo: f.RepoInfo(user),
159
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
148
160
Issue: issue,
149
161
})
150
162
case http.MethodPost:
···
152
164
newIssue := issue
153
165
newIssue.Title = r.FormValue("title")
154
166
newIssue.Body = r.FormValue("body")
167
+
newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body)
155
168
156
169
if err := rp.validator.ValidateIssue(newIssue); err != nil {
157
170
l.Error("validation error", "err", err)
···
221
234
l := rp.logger.With("handler", "DeleteIssue")
222
235
noticeId := "issue-actions-error"
223
236
224
-
user := rp.oauth.GetUser(r)
225
-
226
237
f, err := rp.repoResolver.Resolve(r)
227
238
if err != nil {
228
239
l.Error("failed to get repo and knot", "err", err)
···
237
248
}
238
249
l = l.With("did", issue.Did, "rkey", issue.Rkey)
239
250
251
+
tx, err := rp.db.Begin()
252
+
if err != nil {
253
+
l.Error("failed to start transaction", "err", err)
254
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
255
+
return
256
+
}
257
+
defer tx.Rollback()
258
+
240
259
// delete from PDS
241
260
client, err := rp.oauth.AuthorizedClient(r)
242
261
if err != nil {
···
257
276
}
258
277
259
278
// delete from db
260
-
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
279
+
if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
261
280
l.Error("failed to delete issue", "err", err)
262
281
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
263
282
return
264
283
}
284
+
tx.Commit()
265
285
266
286
rp.notifier.DeleteIssue(r.Context(), issue)
267
287
268
288
// return to all issues page
269
-
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
289
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
290
+
rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")
270
291
}
271
292
272
293
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
285
306
return
286
307
}
287
308
288
-
collaborators, err := f.Collaborators(r.Context())
289
-
if err != nil {
290
-
l.Error("failed to fetch repo collaborators", "err", err)
291
-
}
292
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
293
-
return user.Did == collab.Did
294
-
})
309
+
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
310
+
isRepoOwner := roles.IsOwner()
311
+
isCollaborator := roles.IsCollaborator()
295
312
isIssueOwner := user.Did == issue.Did
296
313
297
314
// TODO: make this more granular
298
-
if isIssueOwner || isCollaborator {
315
+
if isIssueOwner || isRepoOwner || isCollaborator {
299
316
err = db.CloseIssues(
300
317
rp.db,
301
-
db.FilterEq("id", issue.Id),
318
+
orm.FilterEq("id", issue.Id),
302
319
)
303
320
if err != nil {
304
321
l.Error("failed to close issue", "err", err)
···
309
326
issue.Open = false
310
327
311
328
// notify about the issue closure
312
-
rp.notifier.NewIssueState(r.Context(), issue)
329
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
313
330
314
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
331
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
332
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
315
333
return
316
334
} else {
317
335
l.Error("user is not permitted to close issue")
···
336
354
return
337
355
}
338
356
339
-
collaborators, err := f.Collaborators(r.Context())
340
-
if err != nil {
341
-
l.Error("failed to fetch repo collaborators", "err", err)
342
-
}
343
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
344
-
return user.Did == collab.Did
345
-
})
357
+
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
358
+
isRepoOwner := roles.IsOwner()
359
+
isCollaborator := roles.IsCollaborator()
346
360
isIssueOwner := user.Did == issue.Did
347
361
348
-
if isCollaborator || isIssueOwner {
362
+
if isCollaborator || isRepoOwner || isIssueOwner {
349
363
err := db.ReopenIssues(
350
364
rp.db,
351
-
db.FilterEq("id", issue.Id),
365
+
orm.FilterEq("id", issue.Id),
352
366
)
353
367
if err != nil {
354
368
l.Error("failed to reopen issue", "err", err)
···
359
373
issue.Open = true
360
374
361
375
// notify about the issue reopen
362
-
rp.notifier.NewIssueState(r.Context(), issue)
376
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
363
377
364
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
378
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
379
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
365
380
return
366
381
} else {
367
382
l.Error("user is not the owner of the repo")
···
398
413
replyTo = &replyToUri
399
414
}
400
415
416
+
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
417
+
401
418
comment := models.IssueComment{
402
-
Did: user.Did,
403
-
Rkey: tid.TID(),
404
-
IssueAt: issue.AtUri().String(),
405
-
ReplyTo: replyTo,
406
-
Body: body,
407
-
Created: time.Now(),
419
+
Did: user.Did,
420
+
Rkey: tid.TID(),
421
+
IssueAt: issue.AtUri().String(),
422
+
ReplyTo: replyTo,
423
+
Body: body,
424
+
Created: time.Now(),
425
+
Mentions: mentions,
426
+
References: references,
408
427
}
409
428
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
410
429
l.Error("failed to validate comment", "err", err)
···
441
460
}
442
461
}()
443
462
444
-
commentId, err := db.AddIssueComment(rp.db, comment)
463
+
tx, err := rp.db.Begin()
464
+
if err != nil {
465
+
l.Error("failed to start transaction", "err", err)
466
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
467
+
return
468
+
}
469
+
defer tx.Rollback()
470
+
471
+
commentId, err := db.AddIssueComment(tx, comment)
445
472
if err != nil {
446
473
l.Error("failed to create comment", "err", err)
447
474
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
448
475
return
449
476
}
477
+
err = tx.Commit()
478
+
if err != nil {
479
+
l.Error("failed to commit transaction", "err", err)
480
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
481
+
return
482
+
}
450
483
451
484
// reset atUri to make rollback a no-op
452
485
atUri = ""
453
486
454
487
// notify about the new comment
455
488
comment.Id = commentId
456
-
rp.notifier.NewIssueComment(r.Context(), &comment)
457
489
458
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
490
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
491
+
492
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
493
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
459
494
}
460
495
461
496
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
462
497
l := rp.logger.With("handler", "IssueComment")
463
498
user := rp.oauth.GetUser(r)
464
-
f, err := rp.repoResolver.Resolve(r)
465
-
if err != nil {
466
-
l.Error("failed to get repo and knot", "err", err)
467
-
return
468
-
}
469
499
470
500
issue, ok := r.Context().Value("issue").(*models.Issue)
471
501
if !ok {
···
477
507
commentId := chi.URLParam(r, "commentId")
478
508
comments, err := db.GetIssueComments(
479
509
rp.db,
480
-
db.FilterEq("id", commentId),
510
+
orm.FilterEq("id", commentId),
481
511
)
482
512
if err != nil {
483
513
l.Error("failed to fetch comment", "id", commentId)
···
493
523
494
524
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
495
525
LoggedInUser: user,
496
-
RepoInfo: f.RepoInfo(user),
526
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
497
527
Issue: issue,
498
528
Comment: &comment,
499
529
})
···
502
532
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
503
533
l := rp.logger.With("handler", "EditIssueComment")
504
534
user := rp.oauth.GetUser(r)
505
-
f, err := rp.repoResolver.Resolve(r)
506
-
if err != nil {
507
-
l.Error("failed to get repo and knot", "err", err)
508
-
return
509
-
}
510
535
511
536
issue, ok := r.Context().Value("issue").(*models.Issue)
512
537
if !ok {
···
518
543
commentId := chi.URLParam(r, "commentId")
519
544
comments, err := db.GetIssueComments(
520
545
rp.db,
521
-
db.FilterEq("id", commentId),
546
+
orm.FilterEq("id", commentId),
522
547
)
523
548
if err != nil {
524
549
l.Error("failed to fetch comment", "id", commentId)
···
542
567
case http.MethodGet:
543
568
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
544
569
LoggedInUser: user,
545
-
RepoInfo: f.RepoInfo(user),
570
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
546
571
Issue: issue,
547
572
Comment: &comment,
548
573
})
···
560
585
newComment := comment
561
586
newComment.Body = newBody
562
587
newComment.Edited = &now
588
+
newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
589
+
563
590
record := newComment.AsRecord()
564
591
565
-
_, err = db.AddIssueComment(rp.db, newComment)
592
+
tx, err := rp.db.Begin()
593
+
if err != nil {
594
+
l.Error("failed to start transaction", "err", err)
595
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
596
+
return
597
+
}
598
+
defer tx.Rollback()
599
+
600
+
_, err = db.AddIssueComment(tx, newComment)
566
601
if err != nil {
567
602
l.Error("failed to perferom update-description query", "err", err)
568
603
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
569
604
return
570
605
}
606
+
tx.Commit()
571
607
572
608
// rkey is optional, it was introduced later
573
609
if newComment.Rkey != "" {
···
596
632
// return new comment body with htmx
597
633
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
598
634
LoggedInUser: user,
599
-
RepoInfo: f.RepoInfo(user),
635
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
600
636
Issue: issue,
601
637
Comment: &newComment,
602
638
})
···
606
642
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
607
643
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
608
644
user := rp.oauth.GetUser(r)
609
-
f, err := rp.repoResolver.Resolve(r)
610
-
if err != nil {
611
-
l.Error("failed to get repo and knot", "err", err)
612
-
return
613
-
}
614
645
615
646
issue, ok := r.Context().Value("issue").(*models.Issue)
616
647
if !ok {
···
622
653
commentId := chi.URLParam(r, "commentId")
623
654
comments, err := db.GetIssueComments(
624
655
rp.db,
625
-
db.FilterEq("id", commentId),
656
+
orm.FilterEq("id", commentId),
626
657
)
627
658
if err != nil {
628
659
l.Error("failed to fetch comment", "id", commentId)
···
638
669
639
670
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
640
671
LoggedInUser: user,
641
-
RepoInfo: f.RepoInfo(user),
672
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
642
673
Issue: issue,
643
674
Comment: &comment,
644
675
})
···
647
678
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
648
679
l := rp.logger.With("handler", "ReplyIssueComment")
649
680
user := rp.oauth.GetUser(r)
650
-
f, err := rp.repoResolver.Resolve(r)
651
-
if err != nil {
652
-
l.Error("failed to get repo and knot", "err", err)
653
-
return
654
-
}
655
681
656
682
issue, ok := r.Context().Value("issue").(*models.Issue)
657
683
if !ok {
···
663
689
commentId := chi.URLParam(r, "commentId")
664
690
comments, err := db.GetIssueComments(
665
691
rp.db,
666
-
db.FilterEq("id", commentId),
692
+
orm.FilterEq("id", commentId),
667
693
)
668
694
if err != nil {
669
695
l.Error("failed to fetch comment", "id", commentId)
···
679
705
680
706
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
681
707
LoggedInUser: user,
682
-
RepoInfo: f.RepoInfo(user),
708
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
683
709
Issue: issue,
684
710
Comment: &comment,
685
711
})
···
688
714
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
689
715
l := rp.logger.With("handler", "DeleteIssueComment")
690
716
user := rp.oauth.GetUser(r)
691
-
f, err := rp.repoResolver.Resolve(r)
692
-
if err != nil {
693
-
l.Error("failed to get repo and knot", "err", err)
694
-
return
695
-
}
696
717
697
718
issue, ok := r.Context().Value("issue").(*models.Issue)
698
719
if !ok {
···
704
725
commentId := chi.URLParam(r, "commentId")
705
726
comments, err := db.GetIssueComments(
706
727
rp.db,
707
-
db.FilterEq("id", commentId),
728
+
orm.FilterEq("id", commentId),
708
729
)
709
730
if err != nil {
710
731
l.Error("failed to fetch comment", "id", commentId)
···
731
752
732
753
// optimistic deletion
733
754
deleted := time.Now()
734
-
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
755
+
err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id))
735
756
if err != nil {
736
757
l.Error("failed to delete comment", "err", err)
737
758
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
763
784
// htmx fragment of comment after deletion
764
785
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
765
786
LoggedInUser: user,
766
-
RepoInfo: f.RepoInfo(user),
787
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
767
788
Issue: issue,
768
789
Comment: &comment,
769
790
})
···
793
814
return
794
815
}
795
816
817
+
totalIssues := 0
818
+
if isOpen {
819
+
totalIssues = f.RepoStats.IssueCount.Open
820
+
} else {
821
+
totalIssues = f.RepoStats.IssueCount.Closed
822
+
}
823
+
796
824
keyword := params.Get("q")
797
825
798
-
var ids []int64
826
+
var issues []models.Issue
799
827
searchOpts := models.IssueSearchOptions{
800
828
Keyword: keyword,
801
829
RepoAt: f.RepoAt().String(),
···
808
836
l.Error("failed to search for issues", "err", err)
809
837
return
810
838
}
811
-
ids = res.Hits
812
-
l.Debug("searched issues with indexer", "count", len(ids))
813
-
} else {
814
-
ids, err = db.GetIssueIDs(rp.db, searchOpts)
839
+
l.Debug("searched issues with indexer", "count", len(res.Hits))
840
+
totalIssues = int(res.Total)
841
+
842
+
issues, err = db.GetIssues(
843
+
rp.db,
844
+
orm.FilterIn("id", res.Hits),
845
+
)
815
846
if err != nil {
816
-
l.Error("failed to search for issues", "err", err)
847
+
l.Error("failed to get issues", "err", err)
848
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
817
849
return
818
850
}
819
-
l.Debug("indexed all issues from the db", "count", len(ids))
820
-
}
821
851
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
852
+
} else {
853
+
openInt := 0
854
+
if isOpen {
855
+
openInt = 1
856
+
}
857
+
issues, err = db.GetIssuesPaginated(
858
+
rp.db,
859
+
page,
860
+
orm.FilterEq("repo_at", f.RepoAt()),
861
+
orm.FilterEq("open", openInt),
862
+
)
863
+
if err != nil {
864
+
l.Error("failed to get issues", "err", err)
865
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
866
+
return
867
+
}
830
868
}
831
869
832
870
labelDefs, err := db.GetLabelDefinitions(
833
871
rp.db,
834
-
db.FilterIn("at_uri", f.Repo.Labels),
835
-
db.FilterContains("scope", tangled.RepoIssueNSID),
872
+
orm.FilterIn("at_uri", f.Labels),
873
+
orm.FilterContains("scope", tangled.RepoIssueNSID),
836
874
)
837
875
if err != nil {
838
876
l.Error("failed to fetch labels", "err", err)
···
847
885
848
886
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
849
887
LoggedInUser: rp.oauth.GetUser(r),
850
-
RepoInfo: f.RepoInfo(user),
888
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
851
889
Issues: issues,
890
+
IssueCount: totalIssues,
852
891
LabelDefs: defs,
853
892
FilteringByOpen: isOpen,
854
893
FilterQuery: keyword,
···
870
909
case http.MethodGet:
871
910
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
872
911
LoggedInUser: user,
873
-
RepoInfo: f.RepoInfo(user),
912
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
874
913
})
875
914
case http.MethodPost:
915
+
body := r.FormValue("body")
916
+
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
917
+
876
918
issue := &models.Issue{
877
-
RepoAt: f.RepoAt(),
878
-
Rkey: tid.TID(),
879
-
Title: r.FormValue("title"),
880
-
Body: r.FormValue("body"),
881
-
Open: true,
882
-
Did: user.Did,
883
-
Created: time.Now(),
884
-
Repo: &f.Repo,
919
+
RepoAt: f.RepoAt(),
920
+
Rkey: tid.TID(),
921
+
Title: r.FormValue("title"),
922
+
Body: body,
923
+
Open: true,
924
+
Did: user.Did,
925
+
Created: time.Now(),
926
+
Mentions: mentions,
927
+
References: references,
928
+
Repo: f,
885
929
}
886
930
887
931
if err := rp.validator.ValidateIssue(issue); err != nil {
···
948
992
949
993
// everything is successful, do not rollback the atproto record
950
994
atUri = ""
951
-
rp.notifier.NewIssue(r.Context(), issue)
952
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
995
+
996
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
997
+
998
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
999
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
953
1000
return
954
1001
}
955
1002
}
+9
-9
appview/issues/opengraph.go
+9
-9
appview/issues/opengraph.go
···
143
143
var statusBgColor color.RGBA
144
144
145
145
if issue.Open {
146
-
statusIcon = "static/icons/circle-dot.svg"
146
+
statusIcon = "circle-dot"
147
147
statusText = "open"
148
148
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
149
} else {
150
-
statusIcon = "static/icons/circle-dot.svg"
150
+
statusIcon = "ban"
151
151
statusText = "closed"
152
152
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
153
}
···
155
155
badgeIconSize := 36
156
156
157
157
// Draw icon with status color (no background)
158
-
err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
158
+
err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
159
if err != nil {
160
160
log.Printf("failed to draw status icon: %v", err)
161
161
}
···
172
172
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
173
174
174
// Draw comment count
175
-
err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
175
+
err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
176
if err != nil {
177
177
log.Printf("failed to draw comment icon: %v", err)
178
178
}
···
193
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
196
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
197
197
if err != nil {
198
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
198
+
log.Printf("dolly not available (this is ok): %v", err)
199
199
}
200
200
201
201
// Draw "opened by @author" and date at the bottom with more spacing
···
232
232
233
233
// Get owner handle for avatar
234
234
var ownerHandle string
235
-
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
235
+
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did)
236
236
if err != nil {
237
-
ownerHandle = f.Repo.Did
237
+
ownerHandle = f.Did
238
238
} else {
239
239
ownerHandle = "@" + owner.Handle.String()
240
240
}
241
241
242
-
card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
242
+
card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle)
243
243
if err != nil {
244
244
log.Println("failed to draw issue summary card", err)
245
245
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+46
-24
appview/knots/knots.go
+46
-24
appview/knots/knots.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
20
21
"tangled.org/core/appview/xrpcclient"
21
22
"tangled.org/core/eventconsumer"
22
23
"tangled.org/core/idresolver"
24
+
"tangled.org/core/orm"
23
25
"tangled.org/core/rbac"
24
26
"tangled.org/core/tid"
25
27
···
38
40
Knotstream *eventconsumer.Consumer
39
41
}
40
42
43
+
type tab = map[string]any
44
+
45
+
var (
46
+
knotsTabs []tab = []tab{
47
+
{"Name": "profile", "Icon": "user"},
48
+
{"Name": "keys", "Icon": "key"},
49
+
{"Name": "emails", "Icon": "mail"},
50
+
{"Name": "notifications", "Icon": "bell"},
51
+
{"Name": "knots", "Icon": "volleyball"},
52
+
{"Name": "spindles", "Icon": "spool"},
53
+
}
54
+
)
55
+
41
56
func (k *Knots) Router() http.Handler {
42
57
r := chi.NewRouter()
43
58
···
58
73
user := k.OAuth.GetUser(r)
59
74
registrations, err := db.GetRegistrations(
60
75
k.Db,
61
-
db.FilterEq("did", user.Did),
76
+
orm.FilterEq("did", user.Did),
62
77
)
63
78
if err != nil {
64
79
k.Logger.Error("failed to fetch knot registrations", "err", err)
···
69
84
k.Pages.Knots(w, pages.KnotsParams{
70
85
LoggedInUser: user,
71
86
Registrations: registrations,
87
+
Tabs: knotsTabs,
88
+
Tab: "knots",
72
89
})
73
90
}
74
91
···
86
103
87
104
registrations, err := db.GetRegistrations(
88
105
k.Db,
89
-
db.FilterEq("did", user.Did),
90
-
db.FilterEq("domain", domain),
106
+
orm.FilterEq("did", user.Did),
107
+
orm.FilterEq("domain", domain),
91
108
)
92
109
if err != nil {
93
110
l.Error("failed to get registrations", "err", err)
···
111
128
repos, err := db.GetRepos(
112
129
k.Db,
113
130
0,
114
-
db.FilterEq("knot", domain),
131
+
orm.FilterEq("knot", domain),
115
132
)
116
133
if err != nil {
117
134
l.Error("failed to get knot repos", "err", err)
···
131
148
Members: members,
132
149
Repos: repoMap,
133
150
IsOwner: true,
151
+
Tabs: knotsTabs,
152
+
Tab: "knots",
134
153
})
135
154
}
136
155
···
145
164
}
146
165
147
166
domain := r.FormValue("domain")
167
+
// Strip protocol, trailing slashes, and whitespace
168
+
// Rkey cannot contain slashes
169
+
domain = strings.TrimSpace(domain)
170
+
domain = strings.TrimPrefix(domain, "https://")
171
+
domain = strings.TrimPrefix(domain, "http://")
172
+
domain = strings.TrimSuffix(domain, "/")
148
173
if domain == "" {
149
174
k.Pages.Notice(w, noticeId, "Incomplete form.")
150
175
return
···
269
294
// get record from db first
270
295
registrations, err := db.GetRegistrations(
271
296
k.Db,
272
-
db.FilterEq("did", user.Did),
273
-
db.FilterEq("domain", domain),
297
+
orm.FilterEq("did", user.Did),
298
+
orm.FilterEq("domain", domain),
274
299
)
275
300
if err != nil {
276
301
l.Error("failed to get registration", "err", err)
···
297
322
298
323
err = db.DeleteKnot(
299
324
tx,
300
-
db.FilterEq("did", user.Did),
301
-
db.FilterEq("domain", domain),
325
+
orm.FilterEq("did", user.Did),
326
+
orm.FilterEq("domain", domain),
302
327
)
303
328
if err != nil {
304
329
l.Error("failed to delete registration", "err", err)
···
378
403
// get record from db first
379
404
registrations, err := db.GetRegistrations(
380
405
k.Db,
381
-
db.FilterEq("did", user.Did),
382
-
db.FilterEq("domain", domain),
406
+
orm.FilterEq("did", user.Did),
407
+
orm.FilterEq("domain", domain),
383
408
)
384
409
if err != nil {
385
410
l.Error("failed to get registration", "err", err)
···
469
494
// Get updated registration to show
470
495
registrations, err = db.GetRegistrations(
471
496
k.Db,
472
-
db.FilterEq("did", user.Did),
473
-
db.FilterEq("domain", domain),
497
+
orm.FilterEq("did", user.Did),
498
+
orm.FilterEq("domain", domain),
474
499
)
475
500
if err != nil {
476
501
l.Error("failed to get registration", "err", err)
···
505
530
506
531
registrations, err := db.GetRegistrations(
507
532
k.Db,
508
-
db.FilterEq("did", user.Did),
509
-
db.FilterEq("domain", domain),
510
-
db.FilterIsNot("registered", "null"),
533
+
orm.FilterEq("did", user.Did),
534
+
orm.FilterEq("domain", domain),
535
+
orm.FilterIsNot("registered", "null"),
511
536
)
512
537
if err != nil {
513
538
l.Error("failed to get registration", "err", err)
···
526
551
}
527
552
528
553
member := r.FormValue("member")
554
+
member = strings.TrimPrefix(member, "@")
529
555
if member == "" {
530
556
l.Error("empty member")
531
557
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
588
614
}
589
615
590
616
// success
591
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
617
+
k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain))
592
618
}
593
619
594
620
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
···
612
638
613
639
registrations, err := db.GetRegistrations(
614
640
k.Db,
615
-
db.FilterEq("did", user.Did),
616
-
db.FilterEq("domain", domain),
617
-
db.FilterIsNot("registered", "null"),
641
+
orm.FilterEq("did", user.Did),
642
+
orm.FilterEq("domain", domain),
643
+
orm.FilterIsNot("registered", "null"),
618
644
)
619
645
if err != nil {
620
646
l.Error("failed to get registration", "err", err)
···
626
652
}
627
653
628
654
member := r.FormValue("member")
655
+
member = strings.TrimPrefix(member, "@")
629
656
if member == "" {
630
657
l.Error("empty member")
631
658
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
636
663
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
637
664
if err != nil {
638
665
l.Error("failed to resolve member identity to handle", "err", err)
639
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
640
-
return
641
-
}
642
-
if memberId.Handle.IsInvalidHandle() {
643
-
l.Error("failed to resolve member identity to handle")
644
666
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
645
667
return
646
668
}
+5
-4
appview/labels/labels.go
+5
-4
appview/labels/labels.go
···
16
16
"tangled.org/core/appview/oauth"
17
17
"tangled.org/core/appview/pages"
18
18
"tangled.org/core/appview/validator"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/rbac"
20
21
"tangled.org/core/tid"
21
22
···
88
89
repoAt := r.Form.Get("repo")
89
90
subjectUri := r.Form.Get("subject")
90
91
91
-
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
92
+
repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt))
92
93
if err != nil {
93
94
fail("Failed to get repository.", err)
94
95
return
95
96
}
96
97
97
98
// find all the labels that this repo subscribes to
98
-
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
99
+
repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt))
99
100
if err != nil {
100
101
fail("Failed to get labels for this repository.", err)
101
102
return
···
106
107
labelAts = append(labelAts, rl.LabelAt.String())
107
108
}
108
109
109
-
actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts))
110
+
actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts))
110
111
if err != nil {
111
112
fail("Invalid form data.", err)
112
113
return
113
114
}
114
115
115
116
// calculate the start state by applying already known labels
116
-
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
117
+
existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri))
117
118
if err != nil {
118
119
fail("Invalid form data.", err)
119
120
return
+67
appview/mentions/resolver.go
+67
appview/mentions/resolver.go
···
1
+
package mentions
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.org/core/appview/config"
9
+
"tangled.org/core/appview/db"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/pages/markup"
12
+
"tangled.org/core/idresolver"
13
+
)
14
+
15
+
type Resolver struct {
16
+
config *config.Config
17
+
idResolver *idresolver.Resolver
18
+
execer db.Execer
19
+
logger *slog.Logger
20
+
}
21
+
22
+
func New(
23
+
config *config.Config,
24
+
idResolver *idresolver.Resolver,
25
+
execer db.Execer,
26
+
logger *slog.Logger,
27
+
) *Resolver {
28
+
return &Resolver{
29
+
config,
30
+
idResolver,
31
+
execer,
32
+
logger,
33
+
}
34
+
}
35
+
36
+
func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) {
37
+
l := r.logger.With("method", "Resolve")
38
+
39
+
rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source)
40
+
l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs)
41
+
42
+
idents := r.idResolver.ResolveIdents(ctx, rawMentions)
43
+
var mentions []syntax.DID
44
+
for _, ident := range idents {
45
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
46
+
mentions = append(mentions, ident.DID)
47
+
}
48
+
}
49
+
l.Debug("found mentions", "mentions", mentions)
50
+
51
+
var resolvedRefs []models.ReferenceLink
52
+
for _, rawRef := range rawRefs {
53
+
ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle)
54
+
if err != nil || ident == nil || ident.Handle.IsInvalidHandle() {
55
+
continue
56
+
}
57
+
rawRef.Handle = string(ident.DID)
58
+
resolvedRefs = append(resolvedRefs, rawRef)
59
+
}
60
+
aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs)
61
+
if err != nil {
62
+
l.Error("failed running query", "err", err)
63
+
}
64
+
l.Debug("found references", "refs", aturiRefs)
65
+
66
+
return mentions, aturiRefs
67
+
}
+13
-6
appview/middleware/middleware.go
+13
-6
appview/middleware/middleware.go
···
18
18
"tangled.org/core/appview/pagination"
19
19
"tangled.org/core/appview/reporesolver"
20
20
"tangled.org/core/idresolver"
21
+
"tangled.org/core/orm"
21
22
"tangled.org/core/rbac"
22
23
)
23
24
···
164
165
ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
165
166
if err != nil || !ok {
166
167
// we need a logged in user
167
-
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo())
168
+
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo())
168
169
http.Error(w, "Forbiden", http.StatusUnauthorized)
169
170
return
170
171
}
···
180
181
return func(next http.Handler) http.Handler {
181
182
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
183
didOrHandle := chi.URLParam(req, "user")
184
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
185
+
183
186
if slices.Contains(excluded, didOrHandle) {
184
187
next.ServeHTTP(w, req)
185
188
return
186
189
}
187
190
188
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
-
190
191
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
192
if err != nil {
192
193
// invalid did or handle
···
206
207
return func(next http.Handler) http.Handler {
207
208
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
209
repoName := chi.URLParam(req, "repo")
210
+
repoName = strings.TrimSuffix(repoName, ".git")
211
+
209
212
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
213
if !ok {
211
214
log.Println("malformed middleware")
···
215
218
216
219
repo, err := db.GetRepo(
217
220
mw.db,
218
-
db.FilterEq("did", id.DID.String()),
219
-
db.FilterEq("name", repoName),
221
+
orm.FilterEq("did", id.DID.String()),
222
+
orm.FilterEq("name", repoName),
220
223
)
221
224
if err != nil {
222
225
log.Println("failed to resolve repo", "err", err)
226
+
w.WriteHeader(http.StatusNotFound)
223
227
mw.pages.ErrorKnot404(w)
224
228
return
225
229
}
···
237
241
f, err := mw.repoResolver.Resolve(r)
238
242
if err != nil {
239
243
log.Println("failed to fully resolve repo", err)
244
+
w.WriteHeader(http.StatusNotFound)
240
245
mw.pages.ErrorKnot404(w)
241
246
return
242
247
}
···
285
290
f, err := mw.repoResolver.Resolve(r)
286
291
if err != nil {
287
292
log.Println("failed to fully resolve repo", err)
293
+
w.WriteHeader(http.StatusNotFound)
288
294
mw.pages.ErrorKnot404(w)
289
295
return
290
296
}
···
321
327
f, err := mw.repoResolver.Resolve(r)
322
328
if err != nil {
323
329
log.Println("failed to fully resolve repo", err)
330
+
w.WriteHeader(http.StatusNotFound)
324
331
mw.pages.ErrorKnot404(w)
325
332
return
326
333
}
327
334
328
-
fullName := f.OwnerHandle() + "/" + f.Name
335
+
fullName := reporesolver.GetBaseRepoPath(r, f)
329
336
330
337
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
331
338
if r.URL.Query().Get("go-get") == "1" {
+70
-34
appview/models/issue.go
+70
-34
appview/models/issue.go
···
10
10
)
11
11
12
12
type Issue struct {
13
-
Id int64
14
-
Did string
15
-
Rkey string
16
-
RepoAt syntax.ATURI
17
-
IssueId int
18
-
Created time.Time
19
-
Edited *time.Time
20
-
Deleted *time.Time
21
-
Title string
22
-
Body string
23
-
Open bool
13
+
Id int64
14
+
Did string
15
+
Rkey string
16
+
RepoAt syntax.ATURI
17
+
IssueId int
18
+
Created time.Time
19
+
Edited *time.Time
20
+
Deleted *time.Time
21
+
Title string
22
+
Body string
23
+
Open bool
24
+
Mentions []syntax.DID
25
+
References []syntax.ATURI
24
26
25
27
// optionally, populate this when querying for reverse mappings
26
28
// like comment counts, parent repo etc.
···
34
36
}
35
37
36
38
func (i *Issue) AsRecord() tangled.RepoIssue {
39
+
mentions := make([]string, len(i.Mentions))
40
+
for i, did := range i.Mentions {
41
+
mentions[i] = string(did)
42
+
}
43
+
references := make([]string, len(i.References))
44
+
for i, uri := range i.References {
45
+
references[i] = string(uri)
46
+
}
37
47
return tangled.RepoIssue{
38
-
Repo: i.RepoAt.String(),
39
-
Title: i.Title,
40
-
Body: &i.Body,
41
-
CreatedAt: i.Created.Format(time.RFC3339),
48
+
Repo: i.RepoAt.String(),
49
+
Title: i.Title,
50
+
Body: &i.Body,
51
+
Mentions: mentions,
52
+
References: references,
53
+
CreatedAt: i.Created.Format(time.RFC3339),
42
54
}
43
55
}
44
56
···
161
173
}
162
174
163
175
type IssueComment struct {
164
-
Id int64
165
-
Did string
166
-
Rkey string
167
-
IssueAt string
168
-
ReplyTo *string
169
-
Body string
170
-
Created time.Time
171
-
Edited *time.Time
172
-
Deleted *time.Time
176
+
Id int64
177
+
Did string
178
+
Rkey string
179
+
IssueAt string
180
+
ReplyTo *string
181
+
Body string
182
+
Created time.Time
183
+
Edited *time.Time
184
+
Deleted *time.Time
185
+
Mentions []syntax.DID
186
+
References []syntax.ATURI
173
187
}
174
188
175
189
func (i *IssueComment) AtUri() syntax.ATURI {
···
177
191
}
178
192
179
193
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
194
+
mentions := make([]string, len(i.Mentions))
195
+
for i, did := range i.Mentions {
196
+
mentions[i] = string(did)
197
+
}
198
+
references := make([]string, len(i.References))
199
+
for i, uri := range i.References {
200
+
references[i] = string(uri)
201
+
}
180
202
return tangled.RepoIssueComment{
181
-
Body: i.Body,
182
-
Issue: i.IssueAt,
183
-
CreatedAt: i.Created.Format(time.RFC3339),
184
-
ReplyTo: i.ReplyTo,
203
+
Body: i.Body,
204
+
Issue: i.IssueAt,
205
+
CreatedAt: i.Created.Format(time.RFC3339),
206
+
ReplyTo: i.ReplyTo,
207
+
Mentions: mentions,
208
+
References: references,
185
209
}
186
210
}
187
211
···
205
229
return nil, err
206
230
}
207
231
232
+
i := record
233
+
mentions := make([]syntax.DID, len(record.Mentions))
234
+
for i, did := range record.Mentions {
235
+
mentions[i] = syntax.DID(did)
236
+
}
237
+
references := make([]syntax.ATURI, len(record.References))
238
+
for i, uri := range i.References {
239
+
references[i] = syntax.ATURI(uri)
240
+
}
241
+
208
242
comment := IssueComment{
209
-
Did: ownerDid,
210
-
Rkey: rkey,
211
-
Body: record.Body,
212
-
IssueAt: record.Issue,
213
-
ReplyTo: record.ReplyTo,
214
-
Created: created,
243
+
Did: ownerDid,
244
+
Rkey: rkey,
245
+
Body: record.Body,
246
+
IssueAt: record.Issue,
247
+
ReplyTo: record.ReplyTo,
248
+
Created: created,
249
+
Mentions: mentions,
250
+
References: references,
215
251
}
216
252
217
253
return &comment, nil
+25
-43
appview/models/label.go
+25
-43
appview/models/label.go
···
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"tangled.org/core/api/tangled"
17
-
"tangled.org/core/consts"
18
17
"tangled.org/core/idresolver"
19
18
)
20
19
···
461
460
return result
462
461
}
463
462
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
-
)
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
+
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
471
466
472
-
func DefaultLabelDefs() []string {
473
-
return []string{
474
-
LabelWontfix,
475
-
LabelDuplicate,
476
-
LabelAssignee,
477
-
LabelGoodFirstIssue,
478
-
LabelDocumentation,
479
-
}
480
-
}
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
+
}
481
475
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
-
}
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
+
}
494
480
495
-
var labelDefs []LabelDefinition
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
483
+
}
496
484
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
485
record, err := atproto.RepoGetRecord(
504
-
context.Background(),
505
-
client,
486
+
ctx,
487
+
&xrpcc,
506
488
"",
507
-
parsedUri.Collection().String(),
508
-
parsedUri.Authority().String(),
509
-
parsedUri.RecordKey().String(),
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
510
492
)
511
493
if err != nil {
512
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
526
508
}
527
509
528
510
labelDef, err := LabelDefinitionFromRecord(
529
-
parsedUri.Authority().String(),
530
-
parsedUri.RecordKey().String(),
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
531
513
labelRecord,
532
514
)
533
515
if err != nil {
+7
appview/models/notifications.go
+7
appview/models/notifications.go
···
20
20
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
22
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
23
24
)
24
25
25
26
type Notification struct {
···
63
64
return "git-pull-request-create"
64
65
case NotificationTypeFollowed:
65
66
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
66
69
default:
67
70
return ""
68
71
}
···
84
87
PullCreated bool
85
88
PullCommented bool
86
89
Followed bool
90
+
UserMentioned bool
87
91
PullMerged bool
88
92
IssueClosed bool
89
93
EmailNotifications bool
···
113
117
return prefs.PullCreated // same pref for now
114
118
case NotificationTypeFollowed:
115
119
return prefs.Followed
120
+
case NotificationTypeUserMentioned:
121
+
return prefs.UserMentioned
116
122
default:
117
123
return false
118
124
}
···
127
133
PullCreated: true,
128
134
PullCommented: true,
129
135
Followed: true,
136
+
UserMentioned: true,
130
137
PullMerged: true,
131
138
IssueClosed: true,
132
139
EmailNotifications: false,
+3
-1
appview/models/profile.go
+3
-1
appview/models/profile.go
···
111
111
}
112
112
113
113
type ByMonth struct {
114
+
Commits int
114
115
RepoEvents []RepoEvent
115
116
IssueEvents IssueEvents
116
117
PullEvents PullEvents
···
119
120
func (b ByMonth) IsEmpty() bool {
120
121
return len(b.RepoEvents) == 0 &&
121
122
len(b.IssueEvents.Items) == 0 &&
122
-
len(b.PullEvents.Items) == 0
123
+
len(b.PullEvents.Items) == 0 &&
124
+
b.Commits == 0
123
125
}
124
126
125
127
type IssueEvents struct {
+43
-5
appview/models/pull.go
+43
-5
appview/models/pull.go
···
66
66
TargetBranch string
67
67
State PullState
68
68
Submissions []*PullSubmission
69
+
Mentions []syntax.DID
70
+
References []syntax.ATURI
69
71
70
72
// stacking
71
73
StackId string // nullable string
···
81
83
Repo *Repo
82
84
}
83
85
86
+
// NOTE: This method does not include patch blob in returned atproto record
84
87
func (p Pull) AsRecord() tangled.RepoPull {
85
88
var source *tangled.RepoPull_Source
86
89
if p.PullSource != nil {
···
92
95
source.Repo = &s
93
96
}
94
97
}
98
+
mentions := make([]string, len(p.Mentions))
99
+
for i, did := range p.Mentions {
100
+
mentions[i] = string(did)
101
+
}
102
+
references := make([]string, len(p.References))
103
+
for i, uri := range p.References {
104
+
references[i] = string(uri)
105
+
}
95
106
96
107
record := tangled.RepoPull{
97
-
Title: p.Title,
98
-
Body: &p.Body,
99
-
CreatedAt: p.Created.Format(time.RFC3339),
108
+
Title: p.Title,
109
+
Body: &p.Body,
110
+
Mentions: mentions,
111
+
References: references,
112
+
CreatedAt: p.Created.Format(time.RFC3339),
100
113
Target: &tangled.RepoPull_Target{
101
114
Repo: p.RepoAt.String(),
102
115
Branch: p.TargetBranch,
103
116
},
104
-
Patch: p.LatestPatch(),
105
117
Source: source,
106
118
}
107
119
return record
···
146
158
147
159
// content
148
160
Body string
161
+
162
+
// meta
163
+
Mentions []syntax.DID
164
+
References []syntax.ATURI
149
165
150
166
// meta
151
167
Created time.Time
152
168
}
153
169
170
+
func (p *PullComment) AtUri() syntax.ATURI {
171
+
return syntax.ATURI(p.CommentAt)
172
+
}
173
+
174
+
// func (p *PullComment) AsRecord() tangled.RepoPullComment {
175
+
// mentions := make([]string, len(p.Mentions))
176
+
// for i, did := range p.Mentions {
177
+
// mentions[i] = string(did)
178
+
// }
179
+
// references := make([]string, len(p.References))
180
+
// for i, uri := range p.References {
181
+
// references[i] = string(uri)
182
+
// }
183
+
// return tangled.RepoPullComment{
184
+
// Pull: p.PullAt,
185
+
// Body: p.Body,
186
+
// Mentions: mentions,
187
+
// References: references,
188
+
// CreatedAt: p.Created.Format(time.RFC3339),
189
+
// }
190
+
// }
191
+
154
192
func (p *Pull) LastRoundNumber() int {
155
193
return len(p.Submissions) - 1
156
194
}
···
167
205
return p.LatestSubmission().SourceRev
168
206
}
169
207
170
-
func (p *Pull) PullAt() syntax.ATURI {
208
+
func (p *Pull) AtUri() syntax.ATURI {
171
209
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
172
210
}
173
211
+49
appview/models/reference.go
+49
appview/models/reference.go
···
1
+
package models
2
+
3
+
import "fmt"
4
+
5
+
type RefKind int
6
+
7
+
const (
8
+
RefKindIssue RefKind = iota
9
+
RefKindPull
10
+
)
11
+
12
+
func (k RefKind) String() string {
13
+
if k == RefKindIssue {
14
+
return "issues"
15
+
} else {
16
+
return "pulls"
17
+
}
18
+
}
19
+
20
+
// /@alice.com/cool-proj/issues/123
21
+
// /@alice.com/cool-proj/issues/123#comment-321
22
+
type ReferenceLink struct {
23
+
Handle string
24
+
Repo string
25
+
Kind RefKind
26
+
SubjectId int
27
+
CommentId *int
28
+
}
29
+
30
+
func (l ReferenceLink) String() string {
31
+
comment := ""
32
+
if l.CommentId != nil {
33
+
comment = fmt.Sprintf("#comment-%d", *l.CommentId)
34
+
}
35
+
return fmt.Sprintf("/%s/%s/%s/%d%s",
36
+
l.Handle,
37
+
l.Repo,
38
+
l.Kind.String(),
39
+
l.SubjectId,
40
+
comment,
41
+
)
42
+
}
43
+
44
+
type RichReferenceLink struct {
45
+
ReferenceLink
46
+
Title string
47
+
// reusing PullState for both issue & PR
48
+
State PullState
49
+
}
+61
-1
appview/models/repo.go
+61
-1
appview/models/repo.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"strings"
5
6
"time"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
17
18
Rkey string
18
19
Created time.Time
19
20
Description string
21
+
Website string
22
+
Topics []string
20
23
Spindle string
21
24
Labels []string
22
25
···
28
31
}
29
32
30
33
func (r *Repo) AsRecord() tangled.Repo {
31
-
var source, spindle, description *string
34
+
var source, spindle, description, website *string
32
35
33
36
if r.Source != "" {
34
37
source = &r.Source
···
42
45
description = &r.Description
43
46
}
44
47
48
+
if r.Website != "" {
49
+
website = &r.Website
50
+
}
51
+
45
52
return tangled.Repo{
46
53
Knot: r.Knot,
47
54
Name: r.Name,
48
55
Description: description,
56
+
Website: website,
57
+
Topics: r.Topics,
49
58
CreatedAt: r.Created.Format(time.RFC3339),
50
59
Source: source,
51
60
Spindle: spindle,
···
60
69
func (r Repo) DidSlashRepo() string {
61
70
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
71
return p
72
+
}
73
+
74
+
func (r Repo) TopicStr() string {
75
+
return strings.Join(r.Topics, " ")
63
76
}
64
77
65
78
type RepoStats struct {
···
91
104
Repo *Repo
92
105
Issues []Issue
93
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
7
)
8
8
9
9
type Star struct {
10
-
StarredByDid string
11
-
RepoAt syntax.ATURI
12
-
Created time.Time
13
-
Rkey string
10
+
Did string
11
+
RepoAt syntax.ATURI
12
+
Created time.Time
13
+
Rkey string
14
+
}
14
15
15
-
// optionally, populate this when querying for reverse mappings
16
+
// RepoStar is used for reverse mapping to repos
17
+
type RepoStar struct {
18
+
Star
16
19
Repo *Repo
17
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
+5
-4
appview/notifications/notifications.go
+5
-4
appview/notifications/notifications.go
···
11
11
"tangled.org/core/appview/oauth"
12
12
"tangled.org/core/appview/pages"
13
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
type Notifications struct {
···
53
54
54
55
total, err := db.CountNotifications(
55
56
n.db,
56
-
db.FilterEq("recipient_did", user.Did),
57
+
orm.FilterEq("recipient_did", user.Did),
57
58
)
58
59
if err != nil {
59
60
l.Error("failed to get total notifications", "err", err)
···
64
65
notifications, err := db.GetNotificationsWithEntities(
65
66
n.db,
66
67
page,
67
-
db.FilterEq("recipient_did", user.Did),
68
+
orm.FilterEq("recipient_did", user.Did),
68
69
)
69
70
if err != nil {
70
71
l.Error("failed to get notifications", "err", err)
···
96
97
97
98
count, err := db.CountNotifications(
98
99
n.db,
99
-
db.FilterEq("recipient_did", user.Did),
100
-
db.FilterEq("read", 0),
100
+
orm.FilterEq("recipient_did", user.Did),
101
+
orm.FilterEq("read", 0),
101
102
)
102
103
if err != nil {
103
104
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+125
-76
appview/notify/db/db.go
+125
-76
appview/notify/db/db.go
···
3
3
import (
4
4
"context"
5
5
"log"
6
-
"maps"
7
6
"slices"
8
7
9
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
10
"tangled.org/core/appview/db"
11
11
"tangled.org/core/appview/models"
12
12
"tangled.org/core/appview/notify"
13
13
"tangled.org/core/idresolver"
14
+
"tangled.org/core/orm"
15
+
"tangled.org/core/sets"
16
+
)
17
+
18
+
const (
19
+
maxMentions = 8
14
20
)
15
21
16
22
type databaseNotifier struct {
···
32
38
}
33
39
34
40
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
41
+
if star.RepoAt.Collection().String() != tangled.RepoNSID {
42
+
// skip string stars for now
43
+
return
44
+
}
35
45
var err error
36
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
46
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt)))
37
47
if err != nil {
38
48
log.Printf("NewStar: failed to get repos: %v", err)
39
49
return
40
50
}
41
51
42
-
actorDid := syntax.DID(star.StarredByDid)
43
-
recipients := []syntax.DID{syntax.DID(repo.Did)}
52
+
actorDid := syntax.DID(star.Did)
53
+
recipients := sets.Singleton(syntax.DID(repo.Did))
44
54
eventType := models.NotificationTypeRepoStarred
45
55
entityType := "repo"
46
56
entityId := star.RepoAt.String()
···
64
74
// no-op
65
75
}
66
76
67
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
77
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
78
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
79
+
if err != nil {
80
+
log.Printf("failed to fetch collaborators: %v", err)
81
+
return
82
+
}
68
83
69
84
// build the recipients list
70
85
// - owner of the repo
71
86
// - collaborators in the repo
72
-
var recipients []syntax.DID
73
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
74
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
75
-
if err != nil {
76
-
log.Printf("failed to fetch collaborators: %v", err)
77
-
return
87
+
// - remove users already mentioned
88
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
89
+
for _, c := range collaborators {
90
+
recipients.Insert(c.SubjectDid)
78
91
}
79
-
for _, c := range collaborators {
80
-
recipients = append(recipients, c.SubjectDid)
92
+
for _, m := range mentions {
93
+
recipients.Remove(m)
81
94
}
82
95
83
96
actorDid := syntax.DID(issue.Did)
84
-
eventType := models.NotificationTypeIssueCreated
85
97
entityType := "issue"
86
98
entityId := issue.AtUri().String()
87
99
repoId := &issue.Repo.Id
···
91
103
n.notifyEvent(
92
104
actorDid,
93
105
recipients,
94
-
eventType,
106
+
models.NotificationTypeIssueCreated,
107
+
entityType,
108
+
entityId,
109
+
repoId,
110
+
issueId,
111
+
pullId,
112
+
)
113
+
n.notifyEvent(
114
+
actorDid,
115
+
sets.Collect(slices.Values(mentions)),
116
+
models.NotificationTypeUserMentioned,
95
117
entityType,
96
118
entityId,
97
119
repoId,
···
100
122
)
101
123
}
102
124
103
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
104
-
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
125
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
126
+
issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt))
105
127
if err != nil {
106
128
log.Printf("NewIssueComment: failed to get issues: %v", err)
107
129
return
···
112
134
}
113
135
issue := issues[0]
114
136
115
-
var recipients []syntax.DID
116
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
137
+
// built the recipients list:
138
+
// - the owner of the repo
139
+
// - | if the comment is a reply -> everybody on that thread
140
+
// | if the comment is a top level -> just the issue owner
141
+
// - remove mentioned users from the recipients list
142
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
117
143
118
144
if comment.IsReply() {
119
145
// if this comment is a reply, then notify everybody in that thread
120
146
parentAtUri := *comment.ReplyTo
121
-
allThreads := issue.CommentList()
122
147
123
148
// find the parent thread, and add all DIDs from here to the recipient list
124
-
for _, t := range allThreads {
149
+
for _, t := range issue.CommentList() {
125
150
if t.Self.AtUri().String() == parentAtUri {
126
-
recipients = append(recipients, t.Participants()...)
151
+
for _, p := range t.Participants() {
152
+
recipients.Insert(p)
153
+
}
127
154
}
128
155
}
129
156
} else {
130
157
// not a reply, notify just the issue author
131
-
recipients = append(recipients, syntax.DID(issue.Did))
158
+
recipients.Insert(syntax.DID(issue.Did))
159
+
}
160
+
161
+
for _, m := range mentions {
162
+
recipients.Remove(m)
132
163
}
133
164
134
165
actorDid := syntax.DID(comment.Did)
135
-
eventType := models.NotificationTypeIssueCommented
136
166
entityType := "issue"
137
167
entityId := issue.AtUri().String()
138
168
repoId := &issue.Repo.Id
···
142
172
n.notifyEvent(
143
173
actorDid,
144
174
recipients,
145
-
eventType,
175
+
models.NotificationTypeIssueCommented,
176
+
entityType,
177
+
entityId,
178
+
repoId,
179
+
issueId,
180
+
pullId,
181
+
)
182
+
n.notifyEvent(
183
+
actorDid,
184
+
sets.Collect(slices.Values(mentions)),
185
+
models.NotificationTypeUserMentioned,
146
186
entityType,
147
187
entityId,
148
188
repoId,
···
157
197
158
198
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
159
199
actorDid := syntax.DID(follow.UserDid)
160
-
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
200
+
recipients := sets.Singleton(syntax.DID(follow.SubjectDid))
161
201
eventType := models.NotificationTypeFollowed
162
202
entityType := "follow"
163
203
entityId := follow.UserDid
···
180
220
}
181
221
182
222
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
183
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
223
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt)))
184
224
if err != nil {
185
225
log.Printf("NewPull: failed to get repos: %v", err)
186
226
return
187
227
}
228
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt()))
229
+
if err != nil {
230
+
log.Printf("failed to fetch collaborators: %v", err)
231
+
return
232
+
}
188
233
189
234
// build the recipients list
190
235
// - owner of the repo
191
236
// - collaborators in the repo
192
-
var recipients []syntax.DID
193
-
recipients = append(recipients, syntax.DID(repo.Did))
194
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
195
-
if err != nil {
196
-
log.Printf("failed to fetch collaborators: %v", err)
197
-
return
198
-
}
237
+
recipients := sets.Singleton(syntax.DID(repo.Did))
199
238
for _, c := range collaborators {
200
-
recipients = append(recipients, c.SubjectDid)
239
+
recipients.Insert(c.SubjectDid)
201
240
}
202
241
203
242
actorDid := syntax.DID(pull.OwnerDid)
204
243
eventType := models.NotificationTypePullCreated
205
244
entityType := "pull"
206
-
entityId := pull.PullAt().String()
245
+
entityId := pull.AtUri().String()
207
246
repoId := &repo.Id
208
247
var issueId *int64
209
248
p := int64(pull.ID)
···
221
260
)
222
261
}
223
262
224
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
263
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
225
264
pull, err := db.GetPull(n.db,
226
265
syntax.ATURI(comment.RepoAt),
227
266
comment.PullId,
···
231
270
return
232
271
}
233
272
234
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
273
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt))
235
274
if err != nil {
236
275
log.Printf("NewPullComment: failed to get repos: %v", err)
237
276
return
···
240
279
// build up the recipients list:
241
280
// - repo owner
242
281
// - all pull participants
243
-
var recipients []syntax.DID
244
-
recipients = append(recipients, syntax.DID(repo.Did))
282
+
// - remove those already mentioned
283
+
recipients := sets.Singleton(syntax.DID(repo.Did))
245
284
for _, p := range pull.Participants() {
246
-
recipients = append(recipients, syntax.DID(p))
285
+
recipients.Insert(syntax.DID(p))
286
+
}
287
+
for _, m := range mentions {
288
+
recipients.Remove(m)
247
289
}
248
290
249
291
actorDid := syntax.DID(comment.OwnerDid)
250
292
eventType := models.NotificationTypePullCommented
251
293
entityType := "pull"
252
-
entityId := pull.PullAt().String()
294
+
entityId := pull.AtUri().String()
253
295
repoId := &repo.Id
254
296
var issueId *int64
255
297
p := int64(pull.ID)
···
265
307
issueId,
266
308
pullId,
267
309
)
310
+
n.notifyEvent(
311
+
actorDid,
312
+
sets.Collect(slices.Values(mentions)),
313
+
models.NotificationTypeUserMentioned,
314
+
entityType,
315
+
entityId,
316
+
repoId,
317
+
issueId,
318
+
pullId,
319
+
)
268
320
}
269
321
270
322
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
283
335
// no-op
284
336
}
285
337
286
-
func (n *databaseNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
338
+
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
339
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
340
+
if err != nil {
341
+
log.Printf("failed to fetch collaborators: %v", err)
342
+
return
343
+
}
344
+
287
345
// build up the recipients list:
288
346
// - repo owner
289
347
// - repo collaborators
290
348
// - all issue participants
291
-
var recipients []syntax.DID
292
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
293
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
294
-
if err != nil {
295
-
log.Printf("failed to fetch collaborators: %v", err)
296
-
return
297
-
}
349
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
298
350
for _, c := range collaborators {
299
-
recipients = append(recipients, c.SubjectDid)
351
+
recipients.Insert(c.SubjectDid)
300
352
}
301
353
for _, p := range issue.Participants() {
302
-
recipients = append(recipients, syntax.DID(p))
354
+
recipients.Insert(syntax.DID(p))
303
355
}
304
356
305
-
actorDid := syntax.DID(issue.Repo.Did)
306
357
entityType := "pull"
307
358
entityId := issue.AtUri().String()
308
359
repoId := &issue.Repo.Id
···
317
368
}
318
369
319
370
n.notifyEvent(
320
-
actorDid,
371
+
actor,
321
372
recipients,
322
373
eventType,
323
374
entityType,
···
328
379
)
329
380
}
330
381
331
-
func (n *databaseNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
382
+
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
332
383
// Get repo details
333
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
384
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt)))
334
385
if err != nil {
335
386
log.Printf("NewPullState: failed to get repos: %v", err)
336
387
return
337
388
}
338
389
339
-
// build up the recipients list:
340
-
// - repo owner
341
-
// - all pull participants
342
-
var recipients []syntax.DID
343
-
recipients = append(recipients, syntax.DID(repo.Did))
344
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
390
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt()))
345
391
if err != nil {
346
392
log.Printf("failed to fetch collaborators: %v", err)
347
393
return
348
394
}
395
+
396
+
// build up the recipients list:
397
+
// - repo owner
398
+
// - all pull participants
399
+
recipients := sets.Singleton(syntax.DID(repo.Did))
349
400
for _, c := range collaborators {
350
-
recipients = append(recipients, c.SubjectDid)
401
+
recipients.Insert(c.SubjectDid)
351
402
}
352
403
for _, p := range pull.Participants() {
353
-
recipients = append(recipients, syntax.DID(p))
404
+
recipients.Insert(syntax.DID(p))
354
405
}
355
406
356
-
actorDid := syntax.DID(repo.Did)
357
407
entityType := "pull"
358
-
entityId := pull.PullAt().String()
408
+
entityId := pull.AtUri().String()
359
409
repoId := &repo.Id
360
410
var issueId *int64
361
411
var eventType models.NotificationType
···
374
424
pullId := &p
375
425
376
426
n.notifyEvent(
377
-
actorDid,
427
+
actor,
378
428
recipients,
379
429
eventType,
380
430
entityType,
···
387
437
388
438
func (n *databaseNotifier) notifyEvent(
389
439
actorDid syntax.DID,
390
-
recipients []syntax.DID,
440
+
recipients sets.Set[syntax.DID],
391
441
eventType models.NotificationType,
392
442
entityType string,
393
443
entityId string,
···
395
445
issueId *int64,
396
446
pullId *int64,
397
447
) {
398
-
recipientSet := make(map[syntax.DID]struct{})
399
-
for _, did := range recipients {
400
-
// everybody except actor themselves
401
-
if did != actorDid {
402
-
recipientSet[did] = struct{}{}
403
-
}
448
+
// if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody
449
+
if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions {
450
+
return
404
451
}
452
+
453
+
recipients.Remove(actorDid)
405
454
406
455
prefMap, err := db.GetNotificationPreferences(
407
456
n.db,
408
-
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
457
+
orm.FilterIn("user_did", slices.Collect(recipients.All())),
409
458
)
410
459
if err != nil {
411
460
// failed to get prefs for users
···
421
470
defer tx.Rollback()
422
471
423
472
// filter based on preferences
424
-
for recipientDid := range recipientSet {
473
+
for recipientDid := range recipients.All() {
425
474
prefs, ok := prefMap[recipientDid]
426
475
if !ok {
427
476
prefs = models.DefaultNotificationPreferences(recipientDid)
+11
-11
appview/notify/merged_notifier.go
+11
-11
appview/notify/merged_notifier.go
···
6
6
"reflect"
7
7
"sync"
8
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
10
"tangled.org/core/appview/models"
10
11
"tangled.org/core/log"
11
12
)
···
38
39
v.Call(in)
39
40
}(n)
40
41
}
41
-
wg.Wait()
42
42
}
43
43
44
44
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
···
53
53
m.fanout("DeleteStar", ctx, star)
54
54
}
55
55
56
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
57
-
m.fanout("NewIssue", ctx, issue)
56
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
57
+
m.fanout("NewIssue", ctx, issue, mentions)
58
58
}
59
59
60
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
61
-
m.fanout("NewIssueComment", ctx, comment)
60
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
61
+
m.fanout("NewIssueComment", ctx, comment, mentions)
62
62
}
63
63
64
-
func (m *mergedNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
65
-
m.fanout("NewIssueState", ctx, issue)
64
+
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
65
+
m.fanout("NewIssueState", ctx, actor, issue)
66
66
}
67
67
68
68
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
···
81
81
m.fanout("NewPull", ctx, pull)
82
82
}
83
83
84
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
85
-
m.fanout("NewPullComment", ctx, comment)
84
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
85
+
m.fanout("NewPullComment", ctx, comment, mentions)
86
86
}
87
87
88
-
func (m *mergedNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
89
-
m.fanout("NewPullState", ctx, pull)
88
+
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
89
+
m.fanout("NewPullState", ctx, actor, pull)
90
90
}
91
91
92
92
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+15
-12
appview/notify/notifier.go
+15
-12
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
7
"tangled.org/core/appview/models"
7
8
)
8
9
···
12
13
NewStar(ctx context.Context, star *models.Star)
13
14
DeleteStar(ctx context.Context, star *models.Star)
14
15
15
-
NewIssue(ctx context.Context, issue *models.Issue)
16
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
-
NewIssueState(ctx context.Context, issue *models.Issue)
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)
18
19
DeleteIssue(ctx context.Context, issue *models.Issue)
19
20
20
21
NewFollow(ctx context.Context, follow *models.Follow)
21
22
DeleteFollow(ctx context.Context, follow *models.Follow)
22
23
23
24
NewPull(ctx context.Context, pull *models.Pull)
24
-
NewPullComment(ctx context.Context, comment *models.PullComment)
25
-
NewPullState(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)
26
27
27
28
UpdateProfile(ctx context.Context, profile *models.Profile)
28
29
···
41
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
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) {}
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) {}
48
50
49
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
50
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
51
53
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) {}
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) {}
55
58
56
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
60
+15
-9
appview/notify/posthog/notifier.go
+15
-9
appview/notify/posthog/notifier.go
···
4
4
"context"
5
5
"log"
6
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
8
"github.com/posthog/posthog-go"
8
9
"tangled.org/core/appview/models"
9
10
"tangled.org/core/appview/notify"
···
36
37
37
38
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
38
39
err := n.client.Enqueue(posthog.Capture{
39
-
DistinctId: star.StarredByDid,
40
+
DistinctId: star.Did,
40
41
Event: "star",
41
42
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
43
})
···
47
48
48
49
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
49
50
err := n.client.Enqueue(posthog.Capture{
50
-
DistinctId: star.StarredByDid,
51
+
DistinctId: star.Did,
51
52
Event: "unstar",
52
53
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
54
})
···
56
57
}
57
58
}
58
59
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
60
61
err := n.client.Enqueue(posthog.Capture{
61
62
DistinctId: issue.Did,
62
63
Event: "new_issue",
63
64
Properties: posthog.Properties{
64
65
"repo_at": issue.RepoAt.String(),
65
66
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
66
68
},
67
69
})
68
70
if err != nil {
···
84
86
}
85
87
}
86
88
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
88
90
err := n.client.Enqueue(posthog.Capture{
89
91
DistinctId: comment.OwnerDid,
90
92
Event: "new_pull_comment",
91
93
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
94
97
},
95
98
})
96
99
if err != nil {
···
177
180
}
178
181
}
179
182
180
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
181
184
err := n.client.Enqueue(posthog.Capture{
182
185
DistinctId: comment.Did,
183
186
Event: "new_issue_comment",
184
187
Properties: posthog.Properties{
185
188
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
186
190
},
187
191
})
188
192
if err != nil {
···
190
194
}
191
195
}
192
196
193
-
func (n *posthogNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
197
+
func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
194
198
var event string
195
199
if issue.Open {
196
200
event = "issue_reopen"
···
202
206
Event: event,
203
207
Properties: posthog.Properties{
204
208
"repo_at": issue.RepoAt.String(),
209
+
"actor": actor,
205
210
"issue_id": issue.IssueId,
206
211
},
207
212
})
···
210
215
}
211
216
}
212
217
213
-
func (n *posthogNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
218
+
func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
214
219
var event string
215
220
switch pull.State {
216
221
case models.PullClosed:
···
229
234
Properties: posthog.Properties{
230
235
"repo_at": pull.RepoAt,
231
236
"pull_id": pull.PullId,
237
+
"actor": actor,
232
238
},
233
239
})
234
240
if err != nil {
+3
-2
appview/oauth/handler.go
+3
-2
appview/oauth/handler.go
···
16
16
"tangled.org/core/api/tangled"
17
17
"tangled.org/core/appview/db"
18
18
"tangled.org/core/consts"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/tid"
20
21
)
21
22
···
97
98
// and create an sh.tangled.spindle.member record with that
98
99
spindleMembers, err := db.GetSpindleMembers(
99
100
o.Db,
100
-
db.FilterEq("instance", "spindle.tangled.sh"),
101
-
db.FilterEq("subject", did),
101
+
orm.FilterEq("instance", "spindle.tangled.sh"),
102
+
orm.FilterEq("subject", did),
102
103
)
103
104
if err != nil {
104
105
l.Error("failed to get spindle members", "err", err)
+19
-2
appview/oauth/oauth.go
+19
-2
appview/oauth/oauth.go
···
74
74
75
75
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
76
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
+
}
77
81
78
82
clientName := config.Core.AppviewName
79
83
···
198
202
exp int64
199
203
lxm string
200
204
dev bool
205
+
timeout time.Duration
201
206
}
202
207
203
208
type ServiceClientOpt func(*ServiceClientOpts)
209
+
210
+
func DefaultServiceClientOpts() ServiceClientOpts {
211
+
return ServiceClientOpts{
212
+
timeout: time.Second * 5,
213
+
}
214
+
}
204
215
205
216
func WithService(service string) ServiceClientOpt {
206
217
return func(s *ServiceClientOpts) {
···
229
240
}
230
241
}
231
242
243
+
func WithTimeout(timeout time.Duration) ServiceClientOpt {
244
+
return func(s *ServiceClientOpts) {
245
+
s.timeout = timeout
246
+
}
247
+
}
248
+
232
249
func (s *ServiceClientOpts) Audience() string {
233
250
return fmt.Sprintf("did:web:%s", s.service)
234
251
}
···
243
260
}
244
261
245
262
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
246
-
opts := ServiceClientOpts{}
263
+
opts := DefaultServiceClientOpts()
247
264
for _, o := range os {
248
265
o(&opts)
249
266
}
···
270
287
},
271
288
Host: opts.Host(),
272
289
Client: &http.Client{
273
-
Timeout: time.Second * 5,
290
+
Timeout: opts.timeout,
274
291
},
275
292
}, nil
276
293
}
+63
-14
appview/ogcard/card.go
+63
-14
appview/ogcard/card.go
···
7
7
import (
8
8
"bytes"
9
9
"fmt"
10
+
"html/template"
10
11
"image"
11
12
"image/color"
12
13
"io"
···
279
280
return width, nil
280
281
}
281
282
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
-
283
+
func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) {
289
284
// Convert color to hex string for SVG
290
285
rgba, isRGBA := iconColor.(color.RGBA)
291
286
if !isRGBA {
···
304
299
// Parse SVG
305
300
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
301
if err != nil {
307
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
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)
308
312
}
309
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) DrawDolly(x, y, size int, iconColor color.Color) error {
338
+
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
340
+
if err != nil {
341
+
return fmt.Errorf("failed to read dolly template: %w", err)
342
+
}
343
+
344
+
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly 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) {
310
361
// Set the icon size
311
362
w, h := float64(size), float64(size)
312
363
icon.SetTarget(0, 0, w, h)
···
334
385
}
335
386
336
387
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
-
338
-
return nil
339
388
}
340
389
341
390
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
···
404
453
405
454
// Handle SVG separately
406
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
407
-
return c.convertSVGToPNG(bodyBytes)
456
+
return convertSVGToPNG(bodyBytes)
408
457
}
409
458
410
459
// Support content types are in-sync with the allowed custom avatar file types
···
444
493
}
445
494
446
495
// convertSVGToPNG converts SVG data to a PNG image
447
-
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
496
+
func convertSVGToPNG(svgData []byte) (image.Image, bool) {
448
497
// Parse the SVG
449
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
450
499
if err != nil {
···
498
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
499
548
500
549
// Draw the image with circular clipping
501
-
for cy := 0; cy < size; cy++ {
502
-
for cx := 0; cx < size; cx++ {
550
+
for cy := range size {
551
+
for cx := range size {
503
552
// Calculate distance from center
504
553
dx := float64(cx - center)
505
554
dy := float64(cy - center)
+104
-11
appview/pages/funcmap.go
+104
-11
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
+
"bytes"
4
5
"context"
5
6
"crypto/hmac"
6
7
"crypto/sha256"
···
17
18
"strings"
18
19
"time"
19
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"
20
25
"github.com/dustin/go-humanize"
21
26
"github.com/go-enry/go-enry/v2"
27
+
"github.com/yuin/goldmark"
28
+
emoji "github.com/yuin/goldmark-emoji"
22
29
"tangled.org/core/appview/filetree"
30
+
"tangled.org/core/appview/models"
23
31
"tangled.org/core/appview/pages/markup"
24
32
"tangled.org/core/crypto"
25
33
)
···
38
46
"contains": func(s string, target string) bool {
39
47
return strings.Contains(s, target)
40
48
},
49
+
"stripPort": func(hostname string) string {
50
+
if strings.Contains(hostname, ":") {
51
+
return strings.Split(hostname, ":")[0]
52
+
}
53
+
return hostname
54
+
},
41
55
"mapContains": func(m any, key any) bool {
42
56
mapValue := reflect.ValueOf(m)
43
57
if mapValue.Kind() != reflect.Map {
···
57
71
return "handle.invalid"
58
72
}
59
73
60
-
return "@" + identity.Handle.String()
74
+
return identity.Handle.String()
75
+
},
76
+
"ownerSlashRepo": func(repo *models.Repo) string {
77
+
ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did)
78
+
if err != nil {
79
+
return repo.DidSlashRepo()
80
+
}
81
+
handle := ownerId.Handle
82
+
if handle != "" && !handle.IsInvalidHandle() {
83
+
return string(handle) + "/" + repo.Name
84
+
}
85
+
return repo.DidSlashRepo()
61
86
},
62
87
"truncateAt30": func(s string) string {
63
88
if len(s) <= 30 {
···
68
93
"splitOn": func(s, sep string) []string {
69
94
return strings.Split(s, sep)
70
95
},
96
+
"string": func(v any) string {
97
+
return fmt.Sprint(v)
98
+
},
71
99
"int64": func(a int) int64 {
72
100
return int64(a)
73
101
},
···
83
111
},
84
112
"sub": func(a, b int) int {
85
113
return a - b
114
+
},
115
+
"mul": func(a, b int) int {
116
+
return a * b
117
+
},
118
+
"div": func(a, b int) int {
119
+
return a / b
120
+
},
121
+
"mod": func(a, b int) int {
122
+
return a % b
86
123
},
87
124
"f64": func(a int) float64 {
88
125
return float64(a)
···
116
153
117
154
return b
118
155
},
119
-
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
122
-
} else {
123
-
return did
124
-
}
125
-
},
126
156
"assoc": func(values ...string) ([][]string, error) {
127
157
if len(values)%2 != 0 {
128
158
return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
···
133
163
}
134
164
return pairs, nil
135
165
},
136
-
"append": func(s []string, values ...string) []string {
166
+
"append": func(s []any, values ...any) []any {
137
167
s = append(s, values...)
138
168
return s
139
169
},
···
232
262
},
233
263
"description": func(text string) template.HTML {
234
264
p.rctx.RendererType = markup.RendererTypeDefault
265
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New(
266
+
goldmark.WithExtensions(
267
+
emoji.Emoji,
268
+
),
269
+
))
270
+
sanitized := p.rctx.SanitizeDescription(htmlString)
271
+
return template.HTML(sanitized)
272
+
},
273
+
"readme": func(text string) template.HTML {
274
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
235
275
htmlString := p.rctx.RenderMarkdown(text)
236
-
sanitized := p.rctx.SanitizeDescription(htmlString)
276
+
sanitized := p.rctx.SanitizeDefault(htmlString)
237
277
return template.HTML(sanitized)
238
278
},
279
+
"code": func(content, path string) string {
280
+
var style *chroma.Style = styles.Get("catpuccin-latte")
281
+
formatter := chromahtml.New(
282
+
chromahtml.InlineCode(false),
283
+
chromahtml.WithLineNumbers(true),
284
+
chromahtml.WithLinkableLineNumbers(true, "L"),
285
+
chromahtml.Standalone(false),
286
+
chromahtml.WithClasses(true),
287
+
)
288
+
289
+
lexer := lexers.Get(filepath.Base(path))
290
+
if lexer == nil {
291
+
lexer = lexers.Fallback
292
+
}
293
+
294
+
iterator, err := lexer.Tokenise(nil, content)
295
+
if err != nil {
296
+
p.logger.Error("chroma tokenize", "err", "err")
297
+
return ""
298
+
}
299
+
300
+
var code bytes.Buffer
301
+
err = formatter.Format(&code, style, iterator)
302
+
if err != nil {
303
+
p.logger.Error("chroma format", "err", "err")
304
+
return ""
305
+
}
306
+
307
+
return code.String()
308
+
},
309
+
"trimUriScheme": func(text string) string {
310
+
text = strings.TrimPrefix(text, "https://")
311
+
text = strings.TrimPrefix(text, "http://")
312
+
return text
313
+
},
239
314
"isNil": func(t any) bool {
240
315
// returns false for other "zero" values
241
316
return t == nil
···
281
356
u, _ := url.PathUnescape(s)
282
357
return u
283
358
},
284
-
359
+
"safeUrl": func(s string) template.URL {
360
+
return template.URL(s)
361
+
},
285
362
"tinyAvatar": func(handle string) string {
286
363
return p.AvatarUrl(handle, "tiny")
287
364
},
···
311
388
}
312
389
}
313
390
391
+
func (p *Pages) resolveDid(did string) string {
392
+
identity, err := p.resolver.ResolveIdent(context.Background(), did)
393
+
394
+
if err != nil {
395
+
return did
396
+
}
397
+
398
+
if identity.Handle.IsInvalidHandle() {
399
+
return "handle.invalid"
400
+
}
401
+
402
+
return identity.Handle.String()
403
+
}
404
+
314
405
func (p *Pages) AvatarUrl(handle, size string) string {
315
406
handle = strings.TrimPrefix(handle, "@")
407
+
408
+
handle = p.resolveDid(handle)
316
409
317
410
secret := p.avatar.SharedSecret
318
411
h := hmac.New(sha256.New, []byte(secret))
+121
appview/pages/markup/extension/atlink.go
+121
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]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`)
39
+
var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`)
40
+
41
+
type atParser struct{}
42
+
43
+
// NewAtParser return a new InlineParser that parses
44
+
// at expressions.
45
+
func NewAtParser() parser.InlineParser {
46
+
return &atParser{}
47
+
}
48
+
49
+
func (s *atParser) Trigger() []byte {
50
+
return []byte{'@'}
51
+
}
52
+
53
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
54
+
line, segment := block.PeekLine()
55
+
m := atRegexp.FindSubmatchIndex(line)
56
+
if m == nil {
57
+
return nil
58
+
}
59
+
60
+
// Check for all links in the markdown to see if the handle found is inside one
61
+
linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1)
62
+
for _, linkMatch := range linksIndexes {
63
+
if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] {
64
+
return nil
65
+
}
66
+
}
67
+
68
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
69
+
block.Advance(m[1])
70
+
node := &AtNode{}
71
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
72
+
node.Handle = string(atSegment.Value(block.Source())[1:])
73
+
return node
74
+
}
75
+
76
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
77
+
// renders At nodes.
78
+
type atHtmlRenderer struct {
79
+
html.Config
80
+
}
81
+
82
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
83
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
84
+
r := &atHtmlRenderer{
85
+
Config: html.NewConfig(),
86
+
}
87
+
for _, opt := range opts {
88
+
opt.SetHTMLOption(&r.Config)
89
+
}
90
+
return r
91
+
}
92
+
93
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
94
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
95
+
reg.Register(KindAt, r.renderAt)
96
+
}
97
+
98
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
99
+
if entering {
100
+
w.WriteString(`<a href="/`)
101
+
w.WriteString(n.(*AtNode).Handle)
102
+
w.WriteString(`" class="mention">`)
103
+
} else {
104
+
w.WriteString("</a>")
105
+
}
106
+
return ast.WalkContinue, nil
107
+
}
108
+
109
+
type atExt struct{}
110
+
111
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
112
+
var AtExt = &atExt{}
113
+
114
+
func (e *atExt) Extend(m goldmark.Markdown) {
115
+
m.Parser().AddOptions(parser.WithInlineParsers(
116
+
util.Prioritized(NewAtParser(), 500),
117
+
))
118
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
119
+
util.Prioritized(NewAtHTMLRenderer(), 500),
120
+
))
121
+
}
+13
-4
appview/pages/markup/markdown.go
+13
-4
appview/pages/markup/markdown.go
···
12
12
13
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
14
"github.com/alecthomas/chroma/v2/styles"
15
-
treeblood "github.com/wyatt915/goldmark-treeblood"
16
15
"github.com/yuin/goldmark"
16
+
"github.com/yuin/goldmark-emoji"
17
17
highlighting "github.com/yuin/goldmark-highlighting/v2"
18
18
"github.com/yuin/goldmark/ast"
19
19
"github.com/yuin/goldmark/extension"
···
25
25
htmlparse "golang.org/x/net/html"
26
26
27
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
28
29
"tangled.org/core/appview/pages/repoinfo"
29
30
)
30
31
···
50
51
Files fs.FS
51
52
}
52
53
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
+
func NewMarkdown() goldmark.Markdown {
54
55
md := goldmark.New(
55
56
goldmark.WithExtensions(
56
57
extension.GFM,
···
64
65
extension.NewFootnote(
65
66
extension.WithFootnoteIDPrefix([]byte("footnote")),
66
67
),
67
-
treeblood.MathML(),
68
68
callout.CalloutExtention,
69
+
textension.AtExt,
70
+
emoji.Emoji,
69
71
),
70
72
goldmark.WithParserOptions(
71
73
parser.WithAutoHeadingID(),
72
74
),
73
75
goldmark.WithRendererOptions(html.WithUnsafe()),
74
76
)
77
+
return md
78
+
}
75
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 {
76
85
if rctx != nil {
77
86
var transformers []util.PrioritizedValue
78
87
···
240
249
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
241
250
242
251
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
243
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
252
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
244
253
245
254
parsedURL := &url.URL{
246
255
Scheme: scheme,
+121
appview/pages/markup/markdown_test.go
+121
appview/pages/markup/markdown_test.go
···
1
+
package markup
2
+
3
+
import (
4
+
"bytes"
5
+
"testing"
6
+
)
7
+
8
+
func TestAtExtension_Rendering(t *testing.T) {
9
+
tests := []struct {
10
+
name string
11
+
markdown string
12
+
expected string
13
+
}{
14
+
{
15
+
name: "renders simple at mention",
16
+
markdown: "Hello @user.tngl.sh!",
17
+
expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`,
18
+
},
19
+
{
20
+
name: "renders multiple at mentions",
21
+
markdown: "Hi @alice.tngl.sh and @bob.example.com",
22
+
expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`,
23
+
},
24
+
{
25
+
name: "renders at mention in parentheses",
26
+
markdown: "Check this out (@user.tngl.sh)",
27
+
expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`,
28
+
},
29
+
{
30
+
name: "does not render email",
31
+
markdown: "Contact me at test@example.com",
32
+
expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`,
33
+
},
34
+
{
35
+
name: "renders at mention with hyphen",
36
+
markdown: "Follow @user-name.tngl.sh",
37
+
expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`,
38
+
},
39
+
{
40
+
name: "renders at mention with numbers",
41
+
markdown: "@user123.test456.social",
42
+
expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`,
43
+
},
44
+
{
45
+
name: "at mention at start of line",
46
+
markdown: "@user.tngl.sh is cool",
47
+
expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`,
48
+
},
49
+
}
50
+
51
+
for _, tt := range tests {
52
+
t.Run(tt.name, func(t *testing.T) {
53
+
md := NewMarkdown()
54
+
55
+
var buf bytes.Buffer
56
+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
57
+
t.Fatalf("failed to convert markdown: %v", err)
58
+
}
59
+
60
+
result := buf.String()
61
+
if result != tt.expected+"\n" {
62
+
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result)
63
+
}
64
+
})
65
+
}
66
+
}
67
+
68
+
func TestAtExtension_WithOtherMarkdown(t *testing.T) {
69
+
tests := []struct {
70
+
name string
71
+
markdown string
72
+
contains string
73
+
}{
74
+
{
75
+
name: "at mention with bold",
76
+
markdown: "**Hello @user.tngl.sh**",
77
+
contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`,
78
+
},
79
+
{
80
+
name: "at mention with italic",
81
+
markdown: "*Check @user.tngl.sh*",
82
+
contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`,
83
+
},
84
+
{
85
+
name: "at mention in list",
86
+
markdown: "- Item 1\n- @user.tngl.sh\n- Item 3",
87
+
contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`,
88
+
},
89
+
{
90
+
name: "at mention in link",
91
+
markdown: "[@regnault.dev](https://regnault.dev)",
92
+
contains: `<a href="https://regnault.dev">@regnault.dev</a>`,
93
+
},
94
+
{
95
+
name: "at mention in link again",
96
+
markdown: "[check out @regnault.dev](https://regnault.dev)",
97
+
contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`,
98
+
},
99
+
{
100
+
name: "at mention in link again, multiline",
101
+
markdown: "[\ncheck out @regnault.dev](https://regnault.dev)",
102
+
contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>",
103
+
},
104
+
}
105
+
106
+
for _, tt := range tests {
107
+
t.Run(tt.name, func(t *testing.T) {
108
+
md := NewMarkdown()
109
+
110
+
var buf bytes.Buffer
111
+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
112
+
t.Fatalf("failed to convert markdown: %v", err)
113
+
}
114
+
115
+
result := buf.String()
116
+
if !bytes.Contains([]byte(result), []byte(tt.contains)) {
117
+
t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result)
118
+
}
119
+
})
120
+
}
121
+
}
+124
appview/pages/markup/reference_link.go
+124
appview/pages/markup/reference_link.go
···
1
+
package markup
2
+
3
+
import (
4
+
"maps"
5
+
"net/url"
6
+
"path"
7
+
"slices"
8
+
"strconv"
9
+
"strings"
10
+
11
+
"github.com/yuin/goldmark/ast"
12
+
"github.com/yuin/goldmark/text"
13
+
"tangled.org/core/appview/models"
14
+
textension "tangled.org/core/appview/pages/markup/extension"
15
+
)
16
+
17
+
// FindReferences collects all links referencing tangled-related objects
18
+
// like issues, PRs, comments or even @-mentions
19
+
// This funciton doesn't actually check for the existence of records in the DB
20
+
// or the PDS; it merely returns a list of what are presumed to be references.
21
+
func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) {
22
+
var (
23
+
refLinkSet = make(map[models.ReferenceLink]struct{})
24
+
mentionsSet = make(map[string]struct{})
25
+
md = NewMarkdown()
26
+
sourceBytes = []byte(source)
27
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
28
+
)
29
+
// trim url scheme. the SSL shouldn't matter
30
+
baseUrl = strings.TrimPrefix(baseUrl, "https://")
31
+
baseUrl = strings.TrimPrefix(baseUrl, "http://")
32
+
33
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
34
+
if !entering {
35
+
return ast.WalkContinue, nil
36
+
}
37
+
switch n.Kind() {
38
+
case textension.KindAt:
39
+
handle := n.(*textension.AtNode).Handle
40
+
mentionsSet[handle] = struct{}{}
41
+
return ast.WalkSkipChildren, nil
42
+
case ast.KindLink:
43
+
dest := string(n.(*ast.Link).Destination)
44
+
ref := parseTangledLink(baseUrl, dest)
45
+
if ref != nil {
46
+
refLinkSet[*ref] = struct{}{}
47
+
}
48
+
return ast.WalkSkipChildren, nil
49
+
case ast.KindAutoLink:
50
+
an := n.(*ast.AutoLink)
51
+
if an.AutoLinkType == ast.AutoLinkURL {
52
+
dest := string(an.URL(sourceBytes))
53
+
ref := parseTangledLink(baseUrl, dest)
54
+
if ref != nil {
55
+
refLinkSet[*ref] = struct{}{}
56
+
}
57
+
}
58
+
return ast.WalkSkipChildren, nil
59
+
}
60
+
return ast.WalkContinue, nil
61
+
})
62
+
mentions := slices.Collect(maps.Keys(mentionsSet))
63
+
references := slices.Collect(maps.Keys(refLinkSet))
64
+
return mentions, references
65
+
}
66
+
67
+
func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink {
68
+
u, err := url.Parse(urlStr)
69
+
if err != nil {
70
+
return nil
71
+
}
72
+
73
+
if u.Host != "" && !strings.EqualFold(u.Host, baseHost) {
74
+
return nil
75
+
}
76
+
77
+
p := path.Clean(u.Path)
78
+
parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' })
79
+
if len(parts) < 4 {
80
+
// need at least: handle / repo / kind / id
81
+
return nil
82
+
}
83
+
84
+
var (
85
+
handle = parts[0]
86
+
repo = parts[1]
87
+
kindSeg = parts[2]
88
+
subjectSeg = parts[3]
89
+
)
90
+
91
+
handle = strings.TrimPrefix(handle, "@")
92
+
93
+
var kind models.RefKind
94
+
switch kindSeg {
95
+
case "issues":
96
+
kind = models.RefKindIssue
97
+
case "pulls":
98
+
kind = models.RefKindPull
99
+
default:
100
+
return nil
101
+
}
102
+
103
+
subjectId, err := strconv.Atoi(subjectSeg)
104
+
if err != nil {
105
+
return nil
106
+
}
107
+
var commentId *int
108
+
if u.Fragment != "" {
109
+
if strings.HasPrefix(u.Fragment, "comment-") {
110
+
commentIdStr := u.Fragment[len("comment-"):]
111
+
if id, err := strconv.Atoi(commentIdStr); err == nil {
112
+
commentId = &id
113
+
}
114
+
}
115
+
}
116
+
117
+
return &models.ReferenceLink{
118
+
Handle: handle,
119
+
Repo: repo,
120
+
Kind: kind,
121
+
SubjectId: subjectId,
122
+
CommentId: commentId,
123
+
}
124
+
}
+42
appview/pages/markup/reference_link_test.go
+42
appview/pages/markup/reference_link_test.go
···
1
+
package markup_test
2
+
3
+
import (
4
+
"testing"
5
+
6
+
"github.com/stretchr/testify/assert"
7
+
"tangled.org/core/appview/models"
8
+
"tangled.org/core/appview/pages/markup"
9
+
)
10
+
11
+
func TestMarkupParsing(t *testing.T) {
12
+
tests := []struct {
13
+
name string
14
+
source string
15
+
wantHandles []string
16
+
wantRefLinks []models.ReferenceLink
17
+
}{
18
+
{
19
+
name: "normal link",
20
+
source: `[link](http://127.0.0.1:3000/alice.pds.tngl.boltless.dev/coolproj/issues/1)`,
21
+
wantHandles: make([]string, 0),
22
+
wantRefLinks: []models.ReferenceLink{
23
+
{Handle: "alice.pds.tngl.boltless.dev", Repo: "coolproj", Kind: models.RefKindIssue, SubjectId: 1, CommentId: nil},
24
+
},
25
+
},
26
+
{
27
+
name: "commonmark style autolink",
28
+
source: `<http://127.0.0.1:3000/alice.pds.tngl.boltless.dev/coolproj/issues/1>`,
29
+
wantHandles: make([]string, 0),
30
+
wantRefLinks: []models.ReferenceLink{
31
+
{Handle: "alice.pds.tngl.boltless.dev", Repo: "coolproj", Kind: models.RefKindIssue, SubjectId: 1, CommentId: nil},
32
+
},
33
+
},
34
+
}
35
+
for _, tt := range tests {
36
+
t.Run(tt.name, func(t *testing.T) {
37
+
handles, refLinks := markup.FindReferences("http://127.0.0.1:3000", tt.source)
38
+
assert.ElementsMatch(t, tt.wantHandles, handles)
39
+
assert.ElementsMatch(t, tt.wantRefLinks, refLinks)
40
+
})
41
+
}
42
+
}
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
···
77
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
79
80
+
// at-mentions
81
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
+
80
83
// centering content
81
84
policy.AllowElements("center")
82
85
+63
-129
appview/pages/pages.go
+63
-129
appview/pages/pages.go
···
1
1
package pages
2
2
3
3
import (
4
-
"bytes"
5
4
"crypto/sha256"
6
5
"embed"
7
6
"encoding/hex"
···
15
14
"path/filepath"
16
15
"strings"
17
16
"sync"
17
+
"time"
18
18
19
19
"tangled.org/core/api/tangled"
20
20
"tangled.org/core/appview/commitverify"
···
28
28
"tangled.org/core/patchutil"
29
29
"tangled.org/core/types"
30
30
31
-
"github.com/alecthomas/chroma/v2"
32
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
33
-
"github.com/alecthomas/chroma/v2/lexers"
34
-
"github.com/alecthomas/chroma/v2/styles"
35
31
"github.com/bluesky-social/indigo/atproto/identity"
36
32
"github.com/bluesky-social/indigo/atproto/syntax"
37
33
"github.com/go-git/go-git/v5/plumbing"
38
-
"github.com/go-git/go-git/v5/plumbing/object"
39
34
)
40
35
41
36
//go:embed templates/* static legal
···
215
210
return tpl.ExecuteTemplate(w, "layouts/base", params)
216
211
}
217
212
213
+
type DollyParams struct {
214
+
Classes string
215
+
FillColor string
216
+
}
217
+
218
+
func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
219
+
return p.executePlain("fragments/dolly/logo", w, params)
220
+
}
221
+
218
222
func (p *Pages) Favicon(w io.Writer) error {
219
-
return p.executePlain("fragments/dolly/silhouette", w, nil)
223
+
return p.Dolly(w, DollyParams{
224
+
Classes: "text-black dark:text-white",
225
+
})
220
226
}
221
227
222
228
type LoginParams struct {
···
411
417
type KnotsParams struct {
412
418
LoggedInUser *oauth.User
413
419
Registrations []models.Registration
420
+
Tabs []map[string]any
421
+
Tab string
414
422
}
415
423
416
424
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
423
431
Members []string
424
432
Repos map[string][]models.Repo
425
433
IsOwner bool
434
+
Tabs []map[string]any
435
+
Tab string
426
436
}
427
437
428
438
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
···
440
450
type SpindlesParams struct {
441
451
LoggedInUser *oauth.User
442
452
Spindles []models.Spindle
453
+
Tabs []map[string]any
454
+
Tab string
443
455
}
444
456
445
457
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
448
460
449
461
type SpindleListingParams struct {
450
462
models.Spindle
463
+
Tabs []map[string]any
464
+
Tab string
451
465
}
452
466
453
467
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
459
473
Spindle models.Spindle
460
474
Members []string
461
475
Repos map[string][]models.Repo
476
+
Tabs []map[string]any
477
+
Tab string
462
478
}
463
479
464
480
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
486
502
487
503
type ProfileCard struct {
488
504
UserDid string
489
-
UserHandle string
490
505
FollowStatus models.FollowStatus
491
506
Punchcard *models.Punchcard
492
507
Profile *models.Profile
···
629
644
return p.executePlain("user/fragments/editPins", w, params)
630
645
}
631
646
632
-
type RepoStarFragmentParams struct {
647
+
type StarBtnFragmentParams struct {
633
648
IsStarred bool
634
-
RepoAt syntax.ATURI
635
-
Stats models.RepoStats
636
-
}
637
-
638
-
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
639
-
return p.executePlain("repo/fragments/repoStar", w, params)
640
-
}
641
-
642
-
type RepoDescriptionParams struct {
643
-
RepoInfo repoinfo.RepoInfo
644
-
}
645
-
646
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
647
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
649
+
SubjectAt syntax.ATURI
650
+
StarCount int
648
651
}
649
652
650
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
651
-
return p.executePlain("repo/fragments/repoDescription", w, params)
653
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
654
+
return p.executePlain("fragments/starBtn-oob", w, params)
652
655
}
653
656
654
657
type RepoIndexParams struct {
···
656
659
RepoInfo repoinfo.RepoInfo
657
660
Active string
658
661
TagMap map[string][]string
659
-
CommitsTrunc []*object.Commit
662
+
CommitsTrunc []types.Commit
660
663
TagsTrunc []*types.TagReference
661
664
BranchesTrunc []types.Branch
662
665
// ForkInfo *types.ForkInfo
···
755
758
func (r RepoTreeParams) TreeStats() RepoTreeStats {
756
759
numFolders, numFiles := 0, 0
757
760
for _, f := range r.Files {
758
-
if !f.IsFile {
761
+
if !f.IsFile() {
759
762
numFolders += 1
760
-
} else if f.IsFile {
763
+
} else if f.IsFile() {
761
764
numFiles += 1
762
765
}
763
766
}
···
828
831
}
829
832
830
833
type RepoBlobParams struct {
831
-
LoggedInUser *oauth.User
832
-
RepoInfo repoinfo.RepoInfo
833
-
Active string
834
-
Unsupported bool
835
-
IsImage bool
836
-
IsVideo bool
837
-
ContentSrc string
838
-
BreadCrumbs [][]string
839
-
ShowRendered bool
840
-
RenderToggle bool
841
-
RenderedContents template.HTML
834
+
LoggedInUser *oauth.User
835
+
RepoInfo repoinfo.RepoInfo
836
+
Active string
837
+
BreadCrumbs [][]string
838
+
BlobView models.BlobView
842
839
*tangled.RepoBlob_Output
843
-
// Computed fields for template compatibility
844
-
Contents string
845
-
Lines int
846
-
SizeHint uint64
847
-
IsBinary bool
848
840
}
849
841
850
842
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
851
-
var style *chroma.Style = styles.Get("catpuccin-latte")
852
-
853
-
if params.ShowRendered {
854
-
switch markup.GetFormat(params.Path) {
855
-
case markup.FormatMarkdown:
856
-
p.rctx.RepoInfo = params.RepoInfo
857
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
858
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
859
-
sanitized := p.rctx.SanitizeDefault(htmlString)
860
-
params.RenderedContents = template.HTML(sanitized)
861
-
}
862
-
}
863
-
864
-
c := params.Contents
865
-
formatter := chromahtml.New(
866
-
chromahtml.InlineCode(false),
867
-
chromahtml.WithLineNumbers(true),
868
-
chromahtml.WithLinkableLineNumbers(true, "L"),
869
-
chromahtml.Standalone(false),
870
-
chromahtml.WithClasses(true),
871
-
)
872
-
873
-
lexer := lexers.Get(filepath.Base(params.Path))
874
-
if lexer == nil {
875
-
lexer = lexers.Fallback
876
-
}
877
-
878
-
iterator, err := lexer.Tokenise(nil, c)
879
-
if err != nil {
880
-
return fmt.Errorf("chroma tokenize: %w", err)
881
-
}
882
-
883
-
var code bytes.Buffer
884
-
err = formatter.Format(&code, style, iterator)
885
-
if err != nil {
886
-
return fmt.Errorf("chroma format: %w", err)
843
+
switch params.BlobView.ContentType {
844
+
case models.BlobContentTypeMarkup:
845
+
p.rctx.RepoInfo = params.RepoInfo
887
846
}
888
847
889
-
params.Contents = code.String()
890
848
params.Active = "overview"
891
849
return p.executeRepo("repo/blob", w, params)
892
850
}
893
851
894
852
type Collaborator struct {
895
-
Did string
896
-
Handle string
897
-
Role string
853
+
Did string
854
+
Role string
898
855
}
899
856
900
857
type RepoSettingsParams struct {
···
969
926
RepoInfo repoinfo.RepoInfo
970
927
Active string
971
928
Issues []models.Issue
929
+
IssueCount int
972
930
LabelDefs map[string]*models.LabelDefinition
973
931
Page pagination.Page
974
932
FilteringByOpen bool
···
986
944
Active string
987
945
Issue *models.Issue
988
946
CommentList []models.CommentListItem
947
+
Backlinks []models.RichReferenceLink
989
948
LabelDefs map[string]*models.LabelDefinition
990
949
991
950
OrderedReactionKinds []models.ReactionKind
···
1139
1098
Pull *models.Pull
1140
1099
Stack models.Stack
1141
1100
AbandonedPulls []*models.Pull
1101
+
Backlinks []models.RichReferenceLink
1142
1102
BranchDeleteStatus *models.BranchDeleteStatus
1143
1103
MergeCheck types.MergeCheckResponse
1144
1104
ResubmitCheck ResubmitResult
···
1310
1270
return p.executePlain("repo/fragments/compareAllowPull", w, params)
1311
1271
}
1312
1272
1313
-
type RepoCompareDiffParams struct {
1314
-
LoggedInUser *oauth.User
1315
-
RepoInfo repoinfo.RepoInfo
1316
-
Diff types.NiceDiff
1273
+
type RepoCompareDiffFragmentParams struct {
1274
+
Diff types.NiceDiff
1275
+
DiffOpts types.DiffOpts
1317
1276
}
1318
1277
1319
-
func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error {
1320
-
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1278
+
func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1279
+
return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1321
1280
}
1322
1281
1323
1282
type LabelPanelParams struct {
···
1361
1320
Name string
1362
1321
Command string
1363
1322
Collapsed bool
1323
+
StartTime time.Time
1364
1324
}
1365
1325
1366
1326
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1367
1327
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1368
1328
}
1369
1329
1330
+
type LogBlockEndParams struct {
1331
+
Id int
1332
+
StartTime time.Time
1333
+
EndTime time.Time
1334
+
}
1335
+
1336
+
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1337
+
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1338
+
}
1339
+
1370
1340
type LogLineParams struct {
1371
1341
Id int
1372
1342
Content string
···
1426
1396
ShowRendered bool
1427
1397
RenderToggle bool
1428
1398
RenderedContents template.HTML
1429
-
String models.String
1399
+
String *models.String
1430
1400
Stats models.StringStats
1401
+
IsStarred bool
1402
+
StarCount int
1431
1403
Owner identity.Identity
1432
1404
}
1433
1405
1434
1406
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1435
-
var style *chroma.Style = styles.Get("catpuccin-latte")
1436
-
1437
-
if params.ShowRendered {
1438
-
switch markup.GetFormat(params.String.Filename) {
1439
-
case markup.FormatMarkdown:
1440
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1441
-
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1442
-
sanitized := p.rctx.SanitizeDefault(htmlString)
1443
-
params.RenderedContents = template.HTML(sanitized)
1444
-
}
1445
-
}
1446
-
1447
-
c := params.String.Contents
1448
-
formatter := chromahtml.New(
1449
-
chromahtml.InlineCode(false),
1450
-
chromahtml.WithLineNumbers(true),
1451
-
chromahtml.WithLinkableLineNumbers(true, "L"),
1452
-
chromahtml.Standalone(false),
1453
-
chromahtml.WithClasses(true),
1454
-
)
1455
-
1456
-
lexer := lexers.Get(filepath.Base(params.String.Filename))
1457
-
if lexer == nil {
1458
-
lexer = lexers.Fallback
1459
-
}
1460
-
1461
-
iterator, err := lexer.Tokenise(nil, c)
1462
-
if err != nil {
1463
-
return fmt.Errorf("chroma tokenize: %w", err)
1464
-
}
1465
-
1466
-
var code bytes.Buffer
1467
-
err = formatter.Format(&code, style, iterator)
1468
-
if err != nil {
1469
-
return fmt.Errorf("chroma format: %w", err)
1470
-
}
1471
-
1472
-
params.String.Contents = code.String()
1473
1407
return p.execute("strings/string", w, params)
1474
1408
}
1475
1409
+27
-24
appview/pages/repoinfo/repoinfo.go
+27
-24
appview/pages/repoinfo/repoinfo.go
···
4
4
"fmt"
5
5
"path"
6
6
"slices"
7
-
"strings"
8
7
9
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
10
"tangled.org/core/appview/models"
11
11
"tangled.org/core/appview/state/userutil"
12
12
)
13
13
14
-
func (r RepoInfo) OwnerWithAt() string {
14
+
func (r RepoInfo) owner() string {
15
15
if r.OwnerHandle != "" {
16
-
return fmt.Sprintf("@%s", r.OwnerHandle)
16
+
return r.OwnerHandle
17
17
} else {
18
18
return r.OwnerDid
19
19
}
20
20
}
21
21
22
22
func (r RepoInfo) FullName() string {
23
-
return path.Join(r.OwnerWithAt(), r.Name)
23
+
return path.Join(r.owner(), r.Name)
24
24
}
25
25
26
-
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
-
return after
26
+
func (r RepoInfo) ownerWithoutAt() string {
27
+
if r.OwnerHandle != "" {
28
+
return r.OwnerHandle
29
29
} else {
30
30
return userutil.FlattenDid(r.OwnerDid)
31
31
}
32
32
}
33
33
34
34
func (r RepoInfo) FullNameWithoutAt() string {
35
-
return path.Join(r.OwnerWithoutAt(), r.Name)
35
+
return path.Join(r.ownerWithoutAt(), r.Name)
36
36
}
37
37
38
38
func (r RepoInfo) GetTabs() [][]string {
···
48
48
}
49
49
50
50
return tabs
51
+
}
52
+
53
+
func (r RepoInfo) RepoAt() syntax.ATURI {
54
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey))
51
55
}
52
56
53
57
type RepoInfo struct {
54
-
Name string
55
-
Rkey string
56
-
OwnerDid string
57
-
OwnerHandle string
58
-
Description string
59
-
Knot string
60
-
Spindle string
61
-
RepoAt syntax.ATURI
62
-
IsStarred bool
63
-
Stats models.RepoStats
64
-
Roles RolesInRepo
65
-
Source *models.Repo
66
-
SourceHandle string
67
-
Ref string
68
-
DisableFork bool
69
-
CurrentDir string
58
+
Name string
59
+
Rkey string
60
+
OwnerDid string
61
+
OwnerHandle string
62
+
Description string
63
+
Website string
64
+
Topics []string
65
+
Knot string
66
+
Spindle string
67
+
IsStarred bool
68
+
Stats models.RepoStats
69
+
Roles RolesInRepo
70
+
Source *models.Repo
71
+
Ref string
72
+
CurrentDir string
70
73
}
71
74
72
75
// each tab on a repo could have some metadata:
+9
-29
appview/pages/templates/brand/brand.html
+9
-29
appview/pages/templates/brand/brand.html
···
4
4
<div class="grid grid-cols-10">
5
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
-
<p class="text-gray-600 dark:text-gray-400 mb-1">
7
+
<p class="text-gray-500 dark:text-gray-300 mb-1">
8
8
Assets and guidelines for using Tangled's logo and brand elements.
9
9
</p>
10
10
</header>
···
14
14
15
15
<!-- Introduction Section -->
16
16
<section>
17
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
17
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
18
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
19
follow the below guidelines when using Dolly and the logotype.
20
20
</p>
21
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
21
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
22
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
23
</p>
24
24
</section>
···
34
34
</div>
35
35
<div class="order-1 lg:order-2">
36
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
37
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p>
38
38
<p class="text-gray-700 dark:text-gray-300">
39
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
40
backgrounds and designs.
···
53
53
</div>
54
54
<div class="order-1 lg:order-2">
55
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
56
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p>
57
57
<p class="text-gray-700 dark:text-gray-300">
58
58
This version features white text and elements, ideal for dark backgrounds
59
59
and inverted designs.
···
81
81
</div>
82
82
<div class="order-1 lg:order-2">
83
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
84
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
85
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
86
</p>
87
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
123
</div>
124
124
<div class="order-1 lg:order-2">
125
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
126
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
127
127
White logo mark on colored backgrounds.
128
128
</p>
129
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
165
</div>
166
166
<div class="order-1 lg:order-2">
167
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
168
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
169
169
Dark logo mark on lighter, pastel backgrounds.
170
170
</p>
171
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
186
</div>
187
187
<div class="order-1 lg:order-2">
188
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
189
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
190
190
Custom coloring of the logotype is permitted.
191
191
</p>
192
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
194
</p>
195
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
-
</p>
198
-
</div>
199
-
</section>
200
-
201
-
<!-- Silhouette Section -->
202
-
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
-
<div class="order-2 lg:order-1">
204
-
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
-
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
-
alt="Dolly silhouette"
207
-
class="w-full max-w-32 mx-auto" />
208
-
</div>
209
-
</div>
210
-
<div class="order-1 lg:order-2">
211
-
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
-
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
-
<p class="text-gray-700 dark:text-gray-300">
214
-
The silhouette can be used where a subtle brand presence is needed,
215
-
or as a background element. Works on any background color with proper contrast.
216
-
For example, we use this as the site's favicon.
217
197
</p>
218
198
</div>
219
199
</section>
+94
-54
appview/pages/templates/fragments/dolly/logo.html
+94
-54
appview/pages/templates/fragments/dolly/logo.html
···
1
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>
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{ .Classes }}"
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
+
<style>
21
+
.dolly {
22
+
color: #000000;
23
+
}
24
+
25
+
@media (prefers-color-scheme: dark) {
26
+
.dolly {
27
+
color: #ffffff;
28
+
}
29
+
}
30
+
</style>
31
+
<sodipodi:namedview
32
+
id="namedview1"
33
+
pagecolor="#ffffff"
34
+
bordercolor="#000000"
35
+
borderopacity="0.25"
36
+
inkscape:showpageshadow="2"
37
+
inkscape:pageopacity="0.0"
38
+
inkscape:pagecheckerboard="true"
39
+
inkscape:deskcolor="#d5d5d5"
40
+
inkscape:zoom="45.254834"
41
+
inkscape:cx="3.1377863"
42
+
inkscape:cy="8.9382717"
43
+
inkscape:window-width="3840"
44
+
inkscape:window-height="2160"
45
+
inkscape:window-x="0"
46
+
inkscape:window-y="0"
47
+
inkscape:window-maximized="0"
48
+
inkscape:current-layer="g1"
49
+
borderlayer="true">
50
+
<inkscape:page
51
+
x="0"
52
+
y="0"
53
+
width="25"
54
+
height="25"
55
+
id="page2"
56
+
margin="0"
57
+
bleed="0" />
58
+
</sodipodi:namedview>
59
+
<g
60
+
inkscape:groupmode="layer"
61
+
inkscape:label="Image"
62
+
id="g1"
63
+
transform="translate(-0.42924038,-0.87777209)">
64
+
<path
65
+
class="dolly"
66
+
fill="{{ or .FillColor "currentColor" }}"
67
+
style="stroke-width:0.111183;"
68
+
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"
69
+
id="path4"
70
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
71
+
</g>
72
+
<metadata
73
+
id="metadata1">
74
+
<rdf:RDF>
75
+
<cc:Work
76
+
rdf:about="">
77
+
<cc:license
78
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
79
+
</cc:Work>
80
+
<cc:License
81
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
82
+
<cc:permits
83
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
84
+
<cc:permits
85
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
86
+
<cc:requires
87
+
rdf:resource="http://creativecommons.org/ns#Notice" />
88
+
<cc:requires
89
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
90
+
<cc:permits
91
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
92
+
</cc:License>
93
+
</rdf:RDF>
94
+
</metadata>
95
+
</svg>
56
96
{{ end }}
-57
appview/pages/templates/fragments/dolly/silhouette.html
-57
appview/pages/templates/fragments/dolly/silhouette.html
···
1
-
{{ define "fragments/dolly/silhouette" }}
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"
30
-
bordercolor="#000000"
31
-
borderopacity="0.25"
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"
39
-
width="25"
40
-
height="25"
41
-
id="page2"
42
-
margin="0"
43
-
bleed="0" />
44
-
</sodipodi:namedview>
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 }}
-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>
+1
-1
appview/pages/templates/fragments/logotype.html
+1
-1
appview/pages/templates/fragments/logotype.html
···
1
1
{{ define "fragments/logotype" }}
2
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }}
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }}
4
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
6
alpha
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
···
1
1
{{ define "fragments/logotypeSmall" }}
2
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}}
4
4
<span class="font-bold text-xl not-italic">tangled</span>
5
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
6
alpha
+5
appview/pages/templates/fragments/starBtn-oob.html
+5
appview/pages/templates/fragments/starBtn-oob.html
+26
appview/pages/templates/fragments/starBtn.html
+26
appview/pages/templates/fragments/starBtn.html
···
1
+
{{ define "fragments/starBtn" }}
2
+
{{/* NOTE: this fragment is always replaced with hx-swap-oob */}}
3
+
<button
4
+
id="starBtn"
5
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
+
data-star-subject-at="{{ .SubjectAt }}"
7
+
{{ if .IsStarred }}
8
+
hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
9
+
{{ else }}
10
+
hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
11
+
{{ end }}
12
+
13
+
hx-trigger="click"
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
+
{{ .StarCount }}
23
+
</span>
24
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
+
</button>
26
+
{{ end }}
+33
appview/pages/templates/fragments/tabSelector.html
+33
appview/pages/templates/fragments/tabSelector.html
···
1
+
{{ define "fragments/tabSelector" }}
2
+
{{ $name := .Name }}
3
+
{{ $all := .Values }}
4
+
{{ $active := .Active }}
5
+
{{ $include := .Include }}
6
+
<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">
7
+
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
8
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
9
+
{{ range $index, $value := $all }}
10
+
{{ $isActive := eq $value.Key $active }}
11
+
<a href="?{{ $name }}={{ $value.Key }}"
12
+
{{ if $include }}
13
+
hx-get="?{{ $name }}={{ $value.Key }}"
14
+
hx-include="{{ $include }}"
15
+
hx-push-url="true"
16
+
hx-target="body"
17
+
hx-on:htmx:config-request="if(!event.detail.parameters.q) delete event.detail.parameters.q"
18
+
{{ end }}
19
+
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 }}">
20
+
{{ if $value.Icon }}
21
+
{{ i $value.Icon "size-4" }}
22
+
{{ end }}
23
+
24
+
{{ with $value.Meta }}
25
+
{{ . }}
26
+
{{ end }}
27
+
28
+
{{ $value.Value }}
29
+
</a>
30
+
{{ end }}
31
+
</div>
32
+
{{ end }}
33
+
+22
appview/pages/templates/fragments/tinyAvatarList.html
+22
appview/pages/templates/fragments/tinyAvatarList.html
···
1
+
{{ define "fragments/tinyAvatarList" }}
2
+
{{ $all := .all }}
3
+
{{ $classes := .classes }}
4
+
{{ $ps := take $all 5 }}
5
+
<div class="inline-flex items-center -space-x-3">
6
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
7
+
{{ range $i, $p := $ps }}
8
+
<img
9
+
src="{{ tinyAvatar . }}"
10
+
alt=""
11
+
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}"
12
+
/>
13
+
{{ end }}
14
+
15
+
{{ if gt (len $all) 5 }}
16
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
17
+
+{{ sub (len $all) 5 }}
18
+
</span>
19
+
{{ end }}
20
+
</div>
21
+
{{ end }}
22
+
+36
appview/pages/templates/fragments/workflow-timers.html
+36
appview/pages/templates/fragments/workflow-timers.html
···
1
+
{{ define "fragments/workflow-timers" }}
2
+
<script>
3
+
function formatElapsed(seconds) {
4
+
if (seconds < 1) return '0s';
5
+
if (seconds < 60) return `${seconds}s`;
6
+
const minutes = Math.floor(seconds / 60);
7
+
const secs = seconds % 60;
8
+
if (seconds < 3600) return `${minutes}m ${secs}s`;
9
+
const hours = Math.floor(seconds / 3600);
10
+
const mins = Math.floor((seconds % 3600) / 60);
11
+
return `${hours}h ${mins}m`;
12
+
}
13
+
14
+
function updateTimers() {
15
+
const now = Math.floor(Date.now() / 1000);
16
+
17
+
document.querySelectorAll('[data-timer]').forEach(el => {
18
+
const startTime = parseInt(el.dataset.start);
19
+
const endTime = el.dataset.end ? parseInt(el.dataset.end) : null;
20
+
21
+
if (endTime) {
22
+
// Step is complete, show final time
23
+
const elapsed = endTime - startTime;
24
+
el.textContent = formatElapsed(elapsed);
25
+
} else {
26
+
// Step is running, update live
27
+
const elapsed = now - startTime;
28
+
el.textContent = formatElapsed(elapsed);
29
+
}
30
+
});
31
+
}
32
+
33
+
setInterval(updateTimers, 1000);
34
+
updateTimers();
35
+
</script>
36
+
{{ end }}
+23
-7
appview/pages/templates/knots/dashboard.html
+23
-7
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }} · knots{{ end }}
1
+
{{ define "title" }}{{ .Registration.Domain }} · {{ .Tab }} settings{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "knotDash" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "knotDash" }}
20
+
<div>
5
21
<div class="flex justify-between items-center">
6
-
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
22
+
<h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} · {{ .Registration.Domain }}</h2>
7
23
<div id="right-side" class="flex gap-2">
8
24
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
25
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
···
35
51
</div>
36
52
37
53
{{ if .Members }}
38
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
54
+
<section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
39
55
<div class="flex flex-col gap-2">
40
56
{{ block "member" . }} {{ end }}
41
57
</div>
···
79
95
<button
80
96
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
81
97
title="Delete knot"
82
-
hx-delete="/knots/{{ .Domain }}"
98
+
hx-delete="/settings/knots/{{ .Domain }}"
83
99
hx-swap="outerHTML"
84
100
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
85
101
hx-headers='{"shouldRedirect": "true"}'
···
95
111
<button
96
112
class="btn gap-2 group"
97
113
title="Retry knot verification"
98
-
hx-post="/knots/{{ .Domain }}/retry"
114
+
hx-post="/settings/knots/{{ .Domain }}/retry"
99
115
hx-swap="none"
100
116
hx-headers='{"shouldRefresh": "true"}'
101
117
>
···
113
129
<button
114
130
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
115
131
title="Remove member"
116
-
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
132
+
hx-post="/settings/knots/{{ $root.Registration.Domain }}/remove"
117
133
hx-swap="none"
118
134
hx-vals='{"member": "{{$member}}" }'
119
135
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+18
-10
appview/pages/templates/knots/fragments/addMemberModal.html
+18
-10
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Id }}"
15
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">
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">
17
19
{{ block "addKnotMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
20
22
21
23
{{ define "addKnotMemberPopover" }}
22
24
<form
23
-
hx-post="/knots/{{ .Domain }}/add"
25
+
hx-post="/settings/knots/{{ .Domain }}/add"
24
26
hx-indicator="#spinner"
25
27
hx-swap="none"
26
28
class="flex flex-col gap-2"
···
29
31
ADD MEMBER
30
32
</label>
31
33
<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
-
/>
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>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
···
54
62
</div>
55
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
64
</form>
57
-
{{ end }}
65
+
{{ end }}
+3
-3
appview/pages/templates/knots/fragments/knotListing.html
+3
-3
appview/pages/templates/knots/fragments/knotListing.html
···
7
7
8
8
{{ define "knotLeftSide" }}
9
9
{{ if .Registered }}
10
-
<a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
10
+
<a href="/settings/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
11
{{ i "hard-drive" "w-4 h-4" }}
12
12
<span class="hover:underline">
13
13
{{ .Domain }}
···
56
56
<button
57
57
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
58
58
title="Delete knot"
59
-
hx-delete="/knots/{{ .Domain }}"
59
+
hx-delete="/settings/knots/{{ .Domain }}"
60
60
hx-swap="outerHTML"
61
61
hx-target="#knot-{{.Id}}"
62
62
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
···
72
72
<button
73
73
class="btn gap-2 group"
74
74
title="Retry knot verification"
75
-
hx-post="/knots/{{ .Domain }}/retry"
75
+
hx-post="/settings/knots/{{ .Domain }}/retry"
76
76
hx-swap="none"
77
77
hx-target="#knot-{{.Id}}"
78
78
>
+42
-11
appview/pages/templates/knots/index.html
+42
-11
appview/pages/templates/knots/index.html
···
1
-
{{ define "title" }}knots{{ end }}
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
-
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
-
<span class="flex items-center gap-1">
7
-
{{ i "book" "w-3 h-3" }}
8
-
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a>
9
-
</span>
10
-
</div>
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "knotsList" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "knotsList" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Knots</h2>
23
+
{{ block "about" . }} {{ end }}
24
+
</div>
25
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
26
+
{{ template "docsButton" . }}
27
+
</div>
28
+
</div>
11
29
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
30
+
<section>
13
31
<div class="flex flex-col gap-6">
14
-
{{ block "about" . }} {{ end }}
15
32
{{ block "list" . }} {{ end }}
16
33
{{ block "register" . }} {{ end }}
17
34
</div>
···
50
67
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
51
68
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
52
69
<form
53
-
hx-post="/knots/register"
70
+
hx-post="/settings/knots/register"
54
71
class="max-w-2xl mb-2 space-y-4"
55
72
hx-indicator="#register-button"
56
73
hx-swap="none"
···
84
101
85
102
</section>
86
103
{{ end }}
104
+
105
+
{{ define "docsButton" }}
106
+
<a
107
+
class="btn flex items-center gap-2"
108
+
href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide">
109
+
{{ i "book" "size-4" }}
110
+
docs
111
+
</a>
112
+
<div
113
+
id="add-email-modal"
114
+
popover
115
+
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">
116
+
</div>
117
+
{{ end }}
+5
appview/pages/templates/layouts/base.html
+5
appview/pages/templates/layouts/base.html
···
9
9
10
10
<script defer src="/static/htmx.min.js"></script>
11
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
+
<script defer src="/static/actor-typeahead.js" type="module"></script>
13
+
14
+
<link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/>
15
+
<link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/>
16
+
<link rel="apple-touch-icon" href="/static/logos/dolly.png"/>
12
17
13
18
<!-- preconnect to image cdn -->
14
19
<link rel="preconnect" href="https://avatar.tangled.sh" />
+1
-7
appview/pages/templates/layouts/fragments/topbar.html
+1
-7
appview/pages/templates/layouts/fragments/topbar.html
···
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
-
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
-
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
-
alpha
10
-
</span>
6
+
{{ template "fragments/logotypeSmall" }}
11
7
</a>
12
8
</div>
13
9
···
61
57
<a href="/{{ $user }}">profile</a>
62
58
<a href="/{{ $user }}?tab=repos">repositories</a>
63
59
<a href="/{{ $user }}?tab=strings">strings</a>
64
-
<a href="/knots">knots</a>
65
-
<a href="/spindles">spindles</a>
66
60
<a href="/settings">settings</a>
67
61
<a href="#"
68
62
hx-post="/logout"
+8
-7
appview/pages/templates/layouts/profilebase.html
+8
-7
appview/pages/templates/layouts/profilebase.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
-
{{ $avatarUrl := fullAvatar .Card.UserHandle }}
5
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
4
+
{{ $handle := resolve .Card.UserDid }}
5
+
{{ $avatarUrl := fullAvatar $handle }}
6
+
<meta property="og:title" content="{{ $handle }}" />
6
7
<meta property="og:type" content="profile" />
7
-
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
8
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
<meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" />
9
+
<meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" />
9
10
<meta property="og:image" content="{{ $avatarUrl }}" />
10
11
<meta property="og:image:width" content="512" />
11
12
<meta property="og:image:height" content="512" />
12
13
13
14
<meta name="twitter:card" content="summary" />
14
-
<meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
15
-
<meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
15
+
<meta name="twitter:title" content="{{ $handle }}" />
16
+
<meta name="twitter:description" content="{{ or .Card.Profile.Description $handle }}" />
16
17
<meta name="twitter:image" content="{{ $avatarUrl }}" />
17
18
{{ end }}
18
19
+57
-26
appview/pages/templates/layouts/repobase.html
+57
-26
appview/pages/templates/layouts/repobase.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
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>
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>
20
49
</div>
21
50
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 }}
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) }}
30
56
<a
31
57
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
32
58
hx-boost="true"
···
36
62
fork
37
63
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
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>
39
71
</div>
40
72
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
42
73
</section>
43
74
44
75
<section class="w-full flex flex-col" >
···
79
110
</div>
80
111
</nav>
81
112
{{ block "repoContentLayout" . }}
82
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
113
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
83
114
{{ block "repoContent" . }}{{ end }}
84
115
</section>
85
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
11
{{ end }}
12
12
13
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
14
{{ $linkstyle := "no-underline hover:underline" }}
19
15
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
20
16
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
36
32
</div>
37
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">
38
34
<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>
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>
51
56
{{ end }}
52
57
</div>
53
58
</div>
54
59
</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>
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>
76
97
{{ else }}
77
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
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>
78
99
{{ end }}
79
-
</div>
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>
80
105
{{ end }}
81
106
{{ template "fragments/multiline-select" }}
82
107
{{ end }}
+35
-10
appview/pages/templates/repo/commit.html
+35
-10
appview/pages/templates/repo/commit.html
···
25
25
</div>
26
26
27
27
<div class="flex flex-wrap items-center space-x-2">
28
-
<p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300">
29
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
30
-
31
-
{{ if $did }}
32
-
{{ template "user/fragments/picHandleLink" $did }}
33
-
{{ else }}
34
-
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
-
{{ end }}
28
+
<p class="flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
29
+
{{ template "attribution" . }}
36
30
37
31
<span class="px-1 select-none before:content-['\00B7']"></span>
38
-
{{ template "repo/fragments/time" $commit.Author.When }}
32
+
{{ template "repo/fragments/time" $commit.Committer.When }}
39
33
<span class="px-1 select-none before:content-['\00B7']"></span>
40
34
41
35
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
···
79
73
</section>
80
74
{{end}}
81
75
76
+
{{ define "attribution" }}
77
+
{{ $commit := .Diff.Commit }}
78
+
{{ $showCommitter := true }}
79
+
{{ if eq $commit.Author.Email $commit.Committer.Email }}
80
+
{{ $showCommitter = false }}
81
+
{{ end }}
82
+
83
+
{{ if $showCommitter }}
84
+
authored by {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid) }}
85
+
{{ range $commit.CoAuthors }}
86
+
{{ template "attributedUser" (list .Email .Name $.EmailToDid) }}
87
+
{{ end }}
88
+
and committed by {{ template "attributedUser" (list $commit.Committer.Email $commit.Committer.Name $.EmailToDid) }}
89
+
{{ else }}
90
+
{{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid )}}
91
+
{{ end }}
92
+
{{ end }}
93
+
94
+
{{ define "attributedUser" }}
95
+
{{ $email := index . 0 }}
96
+
{{ $name := index . 1 }}
97
+
{{ $map := index . 2 }}
98
+
{{ $did := index $map $email }}
99
+
100
+
{{ if $did }}
101
+
{{ template "user/fragments/picHandleLink" $did }}
102
+
{{ else }}
103
+
<a href="mailto:{{ $email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $name }}</a>
104
+
{{ end }}
105
+
{{ end }}
106
+
82
107
{{ define "topbarLayout" }}
83
108
<header class="col-span-full" style="z-index: 20;">
84
109
{{ template "layouts/fragments/topbar" . }}
···
111
136
{{ end }}
112
137
113
138
{{ define "contentAfter" }}
114
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }}
139
+
{{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
115
140
{{end}}
116
141
117
142
{{ define "contentAfterLeft" }}
+2
-2
appview/pages/templates/repo/compare/compare.html
+2
-2
appview/pages/templates/repo/compare/compare.html
···
17
17
{{ end }}
18
18
19
19
{{ define "mainLayout" }}
20
-
<div class="px-1 col-span-full flex flex-col gap-4">
20
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
21
21
{{ block "contentLayout" . }}
22
22
{{ block "content" . }}{{ end }}
23
23
{{ end }}
···
42
42
{{ end }}
43
43
44
44
{{ define "contentAfter" }}
45
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }}
45
+
{{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
46
46
{{end}}
47
47
48
48
{{ define "contentAfterLeft" }}
+2
-2
appview/pages/templates/repo/empty.html
+2
-2
appview/pages/templates/repo/empty.html
···
26
26
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
27
{{ $knot := .RepoInfo.Knot }}
28
28
{{ if eq $knot "knot1.tangled.sh" }}
29
-
{{ $knot = "tangled.sh" }}
29
+
{{ $knot = "tangled.org" }}
30
30
{{ end }}
31
31
<div class="w-full flex place-content-center">
32
32
<div class="py-6 w-fit flex flex-col gap-4">
···
35
35
36
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
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>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code></p>
39
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
40
</div>
41
41
</div>
+2
-1
appview/pages/templates/repo/fork.html
+2
-1
appview/pages/templates/repo/fork.html
···
25
25
value="{{ . }}"
26
26
class="mr-2"
27
27
id="domain-{{ . }}"
28
+
{{if eq (len $.Knots) 1}}checked{{end}}
28
29
/>
29
30
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
30
31
</div>
···
33
34
{{ end }}
34
35
</div>
35
36
</div>
36
-
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
37
+
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/settings/knots" class="underline">Learn how to register your own knot.</a></p>
37
38
</fieldset>
38
39
39
40
<div class="space-y-2">
+49
appview/pages/templates/repo/fragments/backlinks.html
+49
appview/pages/templates/repo/fragments/backlinks.html
···
1
+
{{ define "repo/fragments/backlinks" }}
2
+
{{ if .Backlinks }}
3
+
<div id="at-uri-panel" class="px-2 md:px-0">
4
+
<div>
5
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span>
6
+
</div>
7
+
<ul>
8
+
{{ range .Backlinks }}
9
+
<li>
10
+
{{ $repoOwner := resolve .Handle }}
11
+
{{ $repoName := .Repo }}
12
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
13
+
<div class="flex flex-col">
14
+
<div class="flex gap-2 items-center">
15
+
{{ if .State.IsClosed }}
16
+
<span class="text-gray-500 dark:text-gray-400">
17
+
{{ i "ban" "size-3" }}
18
+
</span>
19
+
{{ else if eq .Kind.String "issues" }}
20
+
<span class="text-green-600 dark:text-green-500">
21
+
{{ i "circle-dot" "size-3" }}
22
+
</span>
23
+
{{ else if .State.IsOpen }}
24
+
<span class="text-green-600 dark:text-green-500">
25
+
{{ i "git-pull-request" "size-3" }}
26
+
</span>
27
+
{{ else if .State.IsMerged }}
28
+
<span class="text-purple-600 dark:text-purple-500">
29
+
{{ i "git-merge" "size-3" }}
30
+
</span>
31
+
{{ else }}
32
+
<span class="text-gray-600 dark:text-gray-300">
33
+
{{ i "git-pull-request-closed" "size-3" }}
34
+
</span>
35
+
{{ end }}
36
+
<a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
+
</div>
38
+
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
+
<div>
40
+
<span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span>
41
+
</div>
42
+
{{ end }}
43
+
</div>
44
+
</li>
45
+
{{ end }}
46
+
</ul>
47
+
</div>
48
+
{{ end }}
49
+
{{ end }}
+5
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
+5
-4
appview/pages/templates/repo/fragments/cloneDropdown.html
···
29
29
<code
30
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code>
34
34
<button
35
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
43
43
44
44
<!-- SSH Clone -->
45
45
<div class="mb-3">
46
+
{{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }}
46
47
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label>
47
48
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
48
49
<code
49
50
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
51
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
52
+
data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}"
53
+
>git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
54
<button
54
55
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
56
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"
+3
-4
appview/pages/templates/repo/fragments/diff.html
+3
-4
appview/pages/templates/repo/fragments/diff.html
···
1
1
{{ define "repo/fragments/diff" }}
2
-
{{ $repo := index . 0 }}
3
-
{{ $diff := index . 1 }}
4
-
{{ $opts := index . 2 }}
2
+
{{ $diff := index . 0 }}
3
+
{{ $opts := index . 1 }}
5
4
6
5
{{ $commit := $diff.Commit }}
7
6
{{ $diff := $diff.Diff }}
···
18
17
{{ else }}
19
18
{{ range $idx, $hunk := $diff }}
20
19
{{ with $hunk }}
21
-
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
20
+
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
22
21
<summary class="list-none cursor-pointer sticky top-0">
23
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
24
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
···
5
5
{{ if .Split }}
6
6
{{ $active = "split" }}
7
7
{{ end }}
8
-
{{ $values := list "unified" "split" }}
9
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ 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) }}
10
28
</section>
11
29
{{ end }}
12
30
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 }}
+15
-1
appview/pages/templates/repo/fragments/editLabelPanel.html
+15
-1
appview/pages/templates/repo/fragments/editLabelPanel.html
···
170
170
{{ $fieldName := $def.AtUri }}
171
171
{{ $valueType := $def.ValueType }}
172
172
{{ $value := .value }}
173
+
173
174
{{ if $valueType.IsDidFormat }}
174
175
{{ $value = trimPrefix (resolve .value) "@" }}
176
+
<actor-typeahead>
177
+
<input
178
+
autocapitalize="none"
179
+
autocorrect="off"
180
+
autocomplete="off"
181
+
placeholder="user.tngl.sh"
182
+
value="{{$value}}"
183
+
name="{{$fieldName}}"
184
+
type="text"
185
+
class="p-1 w-full text-sm"
186
+
/>
187
+
</actor-typeahead>
188
+
{{ else }}
189
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
175
190
{{ end }}
176
-
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
177
191
{{ end }}
178
192
179
193
{{ define "nullTypeInput" }}
-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
+
+1
-16
appview/pages/templates/repo/fragments/participants.html
+1
-16
appview/pages/templates/repo/fragments/participants.html
···
6
6
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
7
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
8
</div>
9
-
<div class="flex items-center -space-x-3 mt-2">
10
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
-
{{ range $i, $p := $ps }}
12
-
<img
13
-
src="{{ tinyAvatar . }}"
14
-
alt=""
15
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
-
/>
17
-
{{ end }}
18
-
19
-
{{ if gt (len $all) 5 }}
20
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
-
+{{ sub (len $all) 5 }}
22
-
</span>
23
-
{{ end }}
24
-
</div>
9
+
{{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "w-8 h-8") }}
25
10
</div>
26
11
{{ end }}
-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 }}
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
6
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
13
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
14
14
{{- range .LeftLines -}}
15
15
{{- if .IsEmpty -}}
16
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
-
</div>
16
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
+
</span>
21
21
{{- else if eq .Op.String "-" -}}
22
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
-
<div class="px-2">{{ .Content }}</div>
26
-
</div>
22
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
+
</span>
27
27
{{- else if eq .Op.String " " -}}
28
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
-
<div class="px-2">{{ .Content }}</div>
32
-
</div>
28
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
+
</span>
33
33
{{- end -}}
34
34
{{- end -}}
35
-
{{- end -}}</div></div></pre>
35
+
{{- end -}}</div></div></div>
36
36
37
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
37
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
38
38
{{- range .RightLines -}}
39
39
{{- if .IsEmpty -}}
40
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
-
</div>
40
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
+
</span>
45
45
{{- else if eq .Op.String "+" -}}
46
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
-
<div class="px-2" >{{ .Content }}</div>
50
-
</div>
46
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
+
</span>
51
51
{{- else if eq .Op.String " " -}}
52
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
-
<div class="px-2">{{ .Content }}</div>
56
-
</div>
52
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
+
</span>
57
57
{{- end -}}
58
58
{{- end -}}
59
-
{{- end -}}</div></div></pre>
59
+
{{- end -}}</div></div></div>
60
60
</div>
61
61
{{ end }}
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
1
{{ define "repo/fragments/unifiedDiff" }}
2
2
{{ $name := .Id }}
3
-
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
3
+
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
4
4
{{- $oldStart := .OldPosition -}}
5
5
{{- $newStart := .NewPosition -}}
6
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
8
{{- $lineNrSepStyle1 := "" -}}
9
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
10
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
15
{{- range .Lines -}}
16
16
{{- if eq .Op.String "+" -}}
17
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
-
<div class="px-2">{{ .Line }}</div>
22
-
</div>
17
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
+
</span>
23
23
{{- $newStart = add64 $newStart 1 -}}
24
24
{{- end -}}
25
25
{{- if eq .Op.String "-" -}}
26
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
-
<div class="px-2">{{ .Line }}</div>
31
-
</div>
26
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
+
</span>
32
32
{{- $oldStart = add64 $oldStart 1 -}}
33
33
{{- end -}}
34
34
{{- if eq .Op.String " " -}}
35
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
-
<div class="px-2">{{ .Line }}</div>
40
-
</div>
35
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
+
</span>
41
41
{{- $newStart = add64 $newStart 1 -}}
42
42
{{- $oldStart = add64 $oldStart 1 -}}
43
43
{{- end -}}
44
44
{{- end -}}
45
-
{{- end -}}</div></div></pre>
45
+
{{- end -}}</div></div></div>
46
46
{{ end }}
47
-
+39
-10
appview/pages/templates/repo/index.html
+39
-10
appview/pages/templates/repo/index.html
···
14
14
{{ end }}
15
15
<div class="flex items-center justify-between pb-5">
16
16
{{ block "branchSelector" . }}{{ end }}
17
-
<div class="flex md:hidden items-center gap-2">
17
+
<div class="flex md:hidden items-center gap-3">
18
18
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold">
19
19
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
20
20
</a>
···
35
35
{{ end }}
36
36
37
37
{{ define "repoLanguages" }}
38
-
<details class="group -m-6 mb-4">
38
+
<details class="group -my-4 -m-6 mb-4">
39
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
40
{{ range $value := .Languages }}
41
41
<div
···
47
47
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
48
48
{{ range $value := .Languages }}
49
49
<div
50
-
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
50
+
class="flex items-center gap-2 text-xs align-items-center justify-center"
51
51
>
52
52
{{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }}
53
53
<div>{{ or $value.Name "Other" }}
···
66
66
67
67
{{ define "branchSelector" }}
68
68
<div class="flex gap-2 items-center justify-between w-full">
69
-
<div class="flex gap-2 items-center">
69
+
<div class="flex gap-2 items-stretch">
70
70
<select
71
71
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
72
72
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
···
129
129
{{ $icon := "folder" }}
130
130
{{ $iconStyle := "size-4 fill-current" }}
131
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
+
132
138
{{ if .IsFile }}
133
139
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
140
{{ $icon = "file" }}
135
141
{{ $iconStyle = "size-4" }}
136
142
{{ end }}
143
+
137
144
<a href="{{ $link }}" class="{{ $linkstyle }}">
138
145
<div class="flex items-center gap-2">
139
146
{{ i $icon $iconStyle "flex-shrink-0" }}
···
221
228
<span
222
229
class="mx-1 before:content-['ยท'] before:select-none"
223
230
></span>
224
-
<span>
225
-
{{ $did := index $.EmailToDid .Author.Email }}
226
-
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
227
-
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
228
-
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
229
-
</span>
231
+
{{ template "attribution" (list . $.EmailToDid) }}
230
232
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
231
233
{{ template "repo/fragments/time" .Committer.When }}
232
234
···
252
254
{{ end }}
253
255
</div>
254
256
</div>
257
+
{{ end }}
258
+
259
+
{{ define "attribution" }}
260
+
{{ $commit := index . 0 }}
261
+
{{ $map := index . 1 }}
262
+
<span class="flex items-center">
263
+
{{ $author := index $map $commit.Author.Email }}
264
+
{{ $coauthors := $commit.CoAuthors }}
265
+
{{ $all := list }}
266
+
267
+
{{ if $author }}
268
+
{{ $all = append $all $author }}
269
+
{{ end }}
270
+
{{ range $coauthors }}
271
+
{{ $co := index $map .Email }}
272
+
{{ if $co }}
273
+
{{ $all = append $all $co }}
274
+
{{ end }}
275
+
{{ end }}
276
+
277
+
{{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }}
278
+
<a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
279
+
class="no-underline hover:underline">
280
+
{{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }}
281
+
{{ if $coauthors }} +{{ length $coauthors }}{{ end }}
282
+
</a>
283
+
</span>
255
284
{{ end }}
256
285
257
286
{{ define "branchList" }}
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
19
19
{{ end }}
20
20
21
21
{{ define "timestamp" }}
22
-
<a href="#{{ .Comment.Id }}"
22
+
<a href="#comment-{{ .Comment.Id }}"
23
23
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
24
-
id="{{ .Comment.Id }}">
24
+
id="comment-{{ .Comment.Id }}">
25
25
{{ if .Comment.Deleted }}
26
26
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
27
27
{{ else if .Comment.Edited }}
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
+1
-1
appview/pages/templates/repo/issues/fragments/issueListing.html
···
8
8
class="no-underline hover:underline"
9
9
>
10
10
{{ .Title | description }}
11
-
<span class="text-gray-500">#{{ .IssueId }}</span>
11
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
12
12
</a>
13
13
</div>
14
14
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+4
appview/pages/templates/repo/issues/issue.html
+4
appview/pages/templates/repo/issues/issue.html
···
20
20
"Subject" $.Issue.AtUri
21
21
"State" $.Issue.Labels) }}
22
22
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
+
{{ template "repo/fragments/backlinks"
24
+
(dict "RepoInfo" $.RepoInfo
25
+
"Backlinks" $.Backlinks) }}
26
+
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
23
27
</div>
24
28
</div>
25
29
{{ end }}
+146
-53
appview/pages/templates/repo/issues/issues.html
+146
-53
appview/pages/templates/repo/issues/issues.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
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 }}"
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
+
id="search-q"
36
+
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"
37
+
type="text"
38
+
name="q"
39
+
value="{{ .FilterQuery }}"
40
+
placeholder=" "
16
41
>
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 }}"
42
+
<a
43
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
44
+
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"
23
45
>
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>
46
+
{{ i "x" "w-4 h-4" }}
47
+
</a>
48
+
</div>
49
+
<button
50
+
type="submit"
51
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
52
+
>
53
+
{{ i "search" "w-4 h-4" }}
54
+
</button>
33
55
</form>
34
-
</div>
35
-
<a
56
+
<div class="sm:row-start-1">
57
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }}
58
+
</div>
59
+
<a
36
60
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
-
>
61
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
62
+
>
39
63
{{ i "circle-plus" "w-4 h-4" }}
40
64
<span>new</span>
41
-
</a>
42
-
</div>
43
-
<div class="error" id="issues"></div>
65
+
</a>
66
+
</div>
67
+
<div class="error" id="issues"></div>
44
68
{{ end }}
45
69
46
70
{{ define "repoAfter" }}
47
71
<div class="mt-2">
48
72
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
49
73
</div>
50
-
{{ block "pagination" . }} {{ end }}
74
+
{{if gt .IssueCount .Page.Limit }}
75
+
{{ block "pagination" . }} {{ end }}
76
+
{{ end }}
51
77
{{ end }}
52
78
53
79
{{ define "pagination" }}
54
-
<div class="flex justify-end mt-4 gap-2">
55
-
{{ $currentState := "closed" }}
56
-
{{ if .FilteringByOpen }}
57
-
{{ $currentState = "open" }}
58
-
{{ end }}
80
+
<div class="flex justify-center items-center mt-4 gap-2">
81
+
{{ $currentState := "closed" }}
82
+
{{ if .FilteringByOpen }}
83
+
{{ $currentState = "open" }}
84
+
{{ end }}
85
+
86
+
{{ $prev := .Page.Previous.Offset }}
87
+
{{ $next := .Page.Next.Offset }}
88
+
{{ $lastPage := sub .IssueCount (mod .IssueCount .Page.Limit) }}
59
89
90
+
<a
91
+
class="
92
+
btn flex items-center gap-2 no-underline hover:no-underline
93
+
dark:text-white dark:hover:bg-gray-700
94
+
{{ if le .Page.Offset 0 }}
95
+
cursor-not-allowed opacity-50
96
+
{{ end }}
97
+
"
60
98
{{ 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>
99
+
hx-boost="true"
100
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}"
72
101
{{ end }}
102
+
>
103
+
{{ i "chevron-left" "w-4 h-4" }}
104
+
previous
105
+
</a>
73
106
107
+
<!-- dont show first page if current page is first page -->
108
+
{{ if gt .Page.Offset 0 }}
109
+
<a
110
+
hx-boost="true"
111
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset=0&limit={{ .Page.Limit }}"
112
+
>
113
+
1
114
+
</a>
115
+
{{ end }}
116
+
117
+
<!-- if previous page is not first or second page (prev > limit) -->
118
+
{{ if gt $prev .Page.Limit }}
119
+
<span>...</span>
120
+
{{ end }}
121
+
122
+
<!-- if previous page is not the first page -->
123
+
{{ if gt $prev 0 }}
124
+
<a
125
+
hx-boost="true"
126
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}"
127
+
>
128
+
{{ add (div $prev .Page.Limit) 1 }}
129
+
</a>
130
+
{{ end }}
131
+
132
+
<!-- current page. this is always visible -->
133
+
<span class="font-bold">
134
+
{{ add (div .Page.Offset .Page.Limit) 1 }}
135
+
</span>
136
+
137
+
<!-- if next page is not last page -->
138
+
{{ if lt $next $lastPage }}
139
+
<a
140
+
hx-boost="true"
141
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}"
142
+
>
143
+
{{ add (div $next .Page.Limit) 1 }}
144
+
</a>
145
+
{{ end }}
146
+
147
+
<!-- if next page is not second last or last page (next < issues - 2 * limit) -->
148
+
{{ if lt ($next) (sub .IssueCount (mul (2) .Page.Limit)) }}
149
+
<span>...</span>
150
+
{{ end }}
151
+
152
+
<!-- if its not the last page -->
153
+
{{ if lt .Page.Offset $lastPage }}
154
+
<a
155
+
hx-boost="true"
156
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $lastPage }}&limit={{ .Page.Limit }}"
157
+
>
158
+
{{ add (div $lastPage .Page.Limit) 1 }}
159
+
</a>
160
+
{{ end }}
161
+
162
+
<a
163
+
class="
164
+
btn flex items-center gap-2 no-underline hover:no-underline
165
+
dark:text-white dark:hover:bg-gray-700
166
+
{{ if ne (len .Issues) .Page.Limit }}
167
+
cursor-not-allowed opacity-50
168
+
{{ end }}
169
+
"
74
170
{{ 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>
171
+
hx-boost="true"
172
+
href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}"
84
173
{{ end }}
174
+
>
175
+
next
176
+
{{ i "chevron-right" "w-4 h-4" }}
177
+
</a>
85
178
</div>
86
179
{{ end }}
+40
-23
appview/pages/templates/repo/log.html
+40
-23
appview/pages/templates/repo/log.html
···
17
17
<div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700">
18
18
{{ $grid := "grid grid-cols-14 gap-4" }}
19
19
<div class="{{ $grid }}">
20
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div>
20
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Author</div>
21
21
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
22
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
23
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
24
</div>
26
25
{{ range $index, $commit := .Commits }}
27
26
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
27
<div class="{{ $grid }} py-3">
29
-
<div class="align-top truncate col-span-2">
30
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
-
{{ if $did }}
32
-
{{ template "user/fragments/picHandleLink" $did }}
33
-
{{ else }}
34
-
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
-
{{ end }}
28
+
<div class="align-top col-span-3">
29
+
{{ template "attribution" (list $commit $.EmailToDid) }}
36
30
</div>
37
31
<div class="align-top font-mono flex items-start col-span-3">
38
32
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
···
61
55
<div class="align-top col-span-6">
62
56
<div>
63
57
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
58
+
64
59
{{ if gt (len $messageParts) 1 }}
65
60
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
66
61
{{ end }}
···
72
67
</span>
73
68
{{ end }}
74
69
{{ end }}
70
+
71
+
<!-- ci status -->
72
+
<span class="text-xs">
73
+
{{ $pipeline := index $.Pipelines .Hash.String }}
74
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
75
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
76
+
{{ end }}
77
+
</span>
75
78
</div>
76
79
77
80
{{ if gt (len $messageParts) 1 }}
78
81
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
79
82
{{ end }}
80
-
</div>
81
-
<div class="align-top col-span-1">
82
-
<!-- ci status -->
83
-
{{ $pipeline := index $.Pipelines .Hash.String }}
84
-
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
85
-
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
-
{{ end }}
87
83
</div>
88
84
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
85
</div>
···
152
148
</a>
153
149
</span>
154
150
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
155
-
<span>
156
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
-
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
-
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
-
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
-
</a>
161
-
</span>
151
+
{{ template "attribution" (list $commit $.EmailToDid) }}
162
152
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
163
153
<span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span>
164
154
···
176
166
</div>
177
167
</section>
178
168
169
+
{{ end }}
170
+
171
+
{{ define "attribution" }}
172
+
{{ $commit := index . 0 }}
173
+
{{ $map := index . 1 }}
174
+
<span class="flex items-center gap-1">
175
+
{{ $author := index $map $commit.Author.Email }}
176
+
{{ $coauthors := $commit.CoAuthors }}
177
+
{{ $all := list }}
178
+
179
+
{{ if $author }}
180
+
{{ $all = append $all $author }}
181
+
{{ end }}
182
+
{{ range $coauthors }}
183
+
{{ $co := index $map .Email }}
184
+
{{ if $co }}
185
+
{{ $all = append $all $co }}
186
+
{{ end }}
187
+
{{ end }}
188
+
189
+
{{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }}
190
+
<a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
191
+
class="no-underline hover:underline">
192
+
{{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }}
193
+
{{ if $coauthors }} +{{ length $coauthors }}{{ end }}
194
+
</a>
195
+
</span>
179
196
{{ end }}
180
197
181
198
{{ define "repoAfter" }}
+2
-1
appview/pages/templates/repo/new.html
+2
-1
appview/pages/templates/repo/new.html
···
155
155
class="mr-2"
156
156
id="domain-{{ . }}"
157
157
required
158
+
{{if eq (len $.Knots) 1}}checked{{end}}
158
159
/>
159
160
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
161
</div>
···
164
165
</div>
165
166
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
167
A knot hosts repository data and handles Git operations.
167
-
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
You can also <a href="/settings/knots" class="underline">register your own knot</a>.
168
169
</p>
169
170
</div>
170
171
{{ end }}
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
2
2
<div id="lines" hx-swap-oob="beforeend">
3
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
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">
6
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
-
</div>
8
-
<div class="hidden group-open:flex items-center gap-1">
9
-
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
-
</div>
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>
11
7
</summary>
12
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>
13
9
</details>
14
10
</div>
15
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 }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
···
1
+
{{ define "repo/pipelines/fragments/logBlockEnd" }}
2
+
<span
3
+
class="ml-auto text-sm text-gray-500 tabular-nums"
4
+
data-timer="{{ .Id }}"
5
+
data-start="{{ .StartTime.Unix }}"
6
+
data-end="{{ .EndTime.Unix }}"
7
+
hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span>
8
+
{{ end }}
9
+
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
···
12
12
{{ range .Pipelines }}
13
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
14
{{ else }}
15
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
16
-
No pipelines run for this repository.
17
-
</p>
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://docs.tangled.org/spindles.html#pipelines" 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>
18
30
{{ end }}
19
31
</div>
20
32
</div>
+1
appview/pages/templates/repo/pipelines/workflow.html
+1
appview/pages/templates/repo/pipelines/workflow.html
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
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 }}
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 }}
62
61
63
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
64
-
{{ $disabled := "" }}
65
-
{{ if $isUpToDate }}
66
-
{{ $disabled = "disabled" }}
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"
67
74
{{ 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
75
77
-
hx-disabled-elt="#resubmitBtn"
78
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
79
78
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 }}
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 }}
91
90
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 }}
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 }}
102
101
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>
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 }}
114
112
</div>
115
113
{{ end }}
116
114
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/patch.html
+1
-1
appview/pages/templates/repo/pulls/patch.html
+5
-1
appview/pages/templates/repo/pulls/pull.html
+5
-1
appview/pages/templates/repo/pulls/pull.html
···
18
18
{{ template "repo/fragments/labelPanel"
19
19
(dict "RepoInfo" $.RepoInfo
20
20
"Defs" $.LabelDefs
21
-
"Subject" $.Pull.PullAt
21
+
"Subject" $.Pull.AtUri
22
22
"State" $.Pull.Labels) }}
23
23
{{ template "repo/fragments/participants" $.Pull.Participants }}
24
+
{{ template "repo/fragments/backlinks"
25
+
(dict "RepoInfo" $.RepoInfo
26
+
"Backlinks" $.Backlinks) }}
27
+
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
24
28
</div>
25
29
</div>
26
30
{{ end }}
+61
-38
appview/pages/templates/repo/pulls/pulls.html
+61
-38
appview/pages/templates/repo/pulls/pulls.html
···
8
8
{{ end }}
9
9
10
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>
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
+
id="search-q"
42
+
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"
43
+
type="text"
44
+
name="q"
45
+
value="{{ .FilterQuery }}"
46
+
placeholder=" "
47
+
>
42
48
<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"
49
+
href="?state={{ .FilteringBy.String }}"
50
+
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"
45
51
>
46
-
{{ i "git-pull-request-create" "w-4 h-4" }}
47
-
<span>new</span>
52
+
{{ i "x" "w-4 h-4" }}
48
53
</a>
54
+
</div>
55
+
<button
56
+
type="submit"
57
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
58
+
>
59
+
{{ i "search" "w-4 h-4" }}
60
+
</button>
61
+
</form>
62
+
<div class="sm:row-start-1">
63
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }}
49
64
</div>
50
-
<div class="error" id="pulls"></div>
65
+
<a
66
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
67
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
68
+
>
69
+
{{ i "git-pull-request-create" "w-4 h-4" }}
70
+
<span>new</span>
71
+
</a>
72
+
</div>
73
+
<div class="error" id="pulls"></div>
51
74
{{ end }}
52
75
53
76
{{ define "repoAfter" }}
···
140
163
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
141
164
</div>
142
165
</summary>
143
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
166
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
144
167
</details>
145
168
{{ end }}
146
169
{{ end }}
···
149
172
</div>
150
173
{{ end }}
151
174
152
-
{{ define "pullList" }}
175
+
{{ define "stackedPullList" }}
153
176
{{ $list := index . 0 }}
154
177
{{ $root := index . 1 }}
155
178
<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">
+22
-14
appview/pages/templates/repo/settings/access.html
+22
-14
appview/pages/templates/repo/settings/access.html
···
29
29
{{ template "addCollaboratorButton" . }}
30
30
{{ end }}
31
31
{{ range .Collaborators }}
32
+
{{ $handle := resolve .Did }}
32
33
<div class="border border-gray-200 dark:border-gray-700 rounded p-4">
33
34
<div class="flex items-center gap-3">
34
35
<img
35
-
src="{{ fullAvatar .Handle }}"
36
-
alt="{{ .Handle }}"
36
+
src="{{ fullAvatar $handle }}"
37
+
alt="{{ $handle }}"
37
38
class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/>
38
39
39
40
<div class="flex-1 min-w-0">
40
-
<a href="/{{ .Handle }}" class="block truncate">
41
-
{{ didOrHandle .Did .Handle }}
41
+
<a href="/{{ $handle }}" class="block truncate">
42
+
{{ $handle }}
42
43
</a>
43
44
<p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p>
44
45
</div>
···
66
67
<div
67
68
id="add-collaborator-modal"
68
69
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
+
class="
71
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
72
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
73
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
70
74
{{ template "addCollaboratorModal" . }}
71
75
</div>
72
76
{{ end }}
···
82
86
ADD COLLABORATOR
83
87
</label>
84
88
<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
-
/>
89
+
<actor-typeahead>
90
+
<input
91
+
autocapitalize="none"
92
+
autocorrect="off"
93
+
autocomplete="off"
94
+
type="text"
95
+
id="add-collaborator"
96
+
name="collaborator"
97
+
required
98
+
placeholder="user.tngl.sh"
99
+
class="w-full"
100
+
/>
101
+
</actor-typeahead>
94
102
<div class="flex gap-2 pt-2">
95
103
<button
96
104
type="button"
+47
appview/pages/templates/repo/settings/general.html
+47
appview/pages/templates/repo/settings/general.html
···
6
6
{{ template "repo/settings/fragments/sidebar" . }}
7
7
</div>
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "baseSettings" . }}
9
10
{{ template "branchSettings" . }}
10
11
{{ template "defaultLabelSettings" . }}
11
12
{{ template "customLabelSettings" . }}
···
13
14
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
15
</div>
15
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>
16
63
{{ end }}
17
64
18
65
{{ define "branchSettings" }}
+1
-1
appview/pages/templates/repo/settings/pipelines.html
+1
-1
appview/pages/templates/repo/settings/pipelines.html
···
22
22
<p class="text-gray-500 dark:text-gray-400">
23
23
Choose a spindle to execute your workflows on. Only repository owners
24
24
can configure spindles. Spindles can be selfhosted,
25
-
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide">
26
26
click to learn more.
27
27
</a>
28
28
</p>
+8
appview/pages/templates/repo/tree.html
+8
appview/pages/templates/repo/tree.html
···
59
59
{{ $icon := "folder" }}
60
60
{{ $iconStyle := "size-4 fill-current" }}
61
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
+
62
68
{{ if .IsFile }}
69
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
63
70
{{ $icon = "file" }}
64
71
{{ $iconStyle = "size-4" }}
65
72
{{ end }}
73
+
66
74
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
75
<div class="flex items-center gap-2">
68
76
{{ i $icon $iconStyle "flex-shrink-0" }}
+22
-6
appview/pages/templates/spindles/dashboard.html
+22
-6
appview/pages/templates/spindles/dashboard.html
···
1
-
{{ define "title" }}{{.Spindle.Instance}} · spindles{{ end }}
1
+
{{ define "title" }}{{.Spindle.Instance}} · {{ .Tab }} settings{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "spindleDash" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "spindleDash" }}
20
+
<div>
5
21
<div class="flex justify-between items-center">
6
-
<h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1>
22
+
<h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} · {{ .Spindle.Instance }}</h2>
7
23
<div id="right-side" class="flex gap-2">
8
24
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
25
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }}
···
71
87
<button
72
88
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
73
89
title="Delete spindle"
74
-
hx-delete="/spindles/{{ .Instance }}"
90
+
hx-delete="/settings/spindles/{{ .Instance }}"
75
91
hx-swap="outerHTML"
76
92
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
77
93
hx-headers='{"shouldRedirect": "true"}'
···
87
103
<button
88
104
class="btn gap-2 group"
89
105
title="Retry spindle verification"
90
-
hx-post="/spindles/{{ .Instance }}/retry"
106
+
hx-post="/settings/spindles/{{ .Instance }}/retry"
91
107
hx-swap="none"
92
108
hx-headers='{"shouldRefresh": "true"}'
93
109
>
···
104
120
<button
105
121
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
106
122
title="Remove member"
107
-
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
123
+
hx-post="/settings/spindles/{{ $root.Spindle.Instance }}/remove"
108
124
hx-swap="none"
109
125
hx-vals='{"member": "{{$member}}" }'
110
126
hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
+17
-11
appview/pages/templates/spindles/fragments/addMemberModal.html
+17
-11
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
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">
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">
17
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
20
22
21
23
{{ define "addSpindleMemberPopover" }}
22
24
<form
23
-
hx-post="/spindles/{{ .Instance }}/add"
25
+
hx-post="/settings/spindles/{{ .Instance }}/add"
24
26
hx-indicator="#spinner"
25
27
hx-swap="none"
26
28
class="flex flex-col gap-2"
···
29
31
ADD MEMBER
30
32
</label>
31
33
<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
-
/>
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>
41
47
<div class="flex gap-2 pt-2">
42
48
<button
43
49
type="button"
+3
-3
appview/pages/templates/spindles/fragments/spindleListing.html
+3
-3
appview/pages/templates/spindles/fragments/spindleListing.html
···
7
7
8
8
{{ define "spindleLeftSide" }}
9
9
{{ if .Verified }}
10
-
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
10
+
<a href="/settings/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
11
{{ i "hard-drive" "w-4 h-4" }}
12
12
<span class="hover:underline">
13
13
{{ .Instance }}
···
50
50
<button
51
51
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
52
52
title="Delete spindle"
53
-
hx-delete="/spindles/{{ .Instance }}"
53
+
hx-delete="/settings/spindles/{{ .Instance }}"
54
54
hx-swap="outerHTML"
55
55
hx-target="#spindle-{{.Id}}"
56
56
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
···
66
66
<button
67
67
class="btn gap-2 group"
68
68
title="Retry spindle verification"
69
-
hx-post="/spindles/{{ .Instance }}/retry"
69
+
hx-post="/settings/spindles/{{ .Instance }}/retry"
70
70
hx-swap="none"
71
71
hx-target="#spindle-{{.Id}}"
72
72
>
+90
-59
appview/pages/templates/spindles/index.html
+90
-59
appview/pages/templates/spindles/index.html
···
1
-
{{ define "title" }}spindles{{ end }}
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
-
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
-
<span class="flex items-center gap-1">
7
-
{{ i "book" "w-3 h-3" }}
8
-
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a>
9
-
</span>
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "spindleList" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "spindleList" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
23
+
{{ block "about" . }} {{ end }}
24
+
</div>
25
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
26
+
{{ template "docsButton" . }}
27
+
</div>
10
28
</div>
11
29
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
30
+
<section>
13
31
<div class="flex flex-col gap-6">
14
-
{{ block "about" . }} {{ end }}
15
32
{{ block "list" . }} {{ end }}
16
33
{{ block "register" . }} {{ end }}
17
34
</div>
···
20
37
21
38
{{ define "about" }}
22
39
<section class="rounded flex items-center gap-2">
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
Spindles are small CI runners.
25
-
</p>
40
+
<p class="text-gray-500 dark:text-gray-400">
41
+
Spindles are small CI runners.
42
+
</p>
26
43
</section>
27
44
{{ end }}
28
45
29
46
{{ define "list" }}
30
-
<section class="rounded w-full flex flex-col gap-2">
31
-
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2>
32
-
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
33
-
{{ range $spindle := .Spindles }}
34
-
{{ template "spindles/fragments/spindleListing" . }}
35
-
{{ else }}
36
-
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
37
-
no spindles registered yet
38
-
</div>
39
-
{{ end }}
47
+
<section class="rounded w-full flex flex-col gap-2">
48
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2>
49
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
50
+
{{ range $spindle := .Spindles }}
51
+
{{ template "spindles/fragments/spindleListing" . }}
52
+
{{ else }}
53
+
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
54
+
no spindles registered yet
40
55
</div>
41
-
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
42
-
</section>
56
+
{{ end }}
57
+
</div>
58
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
59
+
</section>
43
60
{{ end }}
44
61
45
62
{{ define "register" }}
46
-
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
47
-
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2>
48
-
<p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p>
49
-
<form
50
-
hx-post="/spindles/register"
51
-
class="max-w-2xl mb-2 space-y-4"
52
-
hx-indicator="#register-button"
53
-
hx-swap="none"
54
-
>
55
-
<div class="flex gap-2">
56
-
<input
57
-
type="text"
58
-
id="instance"
59
-
name="instance"
60
-
placeholder="spindle.example.com"
61
-
required
62
-
class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
63
-
>
64
-
<button
65
-
type="submit"
66
-
id="register-button"
67
-
class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
68
-
>
69
-
<span class="inline-flex items-center gap-2">
70
-
{{ i "plus" "w-4 h-4" }}
71
-
register
72
-
</span>
73
-
<span class="pl-2 hidden group-[.htmx-request]:inline">
74
-
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
75
-
</span>
76
-
</button>
77
-
</div>
63
+
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
64
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2>
65
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p>
66
+
<form
67
+
hx-post="/settings/spindles/register"
68
+
class="max-w-2xl mb-2 space-y-4"
69
+
hx-indicator="#register-button"
70
+
hx-swap="none"
71
+
>
72
+
<div class="flex gap-2">
73
+
<input
74
+
type="text"
75
+
id="instance"
76
+
name="instance"
77
+
placeholder="spindle.example.com"
78
+
required
79
+
class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
80
+
>
81
+
<button
82
+
type="submit"
83
+
id="register-button"
84
+
class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
85
+
>
86
+
<span class="inline-flex items-center gap-2">
87
+
{{ i "plus" "w-4 h-4" }}
88
+
register
89
+
</span>
90
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
91
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
92
+
</span>
93
+
</button>
94
+
</div>
78
95
79
-
<div id="register-error" class="dark:text-red-400"></div>
80
-
</form>
96
+
<div id="register-error" class="dark:text-red-400"></div>
97
+
</form>
98
+
99
+
</section>
100
+
{{ end }}
81
101
82
-
</section>
102
+
{{ define "docsButton" }}
103
+
<a
104
+
class="btn flex items-center gap-2"
105
+
href="https://docs.tangled.org/spindles.html#self-hosting-guide">
106
+
{{ i "book" "size-4" }}
107
+
docs
108
+
</a>
109
+
<div
110
+
id="add-email-modal"
111
+
popover
112
+
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">
113
+
</div>
83
114
{{ end }}
+6
-5
appview/pages/templates/strings/dashboard.html
+6
-5
appview/pages/templates/strings/dashboard.html
···
1
-
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
1
+
{{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
4
+
{{ $handle := resolve .Card.UserDid }}
5
+
<meta property="og:title" content="{{ $handle }}" />
5
6
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:url" content="https://tangled.org/{{ $handle }}" />
8
+
<meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" />
8
9
{{ end }}
9
10
10
11
···
35
36
{{ $s := index . 1 }}
36
37
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
38
<div class="font-medium dark:text-white flex gap-2 items-center">
38
-
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
+
<a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
40
</div>
40
41
{{ with $s.Description }}
41
42
<div class="text-gray-600 dark:text-gray-300 text-sm">
+13
-9
appview/pages/templates/strings/string.html
+13
-9
appview/pages/templates/strings/string.html
···
1
-
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
1
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ resolve .Owner.DID.String }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
-
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
4
+
{{ $ownerId := resolve .Owner.DID.String }}
5
5
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
6
<meta property="og:type" content="object" />
7
7
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
···
9
9
{{ end }}
10
10
11
11
{{ define "content" }}
12
-
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
12
+
{{ $ownerId := resolve .Owner.DID.String }}
13
13
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
14
14
<div class="text-lg flex items-center justify-between">
15
15
<div>
···
17
17
<span class="select-none">/</span>
18
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
19
</div>
20
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
21
-
<div class="flex gap-2 text-base">
20
+
<div class="flex gap-2 items-stretch text-base">
21
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
23
hx-boost="true"
24
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
37
37
<span class="hidden md:inline">delete</span>
38
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
39
</button>
40
-
</div>
41
-
{{ end }}
40
+
{{ end }}
41
+
{{ template "fragments/starBtn"
42
+
(dict "SubjectAt" .String.AtUri
43
+
"IsStarred" .IsStarred
44
+
"StarCount" .StarCount) }}
45
+
</div>
42
46
</div>
43
47
<span>
44
48
{{ with .String.Description }}
···
75
79
</div>
76
80
<div class="overflow-x-auto overflow-y-hidden relative">
77
81
{{ if .ShowRendered }}
78
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
82
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
79
83
{{ else }}
80
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
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>
81
85
{{ end }}
82
86
</div>
83
87
{{ template "fragments/multiline-select" }}
+1
-2
appview/pages/templates/timeline/fragments/goodfirstissues.html
+1
-2
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
3
3
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
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
5
<div class="flex-1 flex flex-col gap-2">
6
-
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
6
<p>
8
-
Make your first contribution to an open-source project this October.
7
+
Make your first contribution to an open-source project.
9
8
<em>good-first-issue</em> helps new contributors find easy ways to
10
9
start contributing to open-source projects.
11
10
</p>
+2
-2
appview/pages/templates/timeline/fragments/hero.html
+2
-2
appview/pages/templates/timeline/fragments/hero.html
···
4
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
5
6
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>.
7
+
Tangled is a decentralized Git hosting and collaboration platform.
8
8
</p>
9
9
<p class="text-lg">
10
-
we envision a place where developers have complete ownership of their
10
+
We envision a place where developers have complete ownership of their
11
11
code, open source communities can freely self-govern and most
12
12
importantly, coding can be social and fun again.
13
13
</p>
+5
-5
appview/pages/templates/timeline/fragments/timeline.html
+5
-5
appview/pages/templates/timeline/fragments/timeline.html
···
14
14
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
15
15
{{ if .Repo }}
16
16
{{ template "timeline/fragments/repoEvent" (list $ .) }}
17
-
{{ else if .Star }}
17
+
{{ else if .RepoStar }}
18
18
{{ template "timeline/fragments/starEvent" (list $ .) }}
19
19
{{ else if .Follow }}
20
20
{{ template "timeline/fragments/followEvent" (list $ .) }}
···
52
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
53
</div>
54
54
{{ with $repo }}
55
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
55
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
56
56
{{ end }}
57
57
{{ end }}
58
58
59
59
{{ define "timeline/fragments/starEvent" }}
60
60
{{ $root := index . 0 }}
61
61
{{ $event := index . 1 }}
62
-
{{ $star := $event.Star }}
62
+
{{ $star := $event.RepoStar }}
63
63
{{ with $star }}
64
-
{{ $starrerHandle := resolve .StarredByDid }}
64
+
{{ $starrerHandle := resolve .Did }}
65
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
66
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
67
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
72
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
73
</div>
74
74
{{ with .Repo }}
75
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
75
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
76
76
{{ end }}
77
77
{{ end }}
78
78
{{ end }}
+4
-2
appview/pages/templates/user/followers.html
+4
-2
appview/pages/templates/user/followers.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท followers {{ end }}
2
2
3
3
{{ define "profileContent" }}
4
4
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
···
19
19
"FollowersCount" .FollowersCount
20
20
"FollowingCount" .FollowingCount) }}
21
21
{{ else }}
22
-
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
22
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
23
+
<span>This user does not have any followers yet.</span>
24
+
</div>
23
25
{{ end }}
24
26
</div>
25
27
{{ end }}
+4
-2
appview/pages/templates/user/following.html
+4
-2
appview/pages/templates/user/following.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท following {{ end }}
2
2
3
3
{{ define "profileContent" }}
4
4
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
···
19
19
"FollowersCount" .FollowersCount
20
20
"FollowingCount" .FollowingCount) }}
21
21
{{ else }}
22
-
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
22
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
23
+
<span>This user does not follow anyone yet.</span>
24
+
</div>
23
25
{{ end }}
24
26
</div>
25
27
{{ end }}
+7
-1
appview/pages/templates/user/fragments/editBio.html
+7
-1
appview/pages/templates/user/fragments/editBio.html
···
26
26
{{ if and .Profile .Profile.Pronouns }}
27
27
{{ $pronouns = .Profile.Pronouns }}
28
28
{{ end }}
29
-
<input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}">
29
+
<input
30
+
type="text"
31
+
class="py-1 px-1 w-full"
32
+
name="pronouns"
33
+
placeholder="they/them"
34
+
value="{{ $pronouns }}"
35
+
>
30
36
</div>
31
37
</div>
32
38
+2
-2
appview/pages/templates/user/fragments/followCard.html
+2
-2
appview/pages/templates/user/fragments/followCard.html
···
6
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
7
</div>
8
8
9
-
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0">
10
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
11
<a href="/{{ $userIdent }}">
12
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
13
</a>
14
14
{{ with .Profile }}
15
-
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
15
+
<p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p>
16
16
{{ end }}
17
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+15
-7
appview/pages/templates/user/fragments/profileCard.html
+15
-7
appview/pages/templates/user/fragments/profileCard.html
···
1
1
{{ define "user/fragments/profileCard" }}
2
-
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
2
+
{{ $userIdent := resolve .UserDid }}
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
5
<div class="w-3/4 aspect-square relative">
···
17
17
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
18
{{ end }}
19
19
{{ end }}
20
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
21
20
</div>
22
21
23
22
<div class="md:hidden">
···
72
71
{{ end }}
73
72
</div>
74
73
{{ end }}
75
-
{{ if ne .FollowStatus.String "IsSelf" }}
76
-
{{ template "user/fragments/follow" . }}
77
-
{{ else }}
74
+
75
+
<div class="flex mt-2 items-center gap-2">
76
+
{{ if ne .FollowStatus.String "IsSelf" }}
77
+
{{ template "user/fragments/follow" . }}
78
+
{{ else }}
78
79
<button id="editBtn"
79
-
class="btn mt-2 w-full flex items-center gap-2 group"
80
+
class="btn w-full flex items-center gap-2 group"
80
81
hx-target="#profile-bio"
81
82
hx-get="/profile/edit-bio"
82
83
hx-swap="innerHTML">
···
84
85
edit
85
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
86
87
</button>
87
-
{{ end }}
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
+
88
96
</div>
89
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
90
98
</div>
+2
-1
appview/pages/templates/user/fragments/repoCard.html
+2
-1
appview/pages/templates/user/fragments/repoCard.html
···
1
1
{{ define "user/fragments/repoCard" }}
2
+
{{/* root, repo, fullName [,starButton [,starData]] */}}
2
3
{{ $root := index . 0 }}
3
4
{{ $repo := index . 1 }}
4
5
{{ $fullName := index . 2 }}
···
29
30
</div>
30
31
{{ if and $starButton $root.LoggedInUser }}
31
32
<div class="shrink-0">
32
-
{{ template "repo/fragments/repoStar" $starData }}
33
+
{{ template "fragments/starBtn" $starData }}
33
34
</div>
34
35
{{ end }}
35
36
</div>
+22
-4
appview/pages/templates/user/overview.html
+22
-4
appview/pages/templates/user/overview.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "profileContent" }}
4
4
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
···
16
16
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
17
17
<div class="flex flex-col gap-4 relative">
18
18
{{ if .ProfileTimeline.IsEmpty }}
19
-
<p class="dark:text-white">This user does not have any activity yet.</p>
19
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
20
+
<span class="flex items-center gap-2">
21
+
This user does not have any activity yet.
22
+
</span>
23
+
</div>
20
24
{{ end }}
21
25
22
26
{{ with .ProfileTimeline }}
···
33
37
</p>
34
38
35
39
<div class="flex flex-col gap-1">
40
+
{{ block "commits" .Commits }} {{ end }}
36
41
{{ block "repoEvents" .RepoEvents }} {{ end }}
37
42
{{ block "issueEvents" .IssueEvents }} {{ end }}
38
43
{{ block "pullEvents" .PullEvents }} {{ end }}
···
43
48
{{ end }}
44
49
{{ end }}
45
50
</div>
51
+
{{ end }}
52
+
53
+
{{ define "commits" }}
54
+
{{ if . }}
55
+
<div class="flex flex-wrap items-center gap-1">
56
+
{{ i "git-commit-horizontal" "size-5" }}
57
+
created {{ . }} commits
58
+
</div>
59
+
{{ end }}
46
60
{{ end }}
47
61
48
62
{{ define "repoEvents" }}
···
224
238
{{ define "ownRepos" }}
225
239
<div>
226
240
<div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2">
227
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
241
+
<a href="/{{ resolve $.Card.UserDid }}?tab=repos"
228
242
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
229
243
<span>PINNED REPOS</span>
230
244
</a>
···
244
258
{{ template "user/fragments/repoCard" (list $ . false) }}
245
259
</div>
246
260
{{ else }}
247
-
<p class="dark:text-white">This user does not have any pinned repos.</p>
261
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
262
+
<span class="flex items-center gap-2">
263
+
This user does not have any pinned repos.
264
+
</span>
265
+
</div>
248
266
{{ end }}
249
267
</div>
250
268
</div>
+4
-2
appview/pages/templates/user/repos.html
+4
-2
appview/pages/templates/user/repos.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }}
2
2
3
3
{{ define "profileContent" }}
4
4
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
···
13
13
{{ template "user/fragments/repoCard" (list $ . false) }}
14
14
</div>
15
15
{{ else }}
16
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
16
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
17
+
<span>This user does not have any repos yet.</span>
18
+
</div>
17
19
{{ end }}
18
20
</div>
19
21
{{ end }}
+14
appview/pages/templates/user/settings/notifications.html
+14
appview/pages/templates/user/settings/notifications.html
···
144
144
<div class="flex items-center justify-between p-2">
145
145
<div class="flex items-center gap-2">
146
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="user_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">
147
161
<span class="font-bold">Email notifications</span>
148
162
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
163
<span>Receive notifications via email in addition to in-app notifications.</span>
+9
-6
appview/pages/templates/user/signup.html
+9
-6
appview/pages/templates/user/signup.html
···
43
43
page to complete your registration.
44
44
</span>
45
45
<div class="w-full mt-4 text-center">
46
-
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div>
47
47
</div>
48
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
49
<span>join now</span>
50
50
</button>
51
+
<p class="text-sm text-gray-500">
52
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
53
+
</p>
54
+
55
+
<p id="signup-msg" class="error w-full"></p>
56
+
<p class="text-sm text-gray-500 pt-4">
57
+
By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>.
58
+
</p>
51
59
</form>
52
-
<p class="text-sm text-gray-500">
53
-
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
54
-
</p>
55
-
56
-
<p id="signup-msg" class="error w-full"></p>
57
60
</main>
58
61
</body>
59
62
</html>
+4
-2
appview/pages/templates/user/starred.html
+4
-2
appview/pages/templates/user/starred.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }}
2
2
3
3
{{ define "profileContent" }}
4
4
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
···
13
13
{{ template "user/fragments/repoCard" (list $ . true) }}
14
14
</div>
15
15
{{ else }}
16
-
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
16
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
17
+
<span>This user does not have any starred repos yet.</span>
18
+
</div>
17
19
{{ end }}
18
20
</div>
19
21
{{ end }}
+5
-3
appview/pages/templates/user/strings.html
+5
-3
appview/pages/templates/user/strings.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท strings {{ end }}
2
2
3
3
{{ define "profileContent" }}
4
4
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
···
13
13
{{ template "singleString" (list $ .) }}
14
14
</div>
15
15
{{ else }}
16
-
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
16
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
17
+
<span>This user does not have any strings yet.</span>
18
+
</div>
17
19
{{ end }}
18
20
</div>
19
21
{{ end }}
···
23
25
{{ $s := index . 1 }}
24
26
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
25
27
<div class="font-medium dark:text-white flex gap-2 items-center">
26
-
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
28
+
<a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
27
29
</div>
28
30
{{ with $s.Description }}
29
31
<div class="text-gray-600 dark:text-gray-300 text-sm">
+44
-35
appview/pipelines/pipelines.go
+44
-35
appview/pipelines/pipelines.go
···
16
16
"tangled.org/core/appview/reporesolver"
17
17
"tangled.org/core/eventconsumer"
18
18
"tangled.org/core/idresolver"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/rbac"
20
21
spindlemodel "tangled.org/core/spindle/models"
21
22
···
78
79
return
79
80
}
80
81
81
-
repoInfo := f.RepoInfo(user)
82
-
83
82
ps, err := db.GetPipelineStatuses(
84
83
p.db,
85
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
86
-
db.FilterEq("repo_name", repoInfo.Name),
87
-
db.FilterEq("knot", repoInfo.Knot),
84
+
30,
85
+
orm.FilterEq("repo_owner", f.Did),
86
+
orm.FilterEq("repo_name", f.Name),
87
+
orm.FilterEq("knot", f.Knot),
88
88
)
89
89
if err != nil {
90
90
l.Error("failed to query db", "err", err)
···
93
93
94
94
p.pages.Pipelines(w, pages.PipelinesParams{
95
95
LoggedInUser: user,
96
-
RepoInfo: repoInfo,
96
+
RepoInfo: p.repoResolver.GetRepoInfo(r, user),
97
97
Pipelines: ps,
98
98
})
99
99
}
···
107
107
l.Error("failed to get repo and knot", "err", err)
108
108
return
109
109
}
110
-
111
-
repoInfo := f.RepoInfo(user)
112
110
113
111
pipelineId := chi.URLParam(r, "pipeline")
114
112
if pipelineId == "" {
···
124
122
125
123
ps, err := db.GetPipelineStatuses(
126
124
p.db,
127
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
128
-
db.FilterEq("repo_name", repoInfo.Name),
129
-
db.FilterEq("knot", repoInfo.Knot),
130
-
db.FilterEq("id", pipelineId),
125
+
1,
126
+
orm.FilterEq("repo_owner", f.Did),
127
+
orm.FilterEq("repo_name", f.Name),
128
+
orm.FilterEq("knot", f.Knot),
129
+
orm.FilterEq("id", pipelineId),
131
130
)
132
131
if err != nil {
133
132
l.Error("failed to query db", "err", err)
···
143
142
144
143
p.pages.Workflow(w, pages.WorkflowParams{
145
144
LoggedInUser: user,
146
-
RepoInfo: repoInfo,
145
+
RepoInfo: p.repoResolver.GetRepoInfo(r, user),
147
146
Pipeline: singlePipeline,
148
147
Workflow: workflow,
149
148
})
···
174
173
ctx, cancel := context.WithCancel(r.Context())
175
174
defer cancel()
176
175
177
-
user := p.oauth.GetUser(r)
178
176
f, err := p.repoResolver.Resolve(r)
179
177
if err != nil {
180
178
l.Error("failed to get repo and knot", "err", err)
181
179
http.Error(w, "bad repo/knot", http.StatusBadRequest)
182
180
return
183
181
}
184
-
185
-
repoInfo := f.RepoInfo(user)
186
182
187
183
pipelineId := chi.URLParam(r, "pipeline")
188
184
workflow := chi.URLParam(r, "workflow")
···
193
189
194
190
ps, err := db.GetPipelineStatuses(
195
191
p.db,
196
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
197
-
db.FilterEq("repo_name", repoInfo.Name),
198
-
db.FilterEq("knot", repoInfo.Knot),
199
-
db.FilterEq("id", pipelineId),
192
+
1,
193
+
orm.FilterEq("repo_owner", f.Did),
194
+
orm.FilterEq("repo_name", f.Name),
195
+
orm.FilterEq("knot", f.Knot),
196
+
orm.FilterEq("id", pipelineId),
200
197
)
201
198
if err != nil || len(ps) != 1 {
202
199
l.Error("pipeline query failed", "err", err, "count", len(ps))
···
205
202
}
206
203
207
204
singlePipeline := ps[0]
208
-
spindle := repoInfo.Spindle
209
-
knot := repoInfo.Knot
205
+
spindle := f.Spindle
206
+
knot := f.Knot
210
207
rkey := singlePipeline.Rkey
211
208
212
209
if spindle == "" || knot == "" || rkey == "" {
···
236
233
// start a goroutine to read from spindle
237
234
go readLogs(spindleConn, evChan)
238
235
239
-
stepIdx := 0
236
+
stepStartTimes := make(map[int]time.Time)
240
237
var fragment bytes.Buffer
241
238
for {
242
239
select {
···
268
265
269
266
switch logLine.Kind {
270
267
case spindlemodel.LogKindControl:
271
-
// control messages create a new step block
272
-
stepIdx++
273
-
collapsed := false
274
-
if logLine.StepKind == spindlemodel.StepKindSystem {
275
-
collapsed = true
268
+
switch logLine.StepStatus {
269
+
case spindlemodel.StepStatusStart:
270
+
stepStartTimes[logLine.StepId] = logLine.Time
271
+
collapsed := false
272
+
if logLine.StepKind == spindlemodel.StepKindSystem {
273
+
collapsed = true
274
+
}
275
+
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
276
+
Id: logLine.StepId,
277
+
Name: logLine.Content,
278
+
Command: logLine.StepCommand,
279
+
Collapsed: collapsed,
280
+
StartTime: logLine.Time,
281
+
})
282
+
case spindlemodel.StepStatusEnd:
283
+
startTime := stepStartTimes[logLine.StepId]
284
+
endTime := logLine.Time
285
+
err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{
286
+
Id: logLine.StepId,
287
+
StartTime: startTime,
288
+
EndTime: endTime,
289
+
})
276
290
}
277
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
278
-
Id: stepIdx,
279
-
Name: logLine.Content,
280
-
Command: logLine.StepCommand,
281
-
Collapsed: collapsed,
282
-
})
291
+
283
292
case spindlemodel.LogKindData:
284
293
// data messages simply insert new log lines into current step
285
294
err = p.pages.LogLine(&fragment, pages.LogLineParams{
286
-
Id: stepIdx,
295
+
Id: logLine.StepId,
287
296
Content: logLine.Content,
288
297
})
289
298
}
+10
-9
appview/pulls/opengraph.go
+10
-9
appview/pulls/opengraph.go
···
13
13
"tangled.org/core/appview/db"
14
14
"tangled.org/core/appview/models"
15
15
"tangled.org/core/appview/ogcard"
16
+
"tangled.org/core/orm"
16
17
"tangled.org/core/patchutil"
17
18
"tangled.org/core/types"
18
19
)
···
146
147
var statusColor color.RGBA
147
148
148
149
if pull.State.IsOpen() {
149
-
statusIcon = "static/icons/git-pull-request.svg"
150
+
statusIcon = "git-pull-request"
150
151
statusText = "open"
151
152
statusColor = color.RGBA{34, 139, 34, 255} // green
152
153
} else if pull.State.IsMerged() {
153
-
statusIcon = "static/icons/git-merge.svg"
154
+
statusIcon = "git-merge"
154
155
statusText = "merged"
155
156
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
157
} else {
157
-
statusIcon = "static/icons/git-pull-request-closed.svg"
158
+
statusIcon = "git-pull-request-closed"
158
159
statusText = "closed"
159
160
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
161
}
···
162
163
statusIconSize := 36
163
164
164
165
// Draw icon with status color
165
-
err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
+
err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
167
if err != nil {
167
168
log.Printf("failed to draw status icon: %v", err)
168
169
}
···
179
180
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
181
181
182
// Draw comment count
182
-
err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
+
err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
184
if err != nil {
184
185
log.Printf("failed to draw comment icon: %v", err)
185
186
}
···
198
199
currentX += commentTextWidth + 40
199
200
200
201
// Draw files changed
201
-
err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
+
err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
203
if err != nil {
203
204
log.Printf("failed to draw file diff icon: %v", err)
204
205
}
···
241
242
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
243
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
244
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
245
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
245
246
if err != nil {
246
247
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
248
}
···
276
277
}
277
278
278
279
// Get comment count from database
279
-
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
+
comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID))
280
281
if err != nil {
281
282
log.Printf("failed to get pull comments: %v", err)
282
283
}
···
293
294
filesChanged = niceDiff.Stat.FilesChanged
294
295
}
295
296
296
-
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297
+
card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged)
297
298
if err != nil {
298
299
log.Println("failed to draw pull summary card", err)
299
300
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+206
-176
appview/pulls/pulls.go
+206
-176
appview/pulls/pulls.go
···
1
1
package pulls
2
2
3
3
import (
4
+
"context"
4
5
"database/sql"
5
6
"encoding/json"
6
7
"errors"
···
18
19
"tangled.org/core/appview/config"
19
20
"tangled.org/core/appview/db"
20
21
pulls_indexer "tangled.org/core/appview/indexer/pulls"
22
+
"tangled.org/core/appview/mentions"
21
23
"tangled.org/core/appview/models"
22
24
"tangled.org/core/appview/notify"
23
25
"tangled.org/core/appview/oauth"
24
26
"tangled.org/core/appview/pages"
25
27
"tangled.org/core/appview/pages/markup"
28
+
"tangled.org/core/appview/pages/repoinfo"
26
29
"tangled.org/core/appview/reporesolver"
27
30
"tangled.org/core/appview/validator"
28
31
"tangled.org/core/appview/xrpcclient"
29
32
"tangled.org/core/idresolver"
33
+
"tangled.org/core/orm"
30
34
"tangled.org/core/patchutil"
31
35
"tangled.org/core/rbac"
32
36
"tangled.org/core/tid"
33
37
"tangled.org/core/types"
34
38
35
39
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
+
"github.com/bluesky-social/indigo/atproto/syntax"
36
41
lexutil "github.com/bluesky-social/indigo/lex/util"
37
42
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
38
43
"github.com/go-chi/chi/v5"
···
40
45
)
41
46
42
47
type Pulls struct {
43
-
oauth *oauth.OAuth
44
-
repoResolver *reporesolver.RepoResolver
45
-
pages *pages.Pages
46
-
idResolver *idresolver.Resolver
47
-
db *db.DB
48
-
config *config.Config
49
-
notifier notify.Notifier
50
-
enforcer *rbac.Enforcer
51
-
logger *slog.Logger
52
-
validator *validator.Validator
53
-
indexer *pulls_indexer.Indexer
48
+
oauth *oauth.OAuth
49
+
repoResolver *reporesolver.RepoResolver
50
+
pages *pages.Pages
51
+
idResolver *idresolver.Resolver
52
+
mentionsResolver *mentions.Resolver
53
+
db *db.DB
54
+
config *config.Config
55
+
notifier notify.Notifier
56
+
enforcer *rbac.Enforcer
57
+
logger *slog.Logger
58
+
validator *validator.Validator
59
+
indexer *pulls_indexer.Indexer
54
60
}
55
61
56
62
func New(
···
58
64
repoResolver *reporesolver.RepoResolver,
59
65
pages *pages.Pages,
60
66
resolver *idresolver.Resolver,
67
+
mentionsResolver *mentions.Resolver,
61
68
db *db.DB,
62
69
config *config.Config,
63
70
notifier notify.Notifier,
···
67
74
logger *slog.Logger,
68
75
) *Pulls {
69
76
return &Pulls{
70
-
oauth: oauth,
71
-
repoResolver: repoResolver,
72
-
pages: pages,
73
-
idResolver: resolver,
74
-
db: db,
75
-
config: config,
76
-
notifier: notifier,
77
-
enforcer: enforcer,
78
-
logger: logger,
79
-
validator: validator,
80
-
indexer: indexer,
77
+
oauth: oauth,
78
+
repoResolver: repoResolver,
79
+
pages: pages,
80
+
idResolver: resolver,
81
+
mentionsResolver: mentionsResolver,
82
+
db: db,
83
+
config: config,
84
+
notifier: notifier,
85
+
enforcer: enforcer,
86
+
logger: logger,
87
+
validator: validator,
88
+
indexer: indexer,
81
89
}
82
90
}
83
91
···
122
130
123
131
s.pages.PullActionsFragment(w, pages.PullActionsParams{
124
132
LoggedInUser: user,
125
-
RepoInfo: f.RepoInfo(user),
133
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
126
134
Pull: pull,
127
135
RoundNumber: roundNumber,
128
136
MergeCheck: mergeCheckResponse,
···
149
157
return
150
158
}
151
159
160
+
backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
161
+
if err != nil {
162
+
log.Println("failed to get pull backlinks", err)
163
+
s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
164
+
return
165
+
}
166
+
152
167
// can be nil if this pull is not stacked
153
168
stack, _ := r.Context().Value("stack").(models.Stack)
154
169
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
···
159
174
if user != nil && user.Did == pull.OwnerDid {
160
175
resubmitResult = s.resubmitCheck(r, f, pull, stack)
161
176
}
162
-
163
-
repoInfo := f.RepoInfo(user)
164
177
165
178
m := make(map[string]models.Pipeline)
166
179
···
177
190
178
191
ps, err := db.GetPipelineStatuses(
179
192
s.db,
180
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
181
-
db.FilterEq("repo_name", repoInfo.Name),
182
-
db.FilterEq("knot", repoInfo.Knot),
183
-
db.FilterIn("sha", shas),
193
+
len(shas),
194
+
orm.FilterEq("repo_owner", f.Did),
195
+
orm.FilterEq("repo_name", f.Name),
196
+
orm.FilterEq("knot", f.Knot),
197
+
orm.FilterIn("sha", shas),
184
198
)
185
199
if err != nil {
186
200
log.Printf("failed to fetch pipeline statuses: %s", err)
···
191
205
m[p.Sha] = p
192
206
}
193
207
194
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
208
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
195
209
if err != nil {
196
210
log.Println("failed to get pull reactions")
197
211
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
199
213
200
214
userReactions := map[models.ReactionKind]bool{}
201
215
if user != nil {
202
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
216
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
203
217
}
204
218
205
219
labelDefs, err := db.GetLabelDefinitions(
206
220
s.db,
207
-
db.FilterIn("at_uri", f.Repo.Labels),
208
-
db.FilterContains("scope", tangled.RepoPullNSID),
221
+
orm.FilterIn("at_uri", f.Labels),
222
+
orm.FilterContains("scope", tangled.RepoPullNSID),
209
223
)
210
224
if err != nil {
211
225
log.Println("failed to fetch labels", err)
···
220
234
221
235
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
222
236
LoggedInUser: user,
223
-
RepoInfo: repoInfo,
237
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
224
238
Pull: pull,
225
239
Stack: stack,
226
240
AbandonedPulls: abandonedPulls,
241
+
Backlinks: backlinks,
227
242
BranchDeleteStatus: branchDeleteStatus,
228
243
MergeCheck: mergeCheckResponse,
229
244
ResubmitCheck: resubmitResult,
···
237
252
})
238
253
}
239
254
240
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
255
+
func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
241
256
if pull.State == models.PullMerged {
242
257
return types.MergeCheckResponse{}
243
258
}
···
266
281
r.Context(),
267
282
&xrpcc,
268
283
&tangled.RepoMergeCheck_Input{
269
-
Did: f.OwnerDid(),
284
+
Did: f.Did,
270
285
Name: f.Name,
271
286
Branch: pull.TargetBranch,
272
287
Patch: patch,
···
304
319
return result
305
320
}
306
321
307
-
func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
322
+
func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
308
323
if pull.State != models.PullMerged {
309
324
return nil
310
325
}
···
315
330
}
316
331
317
332
var branch string
318
-
var repo *models.Repo
319
333
// check if the branch exists
320
334
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
321
335
if pull.IsBranchBased() {
322
336
branch = pull.PullSource.Branch
323
-
repo = &f.Repo
324
337
} else if pull.IsForkBased() {
325
338
branch = pull.PullSource.Branch
326
339
repo = pull.PullSource.Repo
···
359
372
}
360
373
}
361
374
362
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
375
+
func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
363
376
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
364
377
return pages.Unknown
365
378
}
···
379
392
repoName = sourceRepo.Name
380
393
} else {
381
394
// pulls within the same repo
382
-
knot = f.Knot
383
-
ownerDid = f.OwnerDid()
384
-
repoName = f.Name
395
+
knot = repo.Knot
396
+
ownerDid = repo.Did
397
+
repoName = repo.Name
385
398
}
386
399
387
400
scheme := "http"
···
393
406
Host: host,
394
407
}
395
408
396
-
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
397
-
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
409
+
didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName)
410
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName)
398
411
if err != nil {
399
412
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
400
413
log.Println("failed to call XRPC repo.branches", xrpcerr)
···
422
435
423
436
func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
424
437
user := s.oauth.GetUser(r)
425
-
f, err := s.repoResolver.Resolve(r)
426
-
if err != nil {
427
-
log.Println("failed to get repo and knot", err)
428
-
return
429
-
}
430
438
431
439
var diffOpts types.DiffOpts
432
440
if d := r.URL.Query().Get("diff"); d == "split" {
···
455
463
456
464
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
457
465
LoggedInUser: user,
458
-
RepoInfo: f.RepoInfo(user),
466
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
459
467
Pull: pull,
460
468
Stack: stack,
461
469
Round: roundIdInt,
···
468
476
469
477
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
470
478
user := s.oauth.GetUser(r)
471
-
472
-
f, err := s.repoResolver.Resolve(r)
473
-
if err != nil {
474
-
log.Println("failed to get repo and knot", err)
475
-
return
476
-
}
477
479
478
480
var diffOpts types.DiffOpts
479
481
if d := r.URL.Query().Get("diff"); d == "split" {
···
519
521
520
522
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
521
523
LoggedInUser: s.oauth.GetUser(r),
522
-
RepoInfo: f.RepoInfo(user),
524
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
523
525
Pull: pull,
524
526
Round: roundIdInt,
525
527
Interdiff: interdiff,
···
596
598
597
599
pulls, err := db.GetPulls(
598
600
s.db,
599
-
db.FilterIn("id", ids),
601
+
orm.FilterIn("id", ids),
600
602
)
601
603
if err != nil {
602
604
log.Println("failed to get pulls", err)
···
644
646
}
645
647
pulls = pulls[:n]
646
648
647
-
repoInfo := f.RepoInfo(user)
648
649
ps, err := db.GetPipelineStatuses(
649
650
s.db,
650
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
651
-
db.FilterEq("repo_name", repoInfo.Name),
652
-
db.FilterEq("knot", repoInfo.Knot),
653
-
db.FilterIn("sha", shas),
651
+
len(shas),
652
+
orm.FilterEq("repo_owner", f.Did),
653
+
orm.FilterEq("repo_name", f.Name),
654
+
orm.FilterEq("knot", f.Knot),
655
+
orm.FilterIn("sha", shas),
654
656
)
655
657
if err != nil {
656
658
log.Printf("failed to fetch pipeline statuses: %s", err)
···
663
665
664
666
labelDefs, err := db.GetLabelDefinitions(
665
667
s.db,
666
-
db.FilterIn("at_uri", f.Repo.Labels),
667
-
db.FilterContains("scope", tangled.RepoPullNSID),
668
+
orm.FilterIn("at_uri", f.Labels),
669
+
orm.FilterContains("scope", tangled.RepoPullNSID),
668
670
)
669
671
if err != nil {
670
672
log.Println("failed to fetch labels", err)
···
679
681
680
682
s.pages.RepoPulls(w, pages.RepoPullsParams{
681
683
LoggedInUser: s.oauth.GetUser(r),
682
-
RepoInfo: f.RepoInfo(user),
684
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
683
685
Pulls: pulls,
684
686
LabelDefs: defs,
685
687
FilteringBy: state,
···
716
718
case http.MethodGet:
717
719
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
718
720
LoggedInUser: user,
719
-
RepoInfo: f.RepoInfo(user),
721
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
720
722
Pull: pull,
721
723
RoundNumber: roundNumber,
722
724
})
···
728
730
return
729
731
}
730
732
733
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
734
+
731
735
// Start a transaction
732
736
tx, err := s.db.BeginTx(r.Context(), nil)
733
737
if err != nil {
···
751
755
Rkey: tid.TID(),
752
756
Record: &lexutil.LexiconTypeDecoder{
753
757
Val: &tangled.RepoPullComment{
754
-
Pull: pull.PullAt().String(),
758
+
Pull: pull.AtUri().String(),
755
759
Body: body,
756
760
CreatedAt: createdAt,
757
761
},
···
770
774
Body: body,
771
775
CommentAt: atResp.Uri,
772
776
SubmissionId: pull.Submissions[roundNumber].ID,
777
+
Mentions: mentions,
778
+
References: references,
773
779
}
774
780
775
781
// Create the pull comment in the database with the commentAt field
···
787
793
return
788
794
}
789
795
790
-
s.notifier.NewPullComment(r.Context(), comment)
796
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
791
797
792
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
798
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
799
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
793
800
return
794
801
}
795
802
}
···
813
820
Host: host,
814
821
}
815
822
816
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
823
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
817
824
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
818
825
if err != nil {
819
826
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
840
847
841
848
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
842
849
LoggedInUser: user,
843
-
RepoInfo: f.RepoInfo(user),
850
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
844
851
Branches: result.Branches,
845
852
Strategy: strategy,
846
853
SourceBranch: sourceBranch,
···
863
870
}
864
871
865
872
// Determine PR type based on input parameters
866
-
isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed()
873
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
874
+
isPushAllowed := roles.IsPushAllowed()
867
875
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
868
876
isForkBased := fromFork != "" && sourceBranch != ""
869
877
isPatchBased := patch != "" && !isBranchBased && !isForkBased
···
961
969
func (s *Pulls) handleBranchBasedPull(
962
970
w http.ResponseWriter,
963
971
r *http.Request,
964
-
f *reporesolver.ResolvedRepo,
972
+
repo *models.Repo,
965
973
user *oauth.User,
966
974
title,
967
975
body,
···
973
981
if !s.config.Core.Dev {
974
982
scheme = "https"
975
983
}
976
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
984
+
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
977
985
xrpcc := &indigoxrpc.Client{
978
986
Host: host,
979
987
}
980
988
981
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
982
-
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch)
989
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
990
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch)
983
991
if err != nil {
984
992
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
985
993
log.Println("failed to call XRPC repo.compare", xrpcerr)
···
1016
1024
Sha: comparison.Rev2,
1017
1025
}
1018
1026
1019
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1027
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1020
1028
}
1021
1029
1022
-
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1030
+
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1023
1031
if err := s.validator.ValidatePatch(&patch); err != nil {
1024
1032
s.logger.Error("patch validation failed", "err", err)
1025
1033
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1026
1034
return
1027
1035
}
1028
1036
1029
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1037
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1030
1038
}
1031
1039
1032
-
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
1040
+
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
1033
1041
repoString := strings.SplitN(forkRepo, "/", 2)
1034
1042
forkOwnerDid := repoString[0]
1035
1043
repoName := repoString[1]
···
1131
1139
Sha: sourceRev,
1132
1140
}
1133
1141
1134
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1142
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1135
1143
}
1136
1144
1137
1145
func (s *Pulls) createPullRequest(
1138
1146
w http.ResponseWriter,
1139
1147
r *http.Request,
1140
-
f *reporesolver.ResolvedRepo,
1148
+
repo *models.Repo,
1141
1149
user *oauth.User,
1142
1150
title, body, targetBranch string,
1143
1151
patch string,
···
1152
1160
s.createStackedPullRequest(
1153
1161
w,
1154
1162
r,
1155
-
f,
1163
+
repo,
1156
1164
user,
1157
1165
targetBranch,
1158
1166
patch,
···
1197
1205
body = formatPatches[0].Body
1198
1206
}
1199
1207
}
1208
+
1209
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
1200
1210
1201
1211
rkey := tid.TID()
1202
1212
initialSubmission := models.PullSubmission{
···
1209
1219
Body: body,
1210
1220
TargetBranch: targetBranch,
1211
1221
OwnerDid: user.Did,
1212
-
RepoAt: f.RepoAt(),
1222
+
RepoAt: repo.RepoAt(),
1213
1223
Rkey: rkey,
1224
+
Mentions: mentions,
1225
+
References: references,
1214
1226
Submissions: []*models.PullSubmission{
1215
1227
&initialSubmission,
1216
1228
},
···
1222
1234
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1223
1235
return
1224
1236
}
1225
-
pullId, err := db.NextPullId(tx, f.RepoAt())
1237
+
pullId, err := db.NextPullId(tx, repo.RepoAt())
1226
1238
if err != nil {
1227
1239
log.Println("failed to get pull id", err)
1228
1240
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1229
1241
return
1230
1242
}
1231
1243
1244
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1245
+
if err != nil {
1246
+
log.Println("failed to upload patch", err)
1247
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1248
+
return
1249
+
}
1250
+
1232
1251
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1233
1252
Collection: tangled.RepoPullNSID,
1234
1253
Repo: user.Did,
···
1237
1256
Val: &tangled.RepoPull{
1238
1257
Title: title,
1239
1258
Target: &tangled.RepoPull_Target{
1240
-
Repo: string(f.RepoAt()),
1259
+
Repo: string(repo.RepoAt()),
1241
1260
Branch: targetBranch,
1242
1261
},
1243
-
Patch: patch,
1262
+
PatchBlob: blob.Blob,
1244
1263
Source: recordPullSource,
1245
1264
CreatedAt: time.Now().Format(time.RFC3339),
1246
1265
},
···
1260
1279
1261
1280
s.notifier.NewPull(r.Context(), pull)
1262
1281
1263
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1282
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1283
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
1264
1284
}
1265
1285
1266
1286
func (s *Pulls) createStackedPullRequest(
1267
1287
w http.ResponseWriter,
1268
1288
r *http.Request,
1269
-
f *reporesolver.ResolvedRepo,
1289
+
repo *models.Repo,
1270
1290
user *oauth.User,
1271
1291
targetBranch string,
1272
1292
patch string,
···
1298
1318
1299
1319
// build a stack out of this patch
1300
1320
stackId := uuid.New()
1301
-
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1321
+
stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String())
1302
1322
if err != nil {
1303
1323
log.Println("failed to create stack", err)
1304
1324
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
···
1315
1335
// apply all record creations at once
1316
1336
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1317
1337
for _, p := range stack {
1338
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1339
+
if err != nil {
1340
+
log.Println("failed to upload patch blob", err)
1341
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1342
+
return
1343
+
}
1344
+
1318
1345
record := p.AsRecord()
1319
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1346
+
record.PatchBlob = blob.Blob
1347
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1320
1348
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1321
1349
Collection: tangled.RepoPullNSID,
1322
1350
Rkey: &p.Rkey,
···
1324
1352
Val: &record,
1325
1353
},
1326
1354
},
1327
-
}
1328
-
writes = append(writes, &write)
1355
+
})
1329
1356
}
1330
1357
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1331
1358
Repo: user.Did,
···
1353
1380
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1354
1381
return
1355
1382
}
1383
+
1356
1384
}
1357
1385
1358
1386
if err = tx.Commit(); err != nil {
···
1361
1389
return
1362
1390
}
1363
1391
1364
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1392
+
// notify about each pull
1393
+
//
1394
+
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1395
+
for _, p := range stack {
1396
+
s.notifier.NewPull(r.Context(), p)
1397
+
}
1398
+
1399
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1400
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1365
1401
}
1366
1402
1367
1403
func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
···
1392
1428
1393
1429
func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1394
1430
user := s.oauth.GetUser(r)
1395
-
f, err := s.repoResolver.Resolve(r)
1396
-
if err != nil {
1397
-
log.Println("failed to get repo and knot", err)
1398
-
return
1399
-
}
1400
1431
1401
1432
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1402
-
RepoInfo: f.RepoInfo(user),
1433
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1403
1434
})
1404
1435
}
1405
1436
···
1420
1451
Host: host,
1421
1452
}
1422
1453
1423
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1454
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1424
1455
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1425
1456
if err != nil {
1426
1457
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1453
1484
}
1454
1485
1455
1486
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1456
-
RepoInfo: f.RepoInfo(user),
1487
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1457
1488
Branches: withoutDefault,
1458
1489
})
1459
1490
}
1460
1491
1461
1492
func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1462
1493
user := s.oauth.GetUser(r)
1463
-
f, err := s.repoResolver.Resolve(r)
1464
-
if err != nil {
1465
-
log.Println("failed to get repo and knot", err)
1466
-
return
1467
-
}
1468
1494
1469
1495
forks, err := db.GetForksByDid(s.db, user.Did)
1470
1496
if err != nil {
···
1473
1499
}
1474
1500
1475
1501
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1476
-
RepoInfo: f.RepoInfo(user),
1502
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1477
1503
Forks: forks,
1478
1504
Selected: r.URL.Query().Get("fork"),
1479
1505
})
···
1495
1521
// fork repo
1496
1522
repo, err := db.GetRepo(
1497
1523
s.db,
1498
-
db.FilterEq("did", forkOwnerDid),
1499
-
db.FilterEq("name", forkName),
1524
+
orm.FilterEq("did", forkOwnerDid),
1525
+
orm.FilterEq("name", forkName),
1500
1526
)
1501
1527
if err != nil {
1502
1528
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
···
1541
1567
Host: targetHost,
1542
1568
}
1543
1569
1544
-
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1570
+
targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1545
1571
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1546
1572
if err != nil {
1547
1573
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1566
1592
})
1567
1593
1568
1594
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1569
-
RepoInfo: f.RepoInfo(user),
1595
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1570
1596
SourceBranches: sourceBranches.Branches,
1571
1597
TargetBranches: targetBranches.Branches,
1572
1598
})
···
1574
1600
1575
1601
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1576
1602
user := s.oauth.GetUser(r)
1577
-
f, err := s.repoResolver.Resolve(r)
1578
-
if err != nil {
1579
-
log.Println("failed to get repo and knot", err)
1580
-
return
1581
-
}
1582
1603
1583
1604
pull, ok := r.Context().Value("pull").(*models.Pull)
1584
1605
if !ok {
···
1590
1611
switch r.Method {
1591
1612
case http.MethodGet:
1592
1613
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1593
-
RepoInfo: f.RepoInfo(user),
1614
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1594
1615
Pull: pull,
1595
1616
})
1596
1617
return
···
1657
1678
return
1658
1679
}
1659
1680
1660
-
if !f.RepoInfo(user).Roles.IsPushAllowed() {
1681
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
1682
+
if !roles.IsPushAllowed() {
1661
1683
log.Println("unauthorized user")
1662
1684
w.WriteHeader(http.StatusUnauthorized)
1663
1685
return
···
1672
1694
Host: host,
1673
1695
}
1674
1696
1675
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1697
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1676
1698
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1677
1699
if err != nil {
1678
1700
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1799
1821
func (s *Pulls) resubmitPullHelper(
1800
1822
w http.ResponseWriter,
1801
1823
r *http.Request,
1802
-
f *reporesolver.ResolvedRepo,
1824
+
repo *models.Repo,
1803
1825
user *oauth.User,
1804
1826
pull *models.Pull,
1805
1827
patch string,
···
1808
1830
) {
1809
1831
if pull.IsStacked() {
1810
1832
log.Println("resubmitting stacked PR")
1811
-
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1833
+
s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId)
1812
1834
return
1813
1835
}
1814
1836
···
1838
1860
}
1839
1861
defer tx.Rollback()
1840
1862
1841
-
pullAt := pull.PullAt()
1863
+
pullAt := pull.AtUri()
1842
1864
newRoundNumber := len(pull.Submissions)
1843
1865
newPatch := patch
1844
1866
newSourceRev := sourceRev
···
1863
1885
return
1864
1886
}
1865
1887
1866
-
var recordPullSource *tangled.RepoPull_Source
1867
-
if pull.IsBranchBased() {
1868
-
recordPullSource = &tangled.RepoPull_Source{
1869
-
Branch: pull.PullSource.Branch,
1870
-
Sha: sourceRev,
1871
-
}
1888
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1889
+
if err != nil {
1890
+
log.Println("failed to upload patch blob", err)
1891
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1892
+
return
1872
1893
}
1873
-
if pull.IsForkBased() {
1874
-
repoAt := pull.PullSource.RepoAt.String()
1875
-
recordPullSource = &tangled.RepoPull_Source{
1876
-
Branch: pull.PullSource.Branch,
1877
-
Repo: &repoAt,
1878
-
Sha: sourceRev,
1879
-
}
1880
-
}
1894
+
record := pull.AsRecord()
1895
+
record.PatchBlob = blob.Blob
1896
+
record.CreatedAt = time.Now().Format(time.RFC3339)
1881
1897
1882
1898
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1883
1899
Collection: tangled.RepoPullNSID,
···
1885
1901
Rkey: pull.Rkey,
1886
1902
SwapRecord: ex.Cid,
1887
1903
Record: &lexutil.LexiconTypeDecoder{
1888
-
Val: &tangled.RepoPull{
1889
-
Title: pull.Title,
1890
-
Target: &tangled.RepoPull_Target{
1891
-
Repo: string(f.RepoAt()),
1892
-
Branch: pull.TargetBranch,
1893
-
},
1894
-
Patch: patch, // new patch
1895
-
Source: recordPullSource,
1896
-
CreatedAt: time.Now().Format(time.RFC3339),
1897
-
},
1904
+
Val: &record,
1898
1905
},
1899
1906
})
1900
1907
if err != nil {
···
1909
1916
return
1910
1917
}
1911
1918
1912
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1919
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1920
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1913
1921
}
1914
1922
1915
1923
func (s *Pulls) resubmitStackedPullHelper(
1916
1924
w http.ResponseWriter,
1917
1925
r *http.Request,
1918
-
f *reporesolver.ResolvedRepo,
1926
+
repo *models.Repo,
1919
1927
user *oauth.User,
1920
1928
pull *models.Pull,
1921
1929
patch string,
···
1924
1932
targetBranch := pull.TargetBranch
1925
1933
1926
1934
origStack, _ := r.Context().Value("stack").(models.Stack)
1927
-
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1935
+
newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId)
1928
1936
if err != nil {
1929
1937
log.Println("failed to create resubmitted stack", err)
1930
1938
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1979
1987
}
1980
1988
defer tx.Rollback()
1981
1989
1990
+
client, err := s.oauth.AuthorizedClient(r)
1991
+
if err != nil {
1992
+
log.Println("failed to authorize client")
1993
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1994
+
return
1995
+
}
1996
+
1982
1997
// pds updates to make
1983
1998
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1984
1999
···
2012
2027
return
2013
2028
}
2014
2029
2030
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2031
+
if err != nil {
2032
+
log.Println("failed to upload patch blob", err)
2033
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2034
+
return
2035
+
}
2015
2036
record := p.AsRecord()
2037
+
record.PatchBlob = blob.Blob
2016
2038
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2017
2039
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2018
2040
Collection: tangled.RepoPullNSID,
···
2035
2057
}
2036
2058
2037
2059
// resubmit the new pull
2038
-
pullAt := op.PullAt()
2060
+
pullAt := op.AtUri()
2039
2061
newRoundNumber := len(op.Submissions)
2040
2062
newPatch := np.LatestPatch()
2041
2063
combinedPatch := np.LatestSubmission().Combined
···
2047
2069
return
2048
2070
}
2049
2071
2072
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2073
+
if err != nil {
2074
+
log.Println("failed to upload patch blob", err)
2075
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2076
+
return
2077
+
}
2050
2078
record := np.AsRecord()
2051
-
2079
+
record.PatchBlob = blob.Blob
2052
2080
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2053
2081
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2054
2082
Collection: tangled.RepoPullNSID,
···
2066
2094
tx,
2067
2095
p.ParentChangeId,
2068
2096
// these should be enough filters to be unique per-stack
2069
-
db.FilterEq("repo_at", p.RepoAt.String()),
2070
-
db.FilterEq("owner_did", p.OwnerDid),
2071
-
db.FilterEq("change_id", p.ChangeId),
2097
+
orm.FilterEq("repo_at", p.RepoAt.String()),
2098
+
orm.FilterEq("owner_did", p.OwnerDid),
2099
+
orm.FilterEq("change_id", p.ChangeId),
2072
2100
)
2073
2101
2074
2102
if err != nil {
···
2085
2113
return
2086
2114
}
2087
2115
2088
-
client, err := s.oauth.AuthorizedClient(r)
2089
-
if err != nil {
2090
-
log.Println("failed to authorize client")
2091
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2092
-
return
2093
-
}
2094
-
2095
2116
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2096
2117
Repo: user.Did,
2097
2118
Writes: writes,
···
2102
2123
return
2103
2124
}
2104
2125
2105
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2126
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2127
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2106
2128
}
2107
2129
2108
2130
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2131
+
user := s.oauth.GetUser(r)
2109
2132
f, err := s.repoResolver.Resolve(r)
2110
2133
if err != nil {
2111
2134
log.Println("failed to resolve repo:", err)
···
2154
2177
2155
2178
authorName := ident.Handle.String()
2156
2179
mergeInput := &tangled.RepoMerge_Input{
2157
-
Did: f.OwnerDid(),
2180
+
Did: f.Did,
2158
2181
Name: f.Name,
2159
2182
Branch: pull.TargetBranch,
2160
2183
Patch: patch,
···
2216
2239
2217
2240
// notify about the pull merge
2218
2241
for _, p := range pullsToMerge {
2219
-
s.notifier.NewPullState(r.Context(), p)
2242
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2220
2243
}
2221
2244
2222
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2245
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2246
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2223
2247
}
2224
2248
2225
2249
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2239
2263
}
2240
2264
2241
2265
// auth filter: only owner or collaborators can close
2242
-
roles := f.RolesInRepo(user)
2266
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2243
2267
isOwner := roles.IsOwner()
2244
2268
isCollaborator := roles.IsCollaborator()
2245
2269
isPullAuthor := user.Did == pull.OwnerDid
···
2288
2312
}
2289
2313
2290
2314
for _, p := range pullsToClose {
2291
-
s.notifier.NewPullState(r.Context(), p)
2315
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2292
2316
}
2293
2317
2294
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2318
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2319
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2295
2320
}
2296
2321
2297
2322
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2312
2337
}
2313
2338
2314
2339
// auth filter: only owner or collaborators can close
2315
-
roles := f.RolesInRepo(user)
2340
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2316
2341
isOwner := roles.IsOwner()
2317
2342
isCollaborator := roles.IsCollaborator()
2318
2343
isPullAuthor := user.Did == pull.OwnerDid
···
2361
2386
}
2362
2387
2363
2388
for _, p := range pullsToReopen {
2364
-
s.notifier.NewPullState(r.Context(), p)
2389
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2365
2390
}
2366
2391
2367
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2392
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2393
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2368
2394
}
2369
2395
2370
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2396
+
func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2371
2397
formatPatches, err := patchutil.ExtractPatches(patch)
2372
2398
if err != nil {
2373
2399
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2392
2418
body := fp.Body
2393
2419
rkey := tid.TID()
2394
2420
2421
+
mentions, references := s.mentionsResolver.Resolve(ctx, body)
2422
+
2395
2423
initialSubmission := models.PullSubmission{
2396
2424
Patch: fp.Raw,
2397
2425
SourceRev: fp.SHA,
···
2402
2430
Body: body,
2403
2431
TargetBranch: targetBranch,
2404
2432
OwnerDid: user.Did,
2405
-
RepoAt: f.RepoAt(),
2433
+
RepoAt: repo.RepoAt(),
2406
2434
Rkey: rkey,
2435
+
Mentions: mentions,
2436
+
References: references,
2407
2437
Submissions: []*models.PullSubmission{
2408
2438
&initialSubmission,
2409
2439
},
+50
appview/repo/archive.go
+50
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
+
ref = strings.TrimSuffix(ref, ".tar.gz")
22
+
f, err := rp.repoResolver.Resolve(r)
23
+
if err != nil {
24
+
l.Error("failed to get repo and knot", "err", err)
25
+
return
26
+
}
27
+
scheme := "http"
28
+
if !rp.config.Core.Dev {
29
+
scheme = "https"
30
+
}
31
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
32
+
xrpcc := &indigoxrpc.Client{
33
+
Host: host,
34
+
}
35
+
didSlashRepo := f.DidSlashRepo()
36
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo)
37
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
38
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
39
+
rp.pages.Error503(w)
40
+
return
41
+
}
42
+
// Set headers for file download, just pass along whatever the knot specifies
43
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
44
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
45
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
46
+
w.Header().Set("Content-Type", "application/gzip")
47
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
48
+
// Write the archive data directly
49
+
w.Write(archiveBytes)
50
+
}
+21
-14
appview/repo/artifact.go
+21
-14
appview/repo/artifact.go
···
14
14
"tangled.org/core/appview/db"
15
15
"tangled.org/core/appview/models"
16
16
"tangled.org/core/appview/pages"
17
-
"tangled.org/core/appview/reporesolver"
18
17
"tangled.org/core/appview/xrpcclient"
18
+
"tangled.org/core/orm"
19
19
"tangled.org/core/tid"
20
20
"tangled.org/core/types"
21
21
···
131
131
132
132
rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
133
133
LoggedInUser: user,
134
-
RepoInfo: f.RepoInfo(user),
134
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
135
135
Artifact: artifact,
136
136
})
137
137
}
···
156
156
157
157
artifacts, err := db.GetArtifact(
158
158
rp.db,
159
-
db.FilterEq("repo_at", f.RepoAt()),
160
-
db.FilterEq("tag", tag.Tag.Hash[:]),
161
-
db.FilterEq("name", filename),
159
+
orm.FilterEq("repo_at", f.RepoAt()),
160
+
orm.FilterEq("tag", tag.Tag.Hash[:]),
161
+
orm.FilterEq("name", filename),
162
162
)
163
163
if err != nil {
164
164
log.Println("failed to get artifacts", err)
···
174
174
175
175
artifact := artifacts[0]
176
176
177
-
ownerPds := f.OwnerId.PDSEndpoint()
177
+
ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did)
178
+
if err != nil {
179
+
log.Println("failed to resolve repo owner did", f.Did, err)
180
+
http.Error(w, "repository owner not found", http.StatusNotFound)
181
+
return
182
+
}
183
+
184
+
ownerPds := ownerId.PDSEndpoint()
178
185
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
179
186
q := url.Query()
180
187
q.Set("cid", artifact.BlobCid.String())
···
228
235
229
236
artifacts, err := db.GetArtifact(
230
237
rp.db,
231
-
db.FilterEq("repo_at", f.RepoAt()),
232
-
db.FilterEq("tag", tag[:]),
233
-
db.FilterEq("name", filename),
238
+
orm.FilterEq("repo_at", f.RepoAt()),
239
+
orm.FilterEq("tag", tag[:]),
240
+
orm.FilterEq("name", filename),
234
241
)
235
242
if err != nil {
236
243
log.Println("failed to get artifacts", err)
···
270
277
defer tx.Rollback()
271
278
272
279
err = db.DeleteArtifact(tx,
273
-
db.FilterEq("repo_at", f.RepoAt()),
274
-
db.FilterEq("tag", artifact.Tag[:]),
275
-
db.FilterEq("name", filename),
280
+
orm.FilterEq("repo_at", f.RepoAt()),
281
+
orm.FilterEq("tag", artifact.Tag[:]),
282
+
orm.FilterEq("name", filename),
276
283
)
277
284
if err != nil {
278
285
log.Println("failed to remove artifact record from db", err)
···
290
297
w.Write([]byte{})
291
298
}
292
299
293
-
func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
300
+
func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) {
294
301
tagParam, err := url.QueryUnescape(tagParam)
295
302
if err != nil {
296
303
return nil, err
···
305
312
Host: host,
306
313
}
307
314
308
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
315
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
309
316
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
310
317
if err != nil {
311
318
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+293
appview/repo/blob.go
+293
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.Did, f.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
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
66
+
67
+
// Use XRPC response directly instead of converting to internal types
68
+
var breadcrumbs [][]string
69
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))})
70
+
if filePath != "" {
71
+
for idx, elem := range strings.Split(filePath, "/") {
72
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
73
+
}
74
+
}
75
+
76
+
// Create the blob view
77
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
78
+
79
+
user := rp.oauth.GetUser(r)
80
+
81
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
82
+
LoggedInUser: user,
83
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
84
+
BreadCrumbs: breadcrumbs,
85
+
BlobView: blobView,
86
+
RepoBlob_Output: resp,
87
+
})
88
+
}
89
+
90
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
91
+
l := rp.logger.With("handler", "RepoBlobRaw")
92
+
93
+
f, err := rp.repoResolver.Resolve(r)
94
+
if err != nil {
95
+
l.Error("failed to get repo and knot", "err", err)
96
+
w.WriteHeader(http.StatusBadRequest)
97
+
return
98
+
}
99
+
100
+
ref := chi.URLParam(r, "ref")
101
+
ref, _ = url.PathUnescape(ref)
102
+
103
+
filePath := chi.URLParam(r, "*")
104
+
filePath, _ = url.PathUnescape(filePath)
105
+
106
+
scheme := "http"
107
+
if !rp.config.Core.Dev {
108
+
scheme = "https"
109
+
}
110
+
repo := f.DidSlashRepo()
111
+
baseURL := &url.URL{
112
+
Scheme: scheme,
113
+
Host: f.Knot,
114
+
Path: "/xrpc/sh.tangled.repo.blob",
115
+
}
116
+
query := baseURL.Query()
117
+
query.Set("repo", repo)
118
+
query.Set("ref", ref)
119
+
query.Set("path", filePath)
120
+
query.Set("raw", "true")
121
+
baseURL.RawQuery = query.Encode()
122
+
blobURL := baseURL.String()
123
+
req, err := http.NewRequest("GET", blobURL, nil)
124
+
if err != nil {
125
+
l.Error("failed to create request", "err", err)
126
+
return
127
+
}
128
+
129
+
// forward the If-None-Match header
130
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
131
+
req.Header.Set("If-None-Match", clientETag)
132
+
}
133
+
client := &http.Client{}
134
+
135
+
resp, err := client.Do(req)
136
+
if err != nil {
137
+
l.Error("failed to reach knotserver", "err", err)
138
+
rp.pages.Error503(w)
139
+
return
140
+
}
141
+
142
+
defer resp.Body.Close()
143
+
144
+
// forward 304 not modified
145
+
if resp.StatusCode == http.StatusNotModified {
146
+
w.WriteHeader(http.StatusNotModified)
147
+
return
148
+
}
149
+
150
+
if resp.StatusCode != http.StatusOK {
151
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
152
+
w.WriteHeader(resp.StatusCode)
153
+
_, _ = io.Copy(w, resp.Body)
154
+
return
155
+
}
156
+
157
+
contentType := resp.Header.Get("Content-Type")
158
+
body, err := io.ReadAll(resp.Body)
159
+
if err != nil {
160
+
l.Error("error reading response body from knotserver", "err", err)
161
+
w.WriteHeader(http.StatusInternalServerError)
162
+
return
163
+
}
164
+
165
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
166
+
// serve all textual content as text/plain
167
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
168
+
w.Write(body)
169
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
170
+
// serve images and videos with their original content type
171
+
w.Header().Set("Content-Type", contentType)
172
+
w.Write(body)
173
+
} else {
174
+
w.WriteHeader(http.StatusUnsupportedMediaType)
175
+
w.Write([]byte("unsupported content type"))
176
+
return
177
+
}
178
+
}
179
+
180
+
// NewBlobView creates a BlobView from the XRPC response
181
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView {
182
+
view := models.BlobView{
183
+
Contents: "",
184
+
Lines: 0,
185
+
}
186
+
187
+
// Set size
188
+
if resp.Size != nil {
189
+
view.SizeHint = uint64(*resp.Size)
190
+
} else if resp.Content != nil {
191
+
view.SizeHint = uint64(len(*resp.Content))
192
+
}
193
+
194
+
if resp.Submodule != nil {
195
+
view.ContentType = models.BlobContentTypeSubmodule
196
+
view.HasRenderedView = true
197
+
view.ContentSrc = resp.Submodule.Url
198
+
return view
199
+
}
200
+
201
+
// Determine if binary
202
+
if resp.IsBinary != nil && *resp.IsBinary {
203
+
view.ContentSrc = generateBlobURL(config, repo, ref, filePath)
204
+
ext := strings.ToLower(filepath.Ext(resp.Path))
205
+
206
+
switch ext {
207
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
208
+
view.ContentType = models.BlobContentTypeImage
209
+
view.HasRawView = true
210
+
view.HasRenderedView = true
211
+
view.ShowingRendered = true
212
+
213
+
case ".svg":
214
+
view.ContentType = models.BlobContentTypeSvg
215
+
view.HasRawView = true
216
+
view.HasTextView = true
217
+
view.HasRenderedView = true
218
+
view.ShowingRendered = queryParams.Get("code") != "true"
219
+
if resp.Content != nil {
220
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
221
+
view.Contents = string(bytes)
222
+
view.Lines = strings.Count(view.Contents, "\n") + 1
223
+
}
224
+
225
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
226
+
view.ContentType = models.BlobContentTypeVideo
227
+
view.HasRawView = true
228
+
view.HasRenderedView = true
229
+
view.ShowingRendered = true
230
+
}
231
+
232
+
return view
233
+
}
234
+
235
+
// otherwise, we are dealing with text content
236
+
view.HasRawView = true
237
+
view.HasTextView = true
238
+
239
+
if resp.Content != nil {
240
+
view.Contents = *resp.Content
241
+
view.Lines = strings.Count(view.Contents, "\n") + 1
242
+
}
243
+
244
+
// with text, we may be dealing with markdown
245
+
format := markup.GetFormat(resp.Path)
246
+
if format == markup.FormatMarkdown {
247
+
view.ContentType = models.BlobContentTypeMarkup
248
+
view.HasRenderedView = true
249
+
view.ShowingRendered = queryParams.Get("code") != "true"
250
+
}
251
+
252
+
return view
253
+
}
254
+
255
+
func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string {
256
+
scheme := "http"
257
+
if !config.Core.Dev {
258
+
scheme = "https"
259
+
}
260
+
261
+
repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
262
+
baseURL := &url.URL{
263
+
Scheme: scheme,
264
+
Host: repo.Knot,
265
+
Path: "/xrpc/sh.tangled.repo.blob",
266
+
}
267
+
query := baseURL.Query()
268
+
query.Set("repo", repoName)
269
+
query.Set("ref", ref)
270
+
query.Set("path", filePath)
271
+
query.Set("raw", "true")
272
+
baseURL.RawQuery = query.Encode()
273
+
blobURL := baseURL.String()
274
+
275
+
if !config.Core.Dev {
276
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
277
+
}
278
+
return blobURL
279
+
}
280
+
281
+
func isTextualMimeType(mimeType string) bool {
282
+
textualTypes := []string{
283
+
"application/json",
284
+
"application/xml",
285
+
"application/yaml",
286
+
"application/x-yaml",
287
+
"application/toml",
288
+
"application/javascript",
289
+
"application/ecmascript",
290
+
"message/",
291
+
}
292
+
return slices.Contains(textualTypes, mimeType)
293
+
}
+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.Did, 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: rp.repoResolver.GetRepoInfo(r, 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
+
}
+214
appview/repo/compare.go
+214
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.Did, 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
+
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
92
+
LoggedInUser: user,
93
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
94
+
Branches: branches,
95
+
Tags: tags.Tags,
96
+
Base: base,
97
+
Head: head,
98
+
})
99
+
}
100
+
101
+
func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) {
102
+
l := rp.logger.With("handler", "RepoCompare")
103
+
104
+
user := rp.oauth.GetUser(r)
105
+
f, err := rp.repoResolver.Resolve(r)
106
+
if err != nil {
107
+
l.Error("failed to get repo and knot", "err", err)
108
+
return
109
+
}
110
+
111
+
var diffOpts types.DiffOpts
112
+
if d := r.URL.Query().Get("diff"); d == "split" {
113
+
diffOpts.Split = true
114
+
}
115
+
116
+
// if user is navigating to one of
117
+
// /compare/{base}...{head}
118
+
// /compare/{base}/{head}
119
+
var base, head string
120
+
rest := chi.URLParam(r, "*")
121
+
122
+
var parts []string
123
+
if strings.Contains(rest, "...") {
124
+
parts = strings.SplitN(rest, "...", 2)
125
+
} else if strings.Contains(rest, "/") {
126
+
parts = strings.SplitN(rest, "/", 2)
127
+
}
128
+
129
+
if len(parts) == 2 {
130
+
base = parts[0]
131
+
head = parts[1]
132
+
}
133
+
134
+
base, _ = url.PathUnescape(base)
135
+
head, _ = url.PathUnescape(head)
136
+
137
+
if base == "" || head == "" {
138
+
l.Error("invalid comparison")
139
+
rp.pages.Error404(w)
140
+
return
141
+
}
142
+
143
+
scheme := "http"
144
+
if !rp.config.Core.Dev {
145
+
scheme = "https"
146
+
}
147
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
148
+
xrpcc := &indigoxrpc.Client{
149
+
Host: host,
150
+
}
151
+
152
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
153
+
154
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
155
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
156
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
157
+
rp.pages.Error503(w)
158
+
return
159
+
}
160
+
161
+
var branches types.RepoBranchesResponse
162
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
163
+
l.Error("failed to decode XRPC branches response", "err", err)
164
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
165
+
return
166
+
}
167
+
168
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
169
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
170
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
171
+
rp.pages.Error503(w)
172
+
return
173
+
}
174
+
175
+
var tags types.RepoTagsResponse
176
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
177
+
l.Error("failed to decode XRPC tags response", "err", err)
178
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
179
+
return
180
+
}
181
+
182
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
183
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
184
+
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
185
+
rp.pages.Error503(w)
186
+
return
187
+
}
188
+
189
+
var formatPatch types.RepoFormatPatchResponse
190
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
191
+
l.Error("failed to decode XRPC compare response", "err", err)
192
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
193
+
return
194
+
}
195
+
196
+
var diff types.NiceDiff
197
+
if formatPatch.CombinedPatchRaw != "" {
198
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
199
+
} else {
200
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
201
+
}
202
+
203
+
rp.pages.RepoCompare(w, pages.RepoCompareParams{
204
+
LoggedInUser: user,
205
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
206
+
Branches: branches.Branches,
207
+
Tags: tags.Tags,
208
+
Base: base,
209
+
Head: head,
210
+
Diff: &diff,
211
+
DiffOpts: diffOpts,
212
+
})
213
+
214
+
}
+25
-18
appview/repo/feed.go
+25
-18
appview/repo/feed.go
···
11
11
"tangled.org/core/appview/db"
12
12
"tangled.org/core/appview/models"
13
13
"tangled.org/core/appview/pagination"
14
-
"tangled.org/core/appview/reporesolver"
14
+
"tangled.org/core/orm"
15
15
16
+
"github.com/bluesky-social/indigo/atproto/identity"
16
17
"github.com/bluesky-social/indigo/atproto/syntax"
17
18
"github.com/gorilla/feeds"
18
19
)
19
20
20
-
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
21
+
func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) {
21
22
const feedLimitPerType = 100
22
23
23
-
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
24
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt()))
24
25
if err != nil {
25
26
return nil, err
26
27
}
···
28
29
issues, err := db.GetIssuesPaginated(
29
30
rp.db,
30
31
pagination.Page{Limit: feedLimitPerType},
31
-
db.FilterEq("repo_at", f.RepoAt()),
32
+
orm.FilterEq("repo_at", repo.RepoAt()),
32
33
)
33
34
if err != nil {
34
35
return nil, err
35
36
}
36
37
37
38
feed := &feeds.Feed{
38
-
Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()),
39
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"},
39
+
Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo),
40
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"},
40
41
Items: make([]*feeds.Item, 0),
41
42
Updated: time.UnixMilli(0),
42
43
}
43
44
44
45
for _, pull := range pulls {
45
-
items, err := rp.createPullItems(ctx, pull, f)
46
+
items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo)
46
47
if err != nil {
47
48
return nil, err
48
49
}
···
50
51
}
51
52
52
53
for _, issue := range issues {
53
-
item, err := rp.createIssueItem(ctx, issue, f)
54
+
item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo)
54
55
if err != nil {
55
56
return nil, err
56
57
}
···
71
72
return feed, nil
72
73
}
73
74
74
-
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
75
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
75
76
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
76
77
if err != nil {
77
78
return nil, err
···
80
81
var items []*feeds.Item
81
82
82
83
state := rp.getPullState(pull)
83
-
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
84
+
description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo)
84
85
85
86
mainItem := &feeds.Item{
86
87
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
87
88
Description: description,
88
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
89
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)},
89
90
Created: pull.Created,
90
91
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
91
92
}
···
98
99
99
100
roundItem := &feeds.Item{
100
101
Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
101
-
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()),
102
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)},
102
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo),
103
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)},
103
104
Created: round.Created,
104
105
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
105
106
}
···
109
110
return items, nil
110
111
}
111
112
112
-
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
113
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) {
113
114
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
114
115
if err != nil {
115
116
return nil, err
···
122
123
123
124
return &feeds.Item{
124
125
Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
125
-
Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()),
126
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)},
126
+
Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo),
127
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)},
127
128
Created: issue.Created,
128
129
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
129
130
}, nil
···
146
147
return fmt.Sprintf("%s in %s", base, repoName)
147
148
}
148
149
149
-
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
150
+
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
150
151
f, err := rp.repoResolver.Resolve(r)
151
152
if err != nil {
152
153
log.Println("failed to fully resolve repo:", err)
153
154
return
154
155
}
156
+
repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity)
157
+
if !ok || repoOwnerId.Handle.IsInvalidHandle() {
158
+
log.Println("failed to get resolved repo owner id")
159
+
return
160
+
}
161
+
ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name
155
162
156
-
feed, err := rp.getRepoFeed(r.Context(), f)
163
+
feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo)
157
164
if err != nil {
158
165
log.Println("failed to get repo feed:", err)
159
166
rp.pages.Error500(w)
+23
-25
appview/repo/index.go
+23
-25
appview/repo/index.go
···
22
22
"tangled.org/core/appview/db"
23
23
"tangled.org/core/appview/models"
24
24
"tangled.org/core/appview/pages"
25
-
"tangled.org/core/appview/reporesolver"
26
25
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/orm"
27
27
"tangled.org/core/types"
28
28
29
29
"github.com/go-chi/chi/v5"
30
30
"github.com/go-enry/go-enry/v2"
31
31
)
32
32
33
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
34
l := rp.logger.With("handler", "RepoIndex")
35
35
36
36
ref := chi.URLParam(r, "ref")
···
52
52
}
53
53
54
54
user := rp.oauth.GetUser(r)
55
-
repoInfo := f.RepoInfo(user)
56
55
57
56
// Build index response from multiple XRPC calls
58
57
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
···
62
61
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
63
62
LoggedInUser: user,
64
63
NeedsKnotUpgrade: true,
65
-
RepoInfo: repoInfo,
64
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
66
65
})
67
66
return
68
67
}
···
124
123
l.Error("failed to get email to did map", "err", err)
125
124
}
126
125
127
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
126
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc)
128
127
if err != nil {
129
128
l.Error("failed to GetVerifiedObjectCommits", "err", err)
130
129
}
···
140
139
for _, c := range commitsTrunc {
141
140
shas = append(shas, c.Hash.String())
142
141
}
143
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
142
+
pipelines, err := getPipelineStatuses(rp.db, f, shas)
144
143
if err != nil {
145
144
l.Error("failed to fetch pipeline statuses", "err", err)
146
145
// non-fatal
···
148
147
149
148
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
150
149
LoggedInUser: user,
151
-
RepoInfo: repoInfo,
150
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
152
151
TagMap: tagMap,
153
152
RepoIndexResponse: *result,
154
153
CommitsTrunc: commitsTrunc,
···
165
164
func (rp *Repo) getLanguageInfo(
166
165
ctx context.Context,
167
166
l *slog.Logger,
168
-
f *reporesolver.ResolvedRepo,
167
+
repo *models.Repo,
169
168
xrpcc *indigoxrpc.Client,
170
169
currentRef string,
171
170
isDefaultRef bool,
···
173
172
// first attempt to fetch from db
174
173
langs, err := db.GetRepoLanguages(
175
174
rp.db,
176
-
db.FilterEq("repo_at", f.RepoAt()),
177
-
db.FilterEq("ref", currentRef),
175
+
orm.FilterEq("repo_at", repo.RepoAt()),
176
+
orm.FilterEq("ref", currentRef),
178
177
)
179
178
180
179
if err != nil || langs == nil {
181
180
// non-fatal, fetch langs from ks via XRPC
182
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
183
-
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
181
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
182
+
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo)
184
183
if err != nil {
185
184
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
186
185
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
···
195
194
196
195
for _, lang := range ls.Languages {
197
196
langs = append(langs, models.RepoLanguage{
198
-
RepoAt: f.RepoAt(),
197
+
RepoAt: repo.RepoAt(),
199
198
Ref: currentRef,
200
199
IsDefaultRef: isDefaultRef,
201
200
Language: lang.Name,
···
210
209
defer tx.Rollback()
211
210
212
211
// update appview's cache
213
-
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
212
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs)
214
213
if err != nil {
215
214
// non-fatal
216
215
l.Error("failed to cache lang results", "err", err)
···
255
254
}
256
255
257
256
// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
258
-
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) {
259
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
257
+
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) {
258
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
260
259
261
260
// first get branches to determine the ref if not specified
262
-
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
261
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo)
263
262
if err != nil {
264
263
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
265
264
}
···
303
302
wg.Add(1)
304
303
go func() {
305
304
defer wg.Done()
306
-
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
305
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo)
307
306
if err != nil {
308
307
errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err))
309
308
return
···
318
317
wg.Add(1)
319
318
go func() {
320
319
defer wg.Done()
321
-
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
320
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo)
322
321
if err != nil {
323
322
errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err))
324
323
return
···
330
329
wg.Add(1)
331
330
go func() {
332
331
defer wg.Done()
333
-
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
332
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo)
334
333
if err != nil {
335
334
errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err))
336
335
return
···
351
350
if treeResp != nil && treeResp.Files != nil {
352
351
for _, file := range treeResp.Files {
353
352
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,
353
+
Name: file.Name,
354
+
Mode: file.Mode,
355
+
Size: file.Size,
359
356
}
357
+
360
358
if file.Last_commit != nil {
361
359
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
362
360
niceFile.LastCommit = &types.LastCommitInfo{
+220
appview/repo/log.go
+220
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.Did, 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.GetVerifiedCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
+
if err != nil {
121
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
+
}
123
+
124
+
var shas []string
125
+
for _, c := range xrpcResp.Commits {
126
+
shas = append(shas, c.Hash.String())
127
+
}
128
+
pipelines, err := getPipelineStatuses(rp.db, f, shas)
129
+
if err != nil {
130
+
l.Error("failed to getPipelineStatuses", "err", err)
131
+
// non-fatal
132
+
}
133
+
134
+
rp.pages.RepoLog(w, pages.RepoLogParams{
135
+
LoggedInUser: user,
136
+
TagMap: tagMap,
137
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
138
+
RepoLogResponse: xrpcResp,
139
+
EmailToDid: emailToDidMap,
140
+
VerifiedCommits: vc,
141
+
Pipelines: pipelines,
142
+
})
143
+
}
144
+
145
+
func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) {
146
+
l := rp.logger.With("handler", "RepoCommit")
147
+
148
+
f, err := rp.repoResolver.Resolve(r)
149
+
if err != nil {
150
+
l.Error("failed to fully resolve repo", "err", err)
151
+
return
152
+
}
153
+
ref := chi.URLParam(r, "ref")
154
+
ref, _ = url.PathUnescape(ref)
155
+
156
+
var diffOpts types.DiffOpts
157
+
if d := r.URL.Query().Get("diff"); d == "split" {
158
+
diffOpts.Split = true
159
+
}
160
+
161
+
if !plumbing.IsHash(ref) {
162
+
rp.pages.Error404(w)
163
+
return
164
+
}
165
+
166
+
scheme := "http"
167
+
if !rp.config.Core.Dev {
168
+
scheme = "https"
169
+
}
170
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
171
+
xrpcc := &indigoxrpc.Client{
172
+
Host: host,
173
+
}
174
+
175
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
176
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
177
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
178
+
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
179
+
rp.pages.Error503(w)
180
+
return
181
+
}
182
+
183
+
var result types.RepoCommitResponse
184
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
185
+
l.Error("failed to decode XRPC response", "err", err)
186
+
rp.pages.Error503(w)
187
+
return
188
+
}
189
+
190
+
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
191
+
if err != nil {
192
+
l.Error("failed to get email to did mapping", "err", err)
193
+
}
194
+
195
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.Commit{result.Diff.Commit})
196
+
if err != nil {
197
+
l.Error("failed to GetVerifiedCommits", "err", err)
198
+
}
199
+
200
+
user := rp.oauth.GetUser(r)
201
+
pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This})
202
+
if err != nil {
203
+
l.Error("failed to getPipelineStatuses", "err", err)
204
+
// non-fatal
205
+
}
206
+
var pipeline *models.Pipeline
207
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
208
+
pipeline = &p
209
+
}
210
+
211
+
rp.pages.RepoCommit(w, pages.RepoCommitParams{
212
+
LoggedInUser: user,
213
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
214
+
RepoCommitResponse: result,
215
+
EmailToDid: emailToDidMap,
216
+
VerifiedCommit: vc,
217
+
Pipeline: pipeline,
218
+
DiffOpts: diffOpts,
219
+
})
220
+
}
+9
-8
appview/repo/opengraph.go
+9
-8
appview/repo/opengraph.go
···
16
16
"tangled.org/core/appview/db"
17
17
"tangled.org/core/appview/models"
18
18
"tangled.org/core/appview/ogcard"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/types"
20
21
)
21
22
···
158
159
// Draw star icon, count, and label
159
160
// Align icon baseline with text baseline
160
161
iconBaselineOffset := int(textSize) / 2
161
-
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
+
err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
163
if err != nil {
163
164
log.Printf("failed to draw star icon: %v", err)
164
165
}
···
185
186
186
187
// Draw issues icon, count, and label
187
188
issueStartX := currentX
188
-
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
+
err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
190
if err != nil {
190
191
log.Printf("failed to draw circle-dot icon: %v", err)
191
192
}
···
210
211
211
212
// Draw pull request icon, count, and label
212
213
prStartX := currentX
213
-
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
+
err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
215
if err != nil {
215
216
log.Printf("failed to draw git-pull-request icon: %v", err)
216
217
}
···
236
237
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
238
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
239
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
240
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
240
241
if err != nil {
241
242
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
243
}
···
327
328
return nil
328
329
}
329
330
330
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
331
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
332
f, err := rp.repoResolver.Resolve(r)
332
333
if err != nil {
333
334
log.Println("failed to get repo and knot", err)
···
338
339
var languageStats []types.RepoLanguageDetails
339
340
langs, err := db.GetRepoLanguages(
340
341
rp.db,
341
-
db.FilterEq("repo_at", f.RepoAt()),
342
-
db.FilterEq("is_default_ref", 1),
342
+
orm.FilterEq("repo_at", f.RepoAt()),
343
+
orm.FilterEq("is_default_ref", 1),
343
344
)
344
345
if err != nil {
345
346
log.Printf("failed to get language stats from db: %v", err)
···
374
375
})
375
376
}
376
377
377
-
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
378
+
card, err := rp.drawRepoSummaryCard(f, languageStats)
378
379
if err != nil {
379
380
log.Println("failed to draw repo summary card", err)
380
381
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+39
-1413
appview/repo/repo.go
+39
-1413
appview/repo/repo.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"encoding/json"
7
6
"errors"
8
7
"fmt"
9
-
"io"
10
8
"log/slog"
11
9
"net/http"
12
10
"net/url"
13
-
"path/filepath"
14
11
"slices"
15
-
"strconv"
16
12
"strings"
17
13
"time"
18
14
19
15
"tangled.org/core/api/tangled"
20
-
"tangled.org/core/appview/commitverify"
21
16
"tangled.org/core/appview/config"
22
17
"tangled.org/core/appview/db"
23
18
"tangled.org/core/appview/models"
24
19
"tangled.org/core/appview/notify"
25
20
"tangled.org/core/appview/oauth"
26
21
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
22
"tangled.org/core/appview/reporesolver"
29
23
"tangled.org/core/appview/validator"
30
24
xrpcclient "tangled.org/core/appview/xrpcclient"
31
25
"tangled.org/core/eventconsumer"
32
26
"tangled.org/core/idresolver"
33
-
"tangled.org/core/patchutil"
27
+
"tangled.org/core/orm"
34
28
"tangled.org/core/rbac"
35
29
"tangled.org/core/tid"
36
-
"tangled.org/core/types"
37
30
"tangled.org/core/xrpc/serviceauth"
38
31
39
32
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
33
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
34
"github.com/bluesky-social/indigo/atproto/syntax"
42
35
lexutil "github.com/bluesky-social/indigo/lex/util"
43
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44
36
securejoin "github.com/cyphar/filepath-securejoin"
45
37
"github.com/go-chi/chi/v5"
46
-
"github.com/go-git/go-git/v5/plumbing"
47
38
)
48
39
49
40
type Repo struct {
···
88
79
}
89
80
}
90
81
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
82
// modify the spindle configured for this repo
934
83
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
935
84
user := rp.oauth.GetUser(r)
···
970
119
}
971
120
}
972
121
973
-
newRepo := f.Repo
122
+
newRepo := *f
974
123
newRepo.Spindle = newSpindle
975
124
record := newRepo.AsRecord()
976
125
···
1109
258
l.Info("wrote label record to PDS")
1110
259
1111
260
// update the repo to subscribe to this label
1112
-
newRepo := f.Repo
261
+
newRepo := *f
1113
262
newRepo.Labels = append(newRepo.Labels, aturi)
1114
263
repoRecord := newRepo.AsRecord()
1115
264
···
1197
346
// get form values
1198
347
labelId := r.FormValue("label-id")
1199
348
1200
-
label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
349
+
label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId))
1201
350
if err != nil {
1202
351
fail("Failed to find label definition.", err)
1203
352
return
···
1221
370
}
1222
371
1223
372
// update repo record to remove the label reference
1224
-
newRepo := f.Repo
373
+
newRepo := *f
1225
374
var updated []string
1226
375
removedAt := label.AtUri().String()
1227
376
for _, l := range newRepo.Labels {
···
1261
410
1262
411
err = db.UnsubscribeLabel(
1263
412
tx,
1264
-
db.FilterEq("repo_at", f.RepoAt()),
1265
-
db.FilterEq("label_at", removedAt),
413
+
orm.FilterEq("repo_at", f.RepoAt()),
414
+
orm.FilterEq("label_at", removedAt),
1266
415
)
1267
416
if err != nil {
1268
417
fail("Failed to unsubscribe label.", err)
1269
418
return
1270
419
}
1271
420
1272
-
err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
421
+
err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id))
1273
422
if err != nil {
1274
423
fail("Failed to delete label definition.", err)
1275
424
return
···
1308
457
}
1309
458
1310
459
labelAts := r.Form["label"]
1311
-
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
460
+
_, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
1312
461
if err != nil {
1313
462
fail("Failed to subscribe to label.", err)
1314
463
return
1315
464
}
1316
465
1317
-
newRepo := f.Repo
466
+
newRepo := *f
1318
467
newRepo.Labels = append(newRepo.Labels, labelAts...)
1319
468
1320
469
// dedup
···
1329
478
return
1330
479
}
1331
480
1332
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
481
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
1333
482
if err != nil {
1334
483
fail("Failed to update labels, no record found on PDS.", err)
1335
484
return
···
1394
543
}
1395
544
1396
545
labelAts := r.Form["label"]
1397
-
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
546
+
_, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
1398
547
if err != nil {
1399
548
fail("Failed to unsubscribe to label.", err)
1400
549
return
1401
550
}
1402
551
1403
552
// update repo record to remove the label reference
1404
-
newRepo := f.Repo
553
+
newRepo := *f
1405
554
var updated []string
1406
555
for _, l := range newRepo.Labels {
1407
556
if !slices.Contains(labelAts, l) {
···
1417
566
return
1418
567
}
1419
568
1420
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
569
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
1421
570
if err != nil {
1422
571
fail("Failed to update labels, no record found on PDS.", err)
1423
572
return
···
1434
583
1435
584
err = db.UnsubscribeLabel(
1436
585
rp.db,
1437
-
db.FilterEq("repo_at", f.RepoAt()),
1438
-
db.FilterIn("label_at", labelAts),
586
+
orm.FilterEq("repo_at", f.RepoAt()),
587
+
orm.FilterIn("label_at", labelAts),
1439
588
)
1440
589
if err != nil {
1441
590
fail("Failed to unsubscribe label.", err)
···
1464
613
1465
614
labelDefs, err := db.GetLabelDefinitions(
1466
615
rp.db,
1467
-
db.FilterIn("at_uri", f.Repo.Labels),
1468
-
db.FilterContains("scope", subject.Collection().String()),
616
+
orm.FilterIn("at_uri", f.Labels),
617
+
orm.FilterContains("scope", subject.Collection().String()),
1469
618
)
1470
619
if err != nil {
1471
620
l.Error("failed to fetch label defs", "err", err)
···
1477
626
defs[l.AtUri().String()] = &l
1478
627
}
1479
628
1480
-
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
629
+
states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
1481
630
if err != nil {
1482
631
l.Error("failed to build label state", "err", err)
1483
632
return
···
1487
636
user := rp.oauth.GetUser(r)
1488
637
rp.pages.LabelPanel(w, pages.LabelPanelParams{
1489
638
LoggedInUser: user,
1490
-
RepoInfo: f.RepoInfo(user),
639
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1491
640
Defs: defs,
1492
641
Subject: subject.String(),
1493
642
State: state,
···
1512
661
1513
662
labelDefs, err := db.GetLabelDefinitions(
1514
663
rp.db,
1515
-
db.FilterIn("at_uri", f.Repo.Labels),
1516
-
db.FilterContains("scope", subject.Collection().String()),
664
+
orm.FilterIn("at_uri", f.Labels),
665
+
orm.FilterContains("scope", subject.Collection().String()),
1517
666
)
1518
667
if err != nil {
1519
668
l.Error("failed to fetch labels", "err", err)
···
1525
674
defs[l.AtUri().String()] = &l
1526
675
}
1527
676
1528
-
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
677
+
states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
1529
678
if err != nil {
1530
679
l.Error("failed to build label state", "err", err)
1531
680
return
···
1535
684
user := rp.oauth.GetUser(r)
1536
685
rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1537
686
LoggedInUser: user,
1538
-
RepoInfo: f.RepoInfo(user),
687
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1539
688
Defs: defs,
1540
689
Subject: subject.String(),
1541
690
State: state,
···
1716
865
r.Context(),
1717
866
client,
1718
867
&tangled.RepoDelete_Input{
1719
-
Did: f.OwnerDid(),
868
+
Did: f.Did,
1720
869
Name: f.Name,
1721
870
Rkey: f.Rkey,
1722
871
},
···
1754
903
l.Info("removed collaborators")
1755
904
1756
905
// remove repo RBAC
1757
-
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
906
+
err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo())
1758
907
if err != nil {
1759
908
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1760
909
return
1761
910
}
1762
911
1763
912
// remove repo from db
1764
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
913
+
err = db.RemoveRepo(tx, f.Did, f.Name)
1765
914
if err != nil {
1766
915
rp.pages.Notice(w, noticeId, "Failed to update appview")
1767
916
return
···
1782
931
return
1783
932
}
1784
933
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
-
})
934
+
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did))
2114
935
}
2115
936
2116
937
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
2139
960
return
2140
961
}
2141
962
2142
-
repoInfo := f.RepoInfo(user)
2143
-
if repoInfo.Source == nil {
963
+
if f.Source == "" {
2144
964
rp.pages.Notice(w, "repo", "This repository is not a fork.")
2145
965
return
2146
966
}
···
2151
971
&tangled.RepoForkSync_Input{
2152
972
Did: user.Did,
2153
973
Name: f.Name,
2154
-
Source: repoInfo.Source.RepoAt().String(),
974
+
Source: f.Source,
2155
975
Branch: ref,
2156
976
},
2157
977
)
···
2187
1007
rp.pages.ForkRepo(w, pages.ForkRepoParams{
2188
1008
LoggedInUser: user,
2189
1009
Knots: knots,
2190
-
RepoInfo: f.RepoInfo(user),
1010
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
2191
1011
})
2192
1012
2193
1013
case http.MethodPost:
···
2217
1037
// in the user's account.
2218
1038
existingRepo, err := db.GetRepo(
2219
1039
rp.db,
2220
-
db.FilterEq("did", user.Did),
2221
-
db.FilterEq("name", forkName),
1040
+
orm.FilterEq("did", user.Did),
1041
+
orm.FilterEq("name", forkName),
2222
1042
)
2223
1043
if err != nil {
2224
1044
if !errors.Is(err, sql.ErrNoRows) {
···
2238
1058
uri = "http"
2239
1059
}
2240
1060
2241
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1061
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name)
2242
1062
l = l.With("cloneUrl", forkSourceUrl)
2243
1063
2244
1064
sourceAt := f.RepoAt().String()
···
2251
1071
Knot: targetKnot,
2252
1072
Rkey: rkey,
2253
1073
Source: sourceAt,
2254
-
Description: f.Repo.Description,
1074
+
Description: f.Description,
2255
1075
Created: time.Now(),
2256
-
Labels: models.DefaultLabelDefs(),
1076
+
Labels: rp.config.Label.DefaultLabelDefs,
2257
1077
}
2258
1078
record := repo.AsRecord()
2259
1079
···
2310
1130
}
2311
1131
defer rollback()
2312
1132
1133
+
// TODO: this could coordinate better with the knot to recieve a clone status
2313
1134
client, err := rp.oauth.ServiceClient(
2314
1135
r,
2315
1136
oauth.WithService(targetKnot),
2316
1137
oauth.WithLxm(tangled.RepoCreateNSID),
2317
1138
oauth.WithDev(rp.config.Core.Dev),
1139
+
oauth.WithTimeout(time.Second*20), // big repos take time to clone
2318
1140
)
2319
1141
if err != nil {
2320
1142
l.Error("could not create service client", "err", err)
···
2369
1191
aturi = ""
2370
1192
2371
1193
rp.notifier.NewRepo(r.Context(), repo)
2372
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
1194
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
2373
1195
}
2374
1196
}
2375
1197
···
2394
1216
})
2395
1217
return err
2396
1218
}
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
-
}
+20
-35
appview/repo/repo_util.go
+20
-35
appview/repo/repo_util.go
···
1
1
package repo
2
2
3
3
import (
4
-
"crypto/rand"
5
-
"math/big"
4
+
"maps"
6
5
"slices"
7
6
"sort"
8
7
"strings"
9
8
10
9
"tangled.org/core/appview/db"
11
10
"tangled.org/core/appview/models"
12
-
"tangled.org/core/appview/pages/repoinfo"
11
+
"tangled.org/core/orm"
13
12
"tangled.org/core/types"
14
-
15
-
"github.com/go-git/go-git/v5/plumbing/object"
16
13
)
17
14
18
15
func sortFiles(files []types.NiceTree) {
19
16
sort.Slice(files, func(i, j int) bool {
20
-
iIsFile := files[i].IsFile
21
-
jIsFile := files[j].IsFile
17
+
iIsFile := files[i].IsFile()
18
+
jIsFile := files[j].IsFile()
22
19
if iIsFile != jIsFile {
23
20
return !iIsFile
24
21
}
···
45
42
})
46
43
}
47
44
48
-
func uniqueEmails(commits []*object.Commit) []string {
45
+
func uniqueEmails(commits []types.Commit) []string {
49
46
emails := make(map[string]struct{})
50
47
for _, commit := range commits {
51
-
if commit.Author.Email != "" {
52
-
emails[commit.Author.Email] = struct{}{}
53
-
}
54
-
if commit.Committer.Email != "" {
55
-
emails[commit.Committer.Email] = struct{}{}
48
+
emails[commit.Author.Email] = struct{}{}
49
+
emails[commit.Committer.Email] = struct{}{}
50
+
for _, c := range commit.CoAuthors() {
51
+
emails[c.Email] = struct{}{}
56
52
}
57
53
}
58
-
var uniqueEmails []string
59
-
for email := range emails {
60
-
uniqueEmails = append(uniqueEmails, email)
61
-
}
62
-
return uniqueEmails
54
+
55
+
// delete empty emails if any, from the set
56
+
delete(emails, "")
57
+
58
+
return slices.Collect(maps.Keys(emails))
63
59
}
64
60
65
61
func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) {
···
90
86
return
91
87
}
92
88
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
89
// grab pipelines from DB and munge that into a hashmap with commit sha as key
106
90
//
107
91
// golang is so blessed that it requires 35 lines of imperative code for this
108
92
func getPipelineStatuses(
109
93
d *db.DB,
110
-
repoInfo repoinfo.RepoInfo,
94
+
repo *models.Repo,
111
95
shas []string,
112
96
) (map[string]models.Pipeline, error) {
113
97
m := make(map[string]models.Pipeline)
···
118
102
119
103
ps, err := db.GetPipelineStatuses(
120
104
d,
121
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
122
-
db.FilterEq("repo_name", repoInfo.Name),
123
-
db.FilterEq("knot", repoInfo.Knot),
124
-
db.FilterIn("sha", shas),
105
+
len(shas),
106
+
orm.FilterEq("repo_owner", repo.Did),
107
+
orm.FilterEq("repo_name", repo.Name),
108
+
orm.FilterEq("knot", repo.Knot),
109
+
orm.FilterIn("sha", shas),
125
110
)
126
111
if err != nil {
127
112
return nil, err
+14
-20
appview/repo/router.go
+14
-20
appview/repo/router.go
···
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
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)
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
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
-
r.Get("/", rp.RepoIndex)
18
-
r.Get("/*", rp.RepoTree)
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
19
19
})
20
-
r.Get("/commit/{ref}", rp.RepoCommit)
21
-
r.Get("/branches", rp.RepoBranches)
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
22
r.Delete("/branches", rp.DeleteBranch)
23
23
r.Route("/tags", func(r chi.Router) {
24
-
r.Get("/", rp.RepoTags)
24
+
r.Get("/", rp.Tags)
25
25
r.Route("/{tag}", func(r chi.Router) {
26
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
27
···
37
37
})
38
38
})
39
39
})
40
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
40
+
r.Get("/blob/{ref}/*", rp.Blob)
41
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
42
43
43
// intentionally doesn't use /* as this isn't
···
54
54
})
55
55
56
56
r.Route("/compare", func(r chi.Router) {
57
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
57
+
r.Get("/", rp.CompareNew) // start an new comparison
58
58
59
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
60
// /compare/{ref1}...{ref2}
61
61
// for example:
62
62
// /compare/master...some/feature
63
63
// /compare/master...example.com:another/feature <- this is a fork
64
-
r.Get("/{base}/{head}", rp.RepoCompare)
65
-
r.Get("/*", rp.RepoCompare)
64
+
r.Get("/*", rp.Compare)
66
65
})
67
66
68
67
// label panel in issues/pulls/discussions/tasks
···
74
73
// settings routes, needs auth
75
74
r.Group(func(r chi.Router) {
76
75
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
76
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
84
-
r.Get("/", rp.RepoSettings)
77
+
r.Get("/", rp.Settings)
78
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
85
79
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
86
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
87
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+471
appview/repo/settings.go
+471
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/models"
14
+
"tangled.org/core/appview/oauth"
15
+
"tangled.org/core/appview/pages"
16
+
xrpcclient "tangled.org/core/appview/xrpcclient"
17
+
"tangled.org/core/orm"
18
+
"tangled.org/core/types"
19
+
20
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
+
lexutil "github.com/bluesky-social/indigo/lex/util"
22
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
+
)
24
+
25
+
type tab = map[string]any
26
+
27
+
var (
28
+
// would be great to have ordered maps right about now
29
+
settingsTabs []tab = []tab{
30
+
{"Name": "general", "Icon": "sliders-horizontal"},
31
+
{"Name": "access", "Icon": "users"},
32
+
{"Name": "pipelines", "Icon": "layers-2"},
33
+
}
34
+
)
35
+
36
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
37
+
l := rp.logger.With("handler", "SetDefaultBranch")
38
+
39
+
f, err := rp.repoResolver.Resolve(r)
40
+
if err != nil {
41
+
l.Error("failed to get repo and knot", "err", err)
42
+
return
43
+
}
44
+
45
+
noticeId := "operation-error"
46
+
branch := r.FormValue("branch")
47
+
if branch == "" {
48
+
http.Error(w, "malformed form", http.StatusBadRequest)
49
+
return
50
+
}
51
+
52
+
client, err := rp.oauth.ServiceClient(
53
+
r,
54
+
oauth.WithService(f.Knot),
55
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
56
+
oauth.WithDev(rp.config.Core.Dev),
57
+
)
58
+
if err != nil {
59
+
l.Error("failed to connect to knot server", "err", err)
60
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
61
+
return
62
+
}
63
+
64
+
xe := tangled.RepoSetDefaultBranch(
65
+
r.Context(),
66
+
client,
67
+
&tangled.RepoSetDefaultBranch_Input{
68
+
Repo: f.RepoAt().String(),
69
+
DefaultBranch: branch,
70
+
},
71
+
)
72
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
73
+
l.Error("xrpc failed", "err", xe)
74
+
rp.pages.Notice(w, noticeId, err.Error())
75
+
return
76
+
}
77
+
78
+
rp.pages.HxRefresh(w)
79
+
}
80
+
81
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
82
+
user := rp.oauth.GetUser(r)
83
+
l := rp.logger.With("handler", "Secrets")
84
+
l = l.With("did", user.Did)
85
+
86
+
f, err := rp.repoResolver.Resolve(r)
87
+
if err != nil {
88
+
l.Error("failed to get repo and knot", "err", err)
89
+
return
90
+
}
91
+
92
+
if f.Spindle == "" {
93
+
l.Error("empty spindle cannot add/rm secret", "err", err)
94
+
return
95
+
}
96
+
97
+
lxm := tangled.RepoAddSecretNSID
98
+
if r.Method == http.MethodDelete {
99
+
lxm = tangled.RepoRemoveSecretNSID
100
+
}
101
+
102
+
spindleClient, err := rp.oauth.ServiceClient(
103
+
r,
104
+
oauth.WithService(f.Spindle),
105
+
oauth.WithLxm(lxm),
106
+
oauth.WithExp(60),
107
+
oauth.WithDev(rp.config.Core.Dev),
108
+
)
109
+
if err != nil {
110
+
l.Error("failed to create spindle client", "err", err)
111
+
return
112
+
}
113
+
114
+
key := r.FormValue("key")
115
+
if key == "" {
116
+
w.WriteHeader(http.StatusBadRequest)
117
+
return
118
+
}
119
+
120
+
switch r.Method {
121
+
case http.MethodPut:
122
+
errorId := "add-secret-error"
123
+
124
+
value := r.FormValue("value")
125
+
if value == "" {
126
+
w.WriteHeader(http.StatusBadRequest)
127
+
return
128
+
}
129
+
130
+
err = tangled.RepoAddSecret(
131
+
r.Context(),
132
+
spindleClient,
133
+
&tangled.RepoAddSecret_Input{
134
+
Repo: f.RepoAt().String(),
135
+
Key: key,
136
+
Value: value,
137
+
},
138
+
)
139
+
if err != nil {
140
+
l.Error("Failed to add secret.", "err", err)
141
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
142
+
return
143
+
}
144
+
145
+
case http.MethodDelete:
146
+
errorId := "operation-error"
147
+
148
+
err = tangled.RepoRemoveSecret(
149
+
r.Context(),
150
+
spindleClient,
151
+
&tangled.RepoRemoveSecret_Input{
152
+
Repo: f.RepoAt().String(),
153
+
Key: key,
154
+
},
155
+
)
156
+
if err != nil {
157
+
l.Error("Failed to delete secret.", "err", err)
158
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
159
+
return
160
+
}
161
+
}
162
+
163
+
rp.pages.HxRefresh(w)
164
+
}
165
+
166
+
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
167
+
tabVal := r.URL.Query().Get("tab")
168
+
if tabVal == "" {
169
+
tabVal = "general"
170
+
}
171
+
172
+
switch tabVal {
173
+
case "general":
174
+
rp.generalSettings(w, r)
175
+
176
+
case "access":
177
+
rp.accessSettings(w, r)
178
+
179
+
case "pipelines":
180
+
rp.pipelineSettings(w, r)
181
+
}
182
+
}
183
+
184
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
185
+
l := rp.logger.With("handler", "generalSettings")
186
+
187
+
f, err := rp.repoResolver.Resolve(r)
188
+
user := rp.oauth.GetUser(r)
189
+
190
+
scheme := "http"
191
+
if !rp.config.Core.Dev {
192
+
scheme = "https"
193
+
}
194
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
195
+
xrpcc := &indigoxrpc.Client{
196
+
Host: host,
197
+
}
198
+
199
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
200
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
201
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
202
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
203
+
rp.pages.Error503(w)
204
+
return
205
+
}
206
+
207
+
var result types.RepoBranchesResponse
208
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
209
+
l.Error("failed to decode XRPC response", "err", err)
210
+
rp.pages.Error503(w)
211
+
return
212
+
}
213
+
214
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
215
+
if err != nil {
216
+
l.Error("failed to fetch labels", "err", err)
217
+
rp.pages.Error503(w)
218
+
return
219
+
}
220
+
221
+
labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels))
222
+
if err != nil {
223
+
l.Error("failed to fetch labels", "err", err)
224
+
rp.pages.Error503(w)
225
+
return
226
+
}
227
+
// remove default labels from the labels list, if present
228
+
defaultLabelMap := make(map[string]bool)
229
+
for _, dl := range defaultLabels {
230
+
defaultLabelMap[dl.AtUri().String()] = true
231
+
}
232
+
n := 0
233
+
for _, l := range labels {
234
+
if !defaultLabelMap[l.AtUri().String()] {
235
+
labels[n] = l
236
+
n++
237
+
}
238
+
}
239
+
labels = labels[:n]
240
+
241
+
subscribedLabels := make(map[string]struct{})
242
+
for _, l := range f.Labels {
243
+
subscribedLabels[l] = struct{}{}
244
+
}
245
+
246
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
247
+
// if all default labels are subbed, show the "unsubscribe all" button
248
+
shouldSubscribeAll := false
249
+
for _, dl := range defaultLabels {
250
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
251
+
// one of the default labels is not subscribed to
252
+
shouldSubscribeAll = true
253
+
break
254
+
}
255
+
}
256
+
257
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
258
+
LoggedInUser: user,
259
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
260
+
Branches: result.Branches,
261
+
Labels: labels,
262
+
DefaultLabels: defaultLabels,
263
+
SubscribedLabels: subscribedLabels,
264
+
ShouldSubscribeAll: shouldSubscribeAll,
265
+
Tabs: settingsTabs,
266
+
Tab: "general",
267
+
})
268
+
}
269
+
270
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
271
+
l := rp.logger.With("handler", "accessSettings")
272
+
273
+
f, err := rp.repoResolver.Resolve(r)
274
+
user := rp.oauth.GetUser(r)
275
+
276
+
collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) {
277
+
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot)
278
+
if err != nil {
279
+
return nil, err
280
+
}
281
+
var collaborators []pages.Collaborator
282
+
for _, item := range repoCollaborators {
283
+
// currently only two roles: owner and member
284
+
var role string
285
+
switch item[3] {
286
+
case "repo:owner":
287
+
role = "owner"
288
+
case "repo:collaborator":
289
+
role = "collaborator"
290
+
default:
291
+
continue
292
+
}
293
+
294
+
did := item[0]
295
+
296
+
c := pages.Collaborator{
297
+
Did: did,
298
+
Role: role,
299
+
}
300
+
collaborators = append(collaborators, c)
301
+
}
302
+
return collaborators, nil
303
+
}(f)
304
+
if err != nil {
305
+
l.Error("failed to get collaborators", "err", err)
306
+
}
307
+
308
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
309
+
LoggedInUser: user,
310
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
311
+
Tabs: settingsTabs,
312
+
Tab: "access",
313
+
Collaborators: collaborators,
314
+
})
315
+
}
316
+
317
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
318
+
l := rp.logger.With("handler", "pipelineSettings")
319
+
320
+
f, err := rp.repoResolver.Resolve(r)
321
+
user := rp.oauth.GetUser(r)
322
+
323
+
// all spindles that the repo owner is a member of
324
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
325
+
if err != nil {
326
+
l.Error("failed to fetch spindles", "err", err)
327
+
return
328
+
}
329
+
330
+
var secrets []*tangled.RepoListSecrets_Secret
331
+
if f.Spindle != "" {
332
+
if spindleClient, err := rp.oauth.ServiceClient(
333
+
r,
334
+
oauth.WithService(f.Spindle),
335
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
336
+
oauth.WithExp(60),
337
+
oauth.WithDev(rp.config.Core.Dev),
338
+
); err != nil {
339
+
l.Error("failed to create spindle client", "err", err)
340
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
341
+
l.Error("failed to fetch secrets", "err", err)
342
+
} else {
343
+
secrets = resp.Secrets
344
+
}
345
+
}
346
+
347
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
348
+
return strings.Compare(a.Key, b.Key)
349
+
})
350
+
351
+
var dids []string
352
+
for _, s := range secrets {
353
+
dids = append(dids, s.CreatedBy)
354
+
}
355
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
356
+
357
+
// convert to a more manageable form
358
+
var niceSecret []map[string]any
359
+
for id, s := range secrets {
360
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
361
+
niceSecret = append(niceSecret, map[string]any{
362
+
"Id": id,
363
+
"Key": s.Key,
364
+
"CreatedAt": when,
365
+
"CreatedBy": resolvedIdents[id].Handle.String(),
366
+
})
367
+
}
368
+
369
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
370
+
LoggedInUser: user,
371
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
372
+
Tabs: settingsTabs,
373
+
Tab: "pipelines",
374
+
Spindles: spindles,
375
+
CurrentSpindle: f.Spindle,
376
+
Secrets: niceSecret,
377
+
})
378
+
}
379
+
380
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
381
+
l := rp.logger.With("handler", "EditBaseSettings")
382
+
383
+
noticeId := "repo-base-settings-error"
384
+
385
+
f, err := rp.repoResolver.Resolve(r)
386
+
if err != nil {
387
+
l.Error("failed to get repo and knot", "err", err)
388
+
w.WriteHeader(http.StatusBadRequest)
389
+
return
390
+
}
391
+
392
+
client, err := rp.oauth.AuthorizedClient(r)
393
+
if err != nil {
394
+
l.Error("failed to get client")
395
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
396
+
return
397
+
}
398
+
399
+
var (
400
+
description = r.FormValue("description")
401
+
website = r.FormValue("website")
402
+
topicStr = r.FormValue("topics")
403
+
)
404
+
405
+
err = rp.validator.ValidateURI(website)
406
+
if website != "" && err != nil {
407
+
l.Error("invalid uri", "err", err)
408
+
rp.pages.Notice(w, noticeId, err.Error())
409
+
return
410
+
}
411
+
412
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
413
+
if err != nil {
414
+
l.Error("invalid topics", "err", err)
415
+
rp.pages.Notice(w, noticeId, err.Error())
416
+
return
417
+
}
418
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
419
+
420
+
newRepo := *f
421
+
newRepo.Description = description
422
+
newRepo.Website = website
423
+
newRepo.Topics = topics
424
+
record := newRepo.AsRecord()
425
+
426
+
tx, err := rp.db.BeginTx(r.Context(), nil)
427
+
if err != nil {
428
+
l.Error("failed to begin transaction", "err", err)
429
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
430
+
return
431
+
}
432
+
defer tx.Rollback()
433
+
434
+
err = db.PutRepo(tx, newRepo)
435
+
if err != nil {
436
+
l.Error("failed to update repository", "err", err)
437
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
438
+
return
439
+
}
440
+
441
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
442
+
if err != nil {
443
+
// failed to get record
444
+
l.Error("failed to get repo record", "err", err)
445
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
446
+
return
447
+
}
448
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
449
+
Collection: tangled.RepoNSID,
450
+
Repo: newRepo.Did,
451
+
Rkey: newRepo.Rkey,
452
+
SwapRecord: ex.Cid,
453
+
Record: &lexutil.LexiconTypeDecoder{
454
+
Val: &record,
455
+
},
456
+
})
457
+
458
+
if err != nil {
459
+
l.Error("failed to perferom update-repo query", "err", err)
460
+
// failed to get record
461
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
462
+
return
463
+
}
464
+
465
+
err = tx.Commit()
466
+
if err != nil {
467
+
l.Error("failed to commit", "err", err)
468
+
}
469
+
470
+
rp.pages.HxRefresh(w)
471
+
}
+108
appview/repo/tree.go
+108
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
+
"tangled.org/core/appview/reporesolver"
13
+
xrpcclient "tangled.org/core/appview/xrpcclient"
14
+
"tangled.org/core/types"
15
+
16
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
17
+
"github.com/go-chi/chi/v5"
18
+
"github.com/go-git/go-git/v5/plumbing"
19
+
)
20
+
21
+
func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) {
22
+
l := rp.logger.With("handler", "RepoTree")
23
+
f, err := rp.repoResolver.Resolve(r)
24
+
if err != nil {
25
+
l.Error("failed to fully resolve repo", "err", err)
26
+
return
27
+
}
28
+
ref := chi.URLParam(r, "ref")
29
+
ref, _ = url.PathUnescape(ref)
30
+
// if the tree path has a trailing slash, let's strip it
31
+
// so we don't 404
32
+
treePath := chi.URLParam(r, "*")
33
+
treePath, _ = url.PathUnescape(treePath)
34
+
treePath = strings.TrimSuffix(treePath, "/")
35
+
scheme := "http"
36
+
if !rp.config.Core.Dev {
37
+
scheme = "https"
38
+
}
39
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
40
+
xrpcc := &indigoxrpc.Client{
41
+
Host: host,
42
+
}
43
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
44
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
45
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
46
+
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
47
+
rp.pages.Error503(w)
48
+
return
49
+
}
50
+
// Convert XRPC response to internal types.RepoTreeResponse
51
+
files := make([]types.NiceTree, len(xrpcResp.Files))
52
+
for i, xrpcFile := range xrpcResp.Files {
53
+
file := types.NiceTree{
54
+
Name: xrpcFile.Name,
55
+
Mode: xrpcFile.Mode,
56
+
Size: int64(xrpcFile.Size),
57
+
}
58
+
// Convert last commit info if present
59
+
if xrpcFile.Last_commit != nil {
60
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
61
+
file.LastCommit = &types.LastCommitInfo{
62
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
63
+
Message: xrpcFile.Last_commit.Message,
64
+
When: commitWhen,
65
+
}
66
+
}
67
+
files[i] = file
68
+
}
69
+
result := types.RepoTreeResponse{
70
+
Ref: xrpcResp.Ref,
71
+
Files: files,
72
+
}
73
+
if xrpcResp.Parent != nil {
74
+
result.Parent = *xrpcResp.Parent
75
+
}
76
+
if xrpcResp.Dotdot != nil {
77
+
result.DotDot = *xrpcResp.Dotdot
78
+
}
79
+
if xrpcResp.Readme != nil {
80
+
result.ReadmeFileName = xrpcResp.Readme.Filename
81
+
result.Readme = xrpcResp.Readme.Contents
82
+
}
83
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
84
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
85
+
// so we can safely redirect to the "parent" (which is the same file).
86
+
if len(result.Files) == 0 && result.Parent == treePath {
87
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(ref), result.Parent)
88
+
http.Redirect(w, r, redirectTo, http.StatusFound)
89
+
return
90
+
}
91
+
user := rp.oauth.GetUser(r)
92
+
var breadcrumbs [][]string
93
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))})
94
+
if treePath != "" {
95
+
for idx, elem := range strings.Split(treePath, "/") {
96
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
97
+
}
98
+
}
99
+
sortFiles(result.Files)
100
+
101
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
102
+
LoggedInUser: user,
103
+
BreadCrumbs: breadcrumbs,
104
+
TreePath: treePath,
105
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
106
+
RepoTreeResponse: result,
107
+
})
108
+
}
+98
-159
appview/reporesolver/resolver.go
+98
-159
appview/reporesolver/resolver.go
···
1
1
package reporesolver
2
2
3
3
import (
4
-
"context"
5
-
"database/sql"
6
-
"errors"
7
4
"fmt"
8
5
"log"
9
6
"net/http"
···
12
9
"strings"
13
10
14
11
"github.com/bluesky-social/indigo/atproto/identity"
15
-
securejoin "github.com/cyphar/filepath-securejoin"
16
12
"github.com/go-chi/chi/v5"
17
13
"tangled.org/core/appview/config"
18
14
"tangled.org/core/appview/db"
19
15
"tangled.org/core/appview/models"
20
16
"tangled.org/core/appview/oauth"
21
-
"tangled.org/core/appview/pages"
22
17
"tangled.org/core/appview/pages/repoinfo"
23
-
"tangled.org/core/idresolver"
24
18
"tangled.org/core/rbac"
25
19
)
26
20
27
-
type ResolvedRepo struct {
28
-
models.Repo
29
-
OwnerId identity.Identity
30
-
CurrentDir string
31
-
Ref string
32
-
33
-
rr *RepoResolver
21
+
type RepoResolver struct {
22
+
config *config.Config
23
+
enforcer *rbac.Enforcer
24
+
execer db.Execer
34
25
}
35
26
36
-
type RepoResolver struct {
37
-
config *config.Config
38
-
enforcer *rbac.Enforcer
39
-
idResolver *idresolver.Resolver
40
-
execer db.Execer
27
+
func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer) *RepoResolver {
28
+
return &RepoResolver{config: config, enforcer: enforcer, execer: execer}
41
29
}
42
30
43
-
func New(config *config.Config, enforcer *rbac.Enforcer, resolver *idresolver.Resolver, execer db.Execer) *RepoResolver {
44
-
return &RepoResolver{config: config, enforcer: enforcer, idResolver: resolver, execer: execer}
31
+
// NOTE: this... should not even be here. the entire package will be removed in future refactor
32
+
func GetBaseRepoPath(r *http.Request, repo *models.Repo) string {
33
+
var (
34
+
user = chi.URLParam(r, "user")
35
+
name = chi.URLParam(r, "repo")
36
+
)
37
+
if user == "" || name == "" {
38
+
return repo.DidSlashRepo()
39
+
}
40
+
return path.Join(user, name)
45
41
}
46
42
47
-
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
43
+
// TODO: move this out of `RepoResolver` struct
44
+
func (rr *RepoResolver) Resolve(r *http.Request) (*models.Repo, error) {
48
45
repo, ok := r.Context().Value("repo").(*models.Repo)
49
46
if !ok {
50
47
log.Println("malformed middleware: `repo` not exist in context")
51
48
return nil, fmt.Errorf("malformed middleware")
52
49
}
53
-
id, ok := r.Context().Value("resolvedId").(identity.Identity)
54
-
if !ok {
55
-
log.Println("malformed middleware")
56
-
return nil, fmt.Errorf("malformed middleware")
57
-
}
58
50
59
-
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath()))
60
-
ref := chi.URLParam(r, "ref")
61
-
62
-
return &ResolvedRepo{
63
-
Repo: *repo,
64
-
OwnerId: id,
65
-
CurrentDir: currentDir,
66
-
Ref: ref,
67
-
68
-
rr: rr,
69
-
}, nil
51
+
return repo, nil
70
52
}
71
53
72
-
func (f *ResolvedRepo) OwnerDid() string {
73
-
return f.OwnerId.DID.String()
74
-
}
75
-
76
-
func (f *ResolvedRepo) OwnerHandle() string {
77
-
return f.OwnerId.Handle.String()
78
-
}
79
-
80
-
func (f *ResolvedRepo) OwnerSlashRepo() string {
81
-
handle := f.OwnerId.Handle
82
-
83
-
var p string
84
-
if handle != "" && !handle.IsInvalidHandle() {
85
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name)
86
-
} else {
87
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
54
+
// 1. [x] replace `RepoInfo` to `reporesolver.GetRepoInfo(r *http.Request, repo, user)`
55
+
// 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo`
56
+
// 3. [x] remove `ResolvedRepo`
57
+
// 4. [ ] replace reporesolver to reposervice
58
+
func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo {
59
+
ownerId, ook := r.Context().Value("resolvedId").(identity.Identity)
60
+
repo, rok := r.Context().Value("repo").(*models.Repo)
61
+
if !ook || !rok {
62
+
log.Println("malformed request, failed to get repo from context")
88
63
}
89
64
90
-
return p
91
-
}
65
+
// get dir/ref
66
+
currentDir := extractCurrentDir(r.URL.EscapedPath())
67
+
ref := chi.URLParam(r, "ref")
92
68
93
-
func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) {
94
-
repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
95
-
if err != nil {
96
-
return nil, err
69
+
repoAt := repo.RepoAt()
70
+
isStarred := false
71
+
roles := repoinfo.RolesInRepo{}
72
+
if user != nil {
73
+
isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt)
74
+
roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo())
97
75
}
98
76
99
-
var collaborators []pages.Collaborator
100
-
for _, item := range repoCollaborators {
101
-
// currently only two roles: owner and member
102
-
var role string
103
-
switch item[3] {
104
-
case "repo:owner":
105
-
role = "owner"
106
-
case "repo:collaborator":
107
-
role = "collaborator"
108
-
default:
109
-
continue
77
+
stats := repo.RepoStats
78
+
if stats == nil {
79
+
starCount, err := db.GetStarCount(rr.execer, repoAt)
80
+
if err != nil {
81
+
log.Println("failed to get star count for ", repoAt)
110
82
}
111
-
112
-
did := item[0]
113
-
114
-
c := pages.Collaborator{
115
-
Did: did,
116
-
Handle: "",
117
-
Role: role,
83
+
issueCount, err := db.GetIssueCount(rr.execer, repoAt)
84
+
if err != nil {
85
+
log.Println("failed to get issue count for ", repoAt)
118
86
}
119
-
collaborators = append(collaborators, c)
120
-
}
121
-
122
-
// populate all collborators with handles
123
-
identsToResolve := make([]string, len(collaborators))
124
-
for i, collab := range collaborators {
125
-
identsToResolve[i] = collab.Did
126
-
}
127
-
128
-
resolvedIdents := f.rr.idResolver.ResolveIdents(ctx, identsToResolve)
129
-
for i, resolved := range resolvedIdents {
130
-
if resolved != nil {
131
-
collaborators[i].Handle = resolved.Handle.String()
87
+
pullCount, err := db.GetPullCount(rr.execer, repoAt)
88
+
if err != nil {
89
+
log.Println("failed to get pull count for ", repoAt)
90
+
}
91
+
stats = &models.RepoStats{
92
+
StarCount: starCount,
93
+
IssueCount: issueCount,
94
+
PullCount: pullCount,
132
95
}
133
96
}
134
97
135
-
return collaborators, nil
136
-
}
137
-
138
-
// this function is a bit weird since it now returns RepoInfo from an entirely different
139
-
// package. we should refactor this or get rid of RepoInfo entirely.
140
-
func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo {
141
-
repoAt := f.RepoAt()
142
-
isStarred := false
143
-
if user != nil {
144
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt)
145
-
}
146
-
147
-
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
148
-
if err != nil {
149
-
log.Println("failed to get star count for ", repoAt)
150
-
}
151
-
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
152
-
if err != nil {
153
-
log.Println("failed to get issue count for ", repoAt)
154
-
}
155
-
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
156
-
if err != nil {
157
-
log.Println("failed to get issue count for ", repoAt)
158
-
}
159
-
source, err := db.GetRepoSource(f.rr.execer, repoAt)
160
-
if errors.Is(err, sql.ErrNoRows) {
161
-
source = ""
162
-
} else if err != nil {
163
-
log.Println("failed to get repo source for ", repoAt, err)
164
-
}
165
-
166
98
var sourceRepo *models.Repo
167
-
if source != "" {
168
-
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
99
+
var err error
100
+
if repo.Source != "" {
101
+
sourceRepo, err = db.GetRepoByAtUri(rr.execer, repo.Source)
169
102
if err != nil {
170
103
log.Println("failed to get repo by at uri", err)
171
104
}
172
105
}
173
106
174
-
var sourceHandle *identity.Identity
175
-
if sourceRepo != nil {
176
-
sourceHandle, err = f.rr.idResolver.ResolveIdent(context.Background(), sourceRepo.Did)
177
-
if err != nil {
178
-
log.Println("failed to resolve source repo", err)
179
-
}
180
-
}
107
+
repoInfo := repoinfo.RepoInfo{
108
+
// this is basically a models.Repo
109
+
OwnerDid: ownerId.DID.String(),
110
+
OwnerHandle: ownerId.Handle.String(),
111
+
Name: repo.Name,
112
+
Rkey: repo.Rkey,
113
+
Description: repo.Description,
114
+
Website: repo.Website,
115
+
Topics: repo.Topics,
116
+
Knot: repo.Knot,
117
+
Spindle: repo.Spindle,
118
+
Stats: *stats,
181
119
182
-
knot := f.Knot
120
+
// fork repo upstream
121
+
Source: sourceRepo,
183
122
184
-
repoInfo := repoinfo.RepoInfo{
185
-
OwnerDid: f.OwnerDid(),
186
-
OwnerHandle: f.OwnerHandle(),
187
-
Name: f.Name,
188
-
Rkey: f.Repo.Rkey,
189
-
RepoAt: repoAt,
190
-
Description: f.Description,
191
-
IsStarred: isStarred,
192
-
Knot: knot,
193
-
Spindle: f.Spindle,
194
-
Roles: f.RolesInRepo(user),
195
-
Stats: models.RepoStats{
196
-
StarCount: starCount,
197
-
IssueCount: issueCount,
198
-
PullCount: pullCount,
199
-
},
200
-
CurrentDir: f.CurrentDir,
201
-
Ref: f.Ref,
202
-
}
123
+
// page context
124
+
CurrentDir: currentDir,
125
+
Ref: ref,
203
126
204
-
if sourceRepo != nil {
205
-
repoInfo.Source = sourceRepo
206
-
repoInfo.SourceHandle = sourceHandle.Handle.String()
127
+
// info related to the session
128
+
IsStarred: isStarred,
129
+
Roles: roles,
207
130
}
208
131
209
132
return repoInfo
210
133
}
211
134
212
-
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
213
-
if u != nil {
214
-
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
215
-
return repoinfo.RolesInRepo{Roles: r}
216
-
} else {
217
-
return repoinfo.RolesInRepo{}
135
+
// extractCurrentDir gets the current directory for markdown link resolution.
136
+
// for blob paths, returns the parent dir. for tree paths, returns the path itself.
137
+
//
138
+
// /@user/repo/blob/main/docs/README.md => docs
139
+
// /@user/repo/tree/main/docs => docs
140
+
func extractCurrentDir(fullPath string) string {
141
+
fullPath = strings.TrimPrefix(fullPath, "/")
142
+
143
+
blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`)
144
+
if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 {
145
+
return path.Dir(matches[1])
218
146
}
147
+
148
+
treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`)
149
+
if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 {
150
+
dir := strings.TrimSuffix(matches[1], "/")
151
+
if dir == "" {
152
+
return "."
153
+
}
154
+
return dir
155
+
}
156
+
157
+
return "."
219
158
}
220
159
221
160
// extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
+22
appview/reporesolver/resolver_test.go
···
1
+
package reporesolver
2
+
3
+
import "testing"
4
+
5
+
func TestExtractCurrentDir(t *testing.T) {
6
+
tests := []struct {
7
+
path string
8
+
want string
9
+
}{
10
+
{"/@user/repo/blob/main/docs/README.md", "docs"},
11
+
{"/@user/repo/blob/main/README.md", "."},
12
+
{"/@user/repo/tree/main/docs", "docs"},
13
+
{"/@user/repo/tree/main/docs/", "docs"},
14
+
{"/@user/repo/tree/main", "."},
15
+
}
16
+
17
+
for _, tt := range tests {
18
+
if got := extractCurrentDir(tt.path); got != tt.want {
19
+
t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want)
20
+
}
21
+
}
22
+
}
+5
-4
appview/serververify/verify.go
+5
-4
appview/serververify/verify.go
···
9
9
"tangled.org/core/api/tangled"
10
10
"tangled.org/core/appview/db"
11
11
"tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/orm"
12
13
"tangled.org/core/rbac"
13
14
)
14
15
···
76
77
// mark this spindle as verified in the db
77
78
rowId, err := db.VerifySpindle(
78
79
tx,
79
-
db.FilterEq("owner", owner),
80
-
db.FilterEq("instance", instance),
80
+
orm.FilterEq("owner", owner),
81
+
orm.FilterEq("instance", instance),
81
82
)
82
83
if err != nil {
83
84
return 0, fmt.Errorf("failed to write to DB: %w", err)
···
115
116
// mark as registered
116
117
err = db.MarkRegistered(
117
118
tx,
118
-
db.FilterEq("did", owner),
119
-
db.FilterEq("domain", domain),
119
+
orm.FilterEq("did", owner),
120
+
orm.FilterEq("domain", domain),
120
121
)
121
122
if err != nil {
122
123
return fmt.Errorf("failed to register domain: %w", err)
+3
appview/settings/settings.go
+3
appview/settings/settings.go
···
43
43
{"Name": "keys", "Icon": "key"},
44
44
{"Name": "emails", "Icon": "mail"},
45
45
{"Name": "notifications", "Icon": "bell"},
46
+
{"Name": "knots", "Icon": "volleyball"},
47
+
{"Name": "spindles", "Icon": "spool"},
46
48
}
47
49
)
48
50
···
120
122
PullCommented: r.FormValue("pull_commented") == "on",
121
123
PullMerged: r.FormValue("pull_merged") == "on",
122
124
Followed: r.FormValue("followed") == "on",
125
+
UserMentioned: r.FormValue("user_mentioned") == "on",
123
126
EmailNotifications: r.FormValue("email_notifications") == "on",
124
127
}
125
128
+53
-31
appview/spindles/spindles.go
+53
-31
appview/spindles/spindles.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
19
20
"tangled.org/core/appview/serververify"
20
21
"tangled.org/core/appview/xrpcclient"
21
22
"tangled.org/core/idresolver"
23
+
"tangled.org/core/orm"
22
24
"tangled.org/core/rbac"
23
25
"tangled.org/core/tid"
24
26
···
37
39
Logger *slog.Logger
38
40
}
39
41
42
+
type tab = map[string]any
43
+
44
+
var (
45
+
spindlesTabs []tab = []tab{
46
+
{"Name": "profile", "Icon": "user"},
47
+
{"Name": "keys", "Icon": "key"},
48
+
{"Name": "emails", "Icon": "mail"},
49
+
{"Name": "notifications", "Icon": "bell"},
50
+
{"Name": "knots", "Icon": "volleyball"},
51
+
{"Name": "spindles", "Icon": "spool"},
52
+
}
53
+
)
54
+
40
55
func (s *Spindles) Router() http.Handler {
41
56
r := chi.NewRouter()
42
57
···
57
72
user := s.OAuth.GetUser(r)
58
73
all, err := db.GetSpindles(
59
74
s.Db,
60
-
db.FilterEq("owner", user.Did),
75
+
orm.FilterEq("owner", user.Did),
61
76
)
62
77
if err != nil {
63
78
s.Logger.Error("failed to fetch spindles", "err", err)
···
68
83
s.Pages.Spindles(w, pages.SpindlesParams{
69
84
LoggedInUser: user,
70
85
Spindles: all,
86
+
Tabs: spindlesTabs,
87
+
Tab: "spindles",
71
88
})
72
89
}
73
90
···
85
102
86
103
spindles, err := db.GetSpindles(
87
104
s.Db,
88
-
db.FilterEq("instance", instance),
89
-
db.FilterEq("owner", user.Did),
90
-
db.FilterIsNot("verified", "null"),
105
+
orm.FilterEq("instance", instance),
106
+
orm.FilterEq("owner", user.Did),
107
+
orm.FilterIsNot("verified", "null"),
91
108
)
92
109
if err != nil || len(spindles) != 1 {
93
110
l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles))
···
107
124
repos, err := db.GetRepos(
108
125
s.Db,
109
126
0,
110
-
db.FilterEq("spindle", instance),
127
+
orm.FilterEq("spindle", instance),
111
128
)
112
129
if err != nil {
113
130
l.Error("failed to get spindle repos", "err", err)
···
126
143
Spindle: spindle,
127
144
Members: members,
128
145
Repos: repoMap,
146
+
Tabs: spindlesTabs,
147
+
Tab: "spindles",
129
148
})
130
149
}
131
150
···
146
165
}
147
166
148
167
instance := r.FormValue("instance")
168
+
// Strip protocol, trailing slashes, and whitespace
169
+
// Rkey cannot contain slashes
170
+
instance = strings.TrimSpace(instance)
171
+
instance = strings.TrimPrefix(instance, "https://")
172
+
instance = strings.TrimPrefix(instance, "http://")
173
+
instance = strings.TrimSuffix(instance, "/")
149
174
if instance == "" {
150
175
s.Pages.Notice(w, noticeId, "Incomplete form.")
151
176
return
···
266
291
267
292
spindles, err := db.GetSpindles(
268
293
s.Db,
269
-
db.FilterEq("owner", user.Did),
270
-
db.FilterEq("instance", instance),
294
+
orm.FilterEq("owner", user.Did),
295
+
orm.FilterEq("instance", instance),
271
296
)
272
297
if err != nil || len(spindles) != 1 {
273
298
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
295
320
// remove spindle members first
296
321
err = db.RemoveSpindleMember(
297
322
tx,
298
-
db.FilterEq("did", user.Did),
299
-
db.FilterEq("instance", instance),
323
+
orm.FilterEq("did", user.Did),
324
+
orm.FilterEq("instance", instance),
300
325
)
301
326
if err != nil {
302
327
l.Error("failed to remove spindle members", "err", err)
···
306
331
307
332
err = db.DeleteSpindle(
308
333
tx,
309
-
db.FilterEq("owner", user.Did),
310
-
db.FilterEq("instance", instance),
334
+
orm.FilterEq("owner", user.Did),
335
+
orm.FilterEq("instance", instance),
311
336
)
312
337
if err != nil {
313
338
l.Error("failed to delete spindle", "err", err)
···
358
383
359
384
shouldRedirect := r.Header.Get("shouldRedirect")
360
385
if shouldRedirect == "true" {
361
-
s.Pages.HxRedirect(w, "/spindles")
386
+
s.Pages.HxRedirect(w, "/settings/spindles")
362
387
return
363
388
}
364
389
···
386
411
387
412
spindles, err := db.GetSpindles(
388
413
s.Db,
389
-
db.FilterEq("owner", user.Did),
390
-
db.FilterEq("instance", instance),
414
+
orm.FilterEq("owner", user.Did),
415
+
orm.FilterEq("instance", instance),
391
416
)
392
417
if err != nil || len(spindles) != 1 {
393
418
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
429
454
430
455
verifiedSpindle, err := db.GetSpindles(
431
456
s.Db,
432
-
db.FilterEq("id", rowId),
457
+
orm.FilterEq("id", rowId),
433
458
)
434
459
if err != nil || len(verifiedSpindle) != 1 {
435
460
l.Error("failed get new spindle", "err", err)
···
462
487
463
488
spindles, err := db.GetSpindles(
464
489
s.Db,
465
-
db.FilterEq("owner", user.Did),
466
-
db.FilterEq("instance", instance),
490
+
orm.FilterEq("owner", user.Did),
491
+
orm.FilterEq("instance", instance),
467
492
)
468
493
if err != nil || len(spindles) != 1 {
469
494
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
484
509
}
485
510
486
511
member := r.FormValue("member")
512
+
member = strings.TrimPrefix(member, "@")
487
513
if member == "" {
488
514
l.Error("empty member")
489
515
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
573
599
}
574
600
575
601
// success
576
-
s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance))
602
+
s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance))
577
603
}
578
604
579
605
func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
···
597
623
598
624
spindles, err := db.GetSpindles(
599
625
s.Db,
600
-
db.FilterEq("owner", user.Did),
601
-
db.FilterEq("instance", instance),
626
+
orm.FilterEq("owner", user.Did),
627
+
orm.FilterEq("instance", instance),
602
628
)
603
629
if err != nil || len(spindles) != 1 {
604
630
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
613
639
}
614
640
615
641
member := r.FormValue("member")
642
+
member = strings.TrimPrefix(member, "@")
616
643
if member == "" {
617
644
l.Error("empty member")
618
645
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
626
653
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
627
654
return
628
655
}
629
-
if memberId.Handle.IsInvalidHandle() {
630
-
l.Error("failed to resolve member identity to handle")
631
-
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
632
-
return
633
-
}
634
656
635
657
tx, err := s.Db.Begin()
636
658
if err != nil {
···
646
668
// get the record from the DB first:
647
669
members, err := db.GetSpindleMembers(
648
670
s.Db,
649
-
db.FilterEq("did", user.Did),
650
-
db.FilterEq("instance", instance),
651
-
db.FilterEq("subject", memberId.DID),
671
+
orm.FilterEq("did", user.Did),
672
+
orm.FilterEq("instance", instance),
673
+
orm.FilterEq("subject", memberId.DID),
652
674
)
653
675
if err != nil || len(members) != 1 {
654
676
l.Error("failed to get member", "err", err)
···
659
681
// remove from db
660
682
if err = db.RemoveSpindleMember(
661
683
tx,
662
-
db.FilterEq("did", user.Did),
663
-
db.FilterEq("instance", instance),
664
-
db.FilterEq("subject", memberId.DID),
684
+
orm.FilterEq("did", user.Did),
685
+
orm.FilterEq("instance", instance),
686
+
orm.FilterEq("subject", memberId.DID),
665
687
); err != nil {
666
688
l.Error("failed to remove spindle member", "err", err)
667
689
fail()
+15
-8
appview/state/gfi.go
+15
-8
appview/state/gfi.go
···
1
1
package state
2
2
3
3
import (
4
-
"fmt"
5
4
"log"
6
5
"net/http"
7
6
"sort"
8
7
9
8
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.org/core/api/tangled"
11
9
"tangled.org/core/appview/db"
12
10
"tangled.org/core/appview/models"
13
11
"tangled.org/core/appview/pages"
14
12
"tangled.org/core/appview/pagination"
15
13
"tangled.org/core/consts"
14
+
"tangled.org/core/orm"
16
15
)
17
16
18
17
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
···
20
19
21
20
page := pagination.FromContext(r.Context())
22
21
23
-
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
22
+
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
23
+
24
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", goodFirstIssueLabel))
25
+
if err != nil {
26
+
log.Println("failed to get gfi label def", err)
27
+
s.pages.Error500(w)
28
+
return
29
+
}
24
30
25
-
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
31
+
repoLabels, err := db.GetRepoLabels(s.db, orm.FilterEq("label_at", goodFirstIssueLabel))
26
32
if err != nil {
27
33
log.Println("failed to get repo labels", err)
28
34
s.pages.Error503(w)
···
35
41
RepoGroups: []*models.RepoGroup{},
36
42
LabelDefs: make(map[string]*models.LabelDefinition),
37
43
Page: page,
44
+
GfiLabel: gfiLabelDef,
38
45
})
39
46
return
40
47
}
···
49
56
pagination.Page{
50
57
Limit: 500,
51
58
},
52
-
db.FilterIn("repo_at", repoUris),
53
-
db.FilterEq("open", 1),
59
+
orm.FilterIn("repo_at", repoUris),
60
+
orm.FilterEq("open", 1),
54
61
)
55
62
if err != nil {
56
63
log.Println("failed to get issues", err)
···
126
133
}
127
134
128
135
if len(uriList) > 0 {
129
-
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
136
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList))
130
137
if err != nil {
131
138
log.Println("failed to fetch labels", err)
132
139
}
···
143
150
RepoGroups: paginatedGroups,
144
151
LabelDefs: labelDefsMap,
145
152
Page: page,
146
-
GfiLabel: labelDefsMap[goodFirstIssueLabel],
153
+
GfiLabel: gfiLabelDef,
147
154
})
148
155
}
+17
appview/state/git_http.go
+17
appview/state/git_http.go
···
25
25
26
26
}
27
27
28
+
func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) {
29
+
user, ok := r.Context().Value("resolvedId").(identity.Identity)
30
+
if !ok {
31
+
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
+
return
33
+
}
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
+
36
+
scheme := "https"
37
+
if s.config.Core.Dev {
38
+
scheme = "http"
39
+
}
40
+
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
+
s.proxyRequest(w, r, targetURL)
43
+
}
44
+
28
45
func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
29
46
user, ok := r.Context().Value("resolvedId").(identity.Identity)
30
47
if !ok {
+6
-5
appview/state/knotstream.go
+6
-5
appview/state/knotstream.go
···
16
16
ec "tangled.org/core/eventconsumer"
17
17
"tangled.org/core/eventconsumer/cursor"
18
18
"tangled.org/core/log"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/rbac"
20
21
"tangled.org/core/workflow"
21
22
···
30
31
31
32
knots, err := db.GetRegistrations(
32
33
d,
33
-
db.FilterIsNot("registered", "null"),
34
+
orm.FilterIsNot("registered", "null"),
34
35
)
35
36
if err != nil {
36
37
return nil, err
···
143
144
repos, err := db.GetRepos(
144
145
d,
145
146
0,
146
-
db.FilterEq("did", record.RepoDid),
147
-
db.FilterEq("name", record.RepoName),
147
+
orm.FilterEq("did", record.RepoDid),
148
+
orm.FilterEq("name", record.RepoName),
148
149
)
149
150
if err != nil {
150
151
return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err)
···
209
210
repos, err := db.GetRepos(
210
211
d,
211
212
0,
212
-
db.FilterEq("did", record.TriggerMetadata.Repo.Did),
213
-
db.FilterEq("name", record.TriggerMetadata.Repo.Repo),
213
+
orm.FilterEq("did", record.TriggerMetadata.Repo.Did),
214
+
orm.FilterEq("name", record.TriggerMetadata.Repo.Repo),
214
215
)
215
216
if err != nil {
216
217
return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
+1
appview/state/login.go
+1
appview/state/login.go
+29
appview/state/manifest.go
+29
appview/state/manifest.go
···
1
+
package state
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
)
7
+
8
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
9
+
// https://www.w3.org/TR/appmanifest/
10
+
var manifestData = map[string]any{
11
+
"name": "tangled",
12
+
"description": "tightly-knit social coding.",
13
+
"icons": []map[string]string{
14
+
{
15
+
"src": "/static/logos/dolly.svg",
16
+
"sizes": "144x144",
17
+
},
18
+
},
19
+
"start_url": "/",
20
+
"id": "https://tangled.org",
21
+
"display": "standalone",
22
+
"background_color": "#111827",
23
+
"theme_color": "#111827",
24
+
}
25
+
26
+
func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) {
27
+
w.Header().Set("Content-Type", "application/manifest+json")
28
+
json.NewEncoder(w).Encode(manifestData)
29
+
}
+30
-21
appview/state/profile.go
+30
-21
appview/state/profile.go
···
19
19
"tangled.org/core/appview/db"
20
20
"tangled.org/core/appview/models"
21
21
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/orm"
22
23
)
23
24
24
25
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
56
57
return nil, fmt.Errorf("failed to get profile: %w", err)
57
58
}
58
59
59
-
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
60
+
repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did))
60
61
if err != nil {
61
62
return nil, fmt.Errorf("failed to get repo count: %w", err)
62
63
}
63
64
64
-
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
65
+
stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did))
65
66
if err != nil {
66
67
return nil, fmt.Errorf("failed to get string count: %w", err)
67
68
}
68
69
69
-
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
70
+
starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did))
70
71
if err != nil {
71
72
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
73
}
···
86
87
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
87
88
punchcard, err := db.MakePunchcard(
88
89
s.db,
89
-
db.FilterEq("did", did),
90
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
91
-
db.FilterLte("date", now.Format(time.DateOnly)),
90
+
orm.FilterEq("did", did),
91
+
orm.FilterGte("date", startOfYear.Format(time.DateOnly)),
92
+
orm.FilterLte("date", now.Format(time.DateOnly)),
92
93
)
93
94
if err != nil {
94
95
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
···
96
97
97
98
return &pages.ProfileCard{
98
99
UserDid: did,
99
-
UserHandle: ident.Handle.String(),
100
100
Profile: profile,
101
101
FollowStatus: followStatus,
102
102
Stats: pages.ProfileStats{
···
119
119
s.pages.Error500(w)
120
120
return
121
121
}
122
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
122
+
l = l.With("profileDid", profile.UserDid)
123
123
124
124
repos, err := db.GetRepos(
125
125
s.db,
126
126
0,
127
-
db.FilterEq("did", profile.UserDid),
127
+
orm.FilterEq("did", profile.UserDid),
128
128
)
129
129
if err != nil {
130
130
l.Error("failed to fetch repos", "err", err)
···
162
162
l.Error("failed to create timeline", "err", err)
163
163
}
164
164
165
+
// populate commit counts in the timeline, using the punchcard
166
+
now := time.Now()
167
+
for _, p := range profile.Punchcard.Punches {
168
+
years := now.Year() - p.Date.Year()
169
+
months := int(now.Month() - p.Date.Month())
170
+
monthsAgo := years*12 + months
171
+
if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) {
172
+
timeline.ByMonth[monthsAgo].Commits += p.Count
173
+
}
174
+
}
175
+
165
176
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
166
177
LoggedInUser: s.oauth.GetUser(r),
167
178
Card: profile,
···
180
191
s.pages.Error500(w)
181
192
return
182
193
}
183
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
194
+
l = l.With("profileDid", profile.UserDid)
184
195
185
196
repos, err := db.GetRepos(
186
197
s.db,
187
198
0,
188
-
db.FilterEq("did", profile.UserDid),
199
+
orm.FilterEq("did", profile.UserDid),
189
200
)
190
201
if err != nil {
191
202
l.Error("failed to get repos", "err", err)
···
209
220
s.pages.Error500(w)
210
221
return
211
222
}
212
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
223
+
l = l.With("profileDid", profile.UserDid)
213
224
214
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
225
+
stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid))
215
226
if err != nil {
216
227
l.Error("failed to get stars", "err", err)
217
228
s.pages.Error500(w)
···
219
230
}
220
231
var repos []models.Repo
221
232
for _, s := range stars {
222
-
if s.Repo != nil {
223
-
repos = append(repos, *s.Repo)
224
-
}
233
+
repos = append(repos, *s.Repo)
225
234
}
226
235
227
236
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
240
249
s.pages.Error500(w)
241
250
return
242
251
}
243
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
252
+
l = l.With("profileDid", profile.UserDid)
244
253
245
-
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
254
+
strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid))
246
255
if err != nil {
247
256
l.Error("failed to get strings", "err", err)
248
257
s.pages.Error500(w)
···
272
281
if err != nil {
273
282
return nil, err
274
283
}
275
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
284
+
l = l.With("profileDid", profile.UserDid)
276
285
277
286
loggedInUser := s.oauth.GetUser(r)
278
287
params := FollowsPageParams{
···
294
303
followDids = append(followDids, extractDid(follow))
295
304
}
296
305
297
-
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
306
+
profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids))
298
307
if err != nil {
299
308
l.Error("failed to get profiles", "followDids", followDids, "err", err)
300
309
return ¶ms, err
···
697
706
log.Printf("getting profile data for %s: %s", user.Did, err)
698
707
}
699
708
700
-
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
709
+
repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did))
701
710
if err != nil {
702
711
log.Printf("getting repos for %s: %s", user.Did, err)
703
712
}
+47
-36
appview/state/router.go
+47
-36
appview/state/router.go
···
32
32
s.pages,
33
33
)
34
34
35
-
router.Get("/favicon.svg", s.Favicon)
36
-
router.Get("/favicon.ico", s.Favicon)
37
-
router.Get("/pwa-manifest.json", s.PWAManifest)
35
+
router.Get("/pwa-manifest.json", s.WebAppManifest)
38
36
router.Get("/robots.txt", s.RobotsTxt)
39
37
40
38
userRouter := s.UserRouter(&middleware)
···
42
40
43
41
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
42
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
-
}
43
+
pathParts := strings.SplitN(pat, "/", 2)
44
+
45
+
if len(pathParts) > 0 {
46
+
firstPart := pathParts[0]
47
+
48
+
// if using a DID or handle, just continue as per usual
49
+
if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
50
+
userRouter.ServeHTTP(w, r)
51
+
return
52
+
}
53
+
54
+
// if using a flattened DID (like you would in go modules), unflatten
55
+
if userutil.IsFlattenedDid(firstPart) {
56
+
unflattenedDid := userutil.UnflattenDid(firstPart)
57
+
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
58
+
59
+
redirectURL := *r.URL
60
+
redirectURL.Path = "/" + redirectPath
61
+
62
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
63
+
return
64
+
}
65
+
66
+
// if using a handle with @, rewrite to work without @
67
+
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
68
+
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
69
+
70
+
redirectURL := *r.URL
71
+
redirectURL.Path = "/" + redirectPath
72
+
73
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
74
+
return
68
75
}
69
-
standardRouter.ServeHTTP(w, r)
76
+
70
77
}
78
+
79
+
standardRouter.ServeHTTP(w, r)
71
80
})
72
81
73
82
return router
···
80
89
r.Get("/", s.Profile)
81
90
r.Get("/feed.atom", s.AtomFeedPage)
82
91
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
92
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
90
93
r.Use(mw.GoImport())
91
94
r.Mount("/", s.RepoRouter(mw))
···
96
99
97
100
// These routes get proxied to the knot
98
101
r.Get("/info/refs", s.InfoRefs)
102
+
r.Post("/git-upload-archive", s.UploadArchive)
99
103
r.Post("/git-upload-pack", s.UploadPack)
100
104
r.Post("/git-receive-pack", s.ReceivePack)
101
105
···
103
107
})
104
108
105
109
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
110
+
w.WriteHeader(http.StatusNotFound)
106
111
s.pages.Error404(w)
107
112
})
108
113
···
134
139
// r.Post("/import", s.ImportRepo)
135
140
})
136
141
137
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
142
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
138
143
139
144
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
140
145
r.Post("/", s.Follow)
···
161
166
162
167
r.Mount("/settings", s.SettingsRouter())
163
168
r.Mount("/strings", s.StringsRouter(mw))
164
-
r.Mount("/knots", s.KnotsRouter())
165
-
r.Mount("/spindles", s.SpindlesRouter())
169
+
170
+
r.Mount("/settings/knots", s.KnotsRouter())
171
+
r.Mount("/settings/spindles", s.SpindlesRouter())
172
+
166
173
r.Mount("/notifications", s.NotificationsRouter(mw))
167
174
168
175
r.Mount("/signup", s.SignupRouter())
···
174
181
r.Get("/brand", s.Brand)
175
182
176
183
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
184
+
w.WriteHeader(http.StatusNotFound)
177
185
s.pages.Error404(w)
178
186
})
179
187
return r
···
256
264
issues := issues.New(
257
265
s.oauth,
258
266
s.repoResolver,
267
+
s.enforcer,
259
268
s.pages,
260
269
s.idResolver,
270
+
s.mentionsResolver,
261
271
s.db,
262
272
s.config,
263
273
s.notifier,
···
274
284
s.repoResolver,
275
285
s.pages,
276
286
s.idResolver,
287
+
s.mentionsResolver,
277
288
s.db,
278
289
s.config,
279
290
s.notifier,
+2
-1
appview/state/spindlestream.go
+2
-1
appview/state/spindlestream.go
···
17
17
ec "tangled.org/core/eventconsumer"
18
18
"tangled.org/core/eventconsumer/cursor"
19
19
"tangled.org/core/log"
20
+
"tangled.org/core/orm"
20
21
"tangled.org/core/rbac"
21
22
spindle "tangled.org/core/spindle/models"
22
23
)
···
27
28
28
29
spindles, err := db.GetSpindles(
29
30
d,
30
-
db.FilterIsNot("verified", "null"),
31
+
orm.FilterIsNot("verified", "null"),
31
32
)
32
33
if err != nil {
33
34
return nil, err
+9
-13
appview/state/star.go
+9
-13
appview/state/star.go
···
57
57
log.Println("created atproto record: ", resp.Uri)
58
58
59
59
star := &models.Star{
60
-
StarredByDid: currentUser.Did,
61
-
RepoAt: subjectUri,
62
-
Rkey: rkey,
60
+
Did: currentUser.Did,
61
+
RepoAt: subjectUri,
62
+
Rkey: rkey,
63
63
}
64
64
65
65
err = db.AddStar(s.db, star)
···
75
75
76
76
s.notifier.NewStar(r.Context(), star)
77
77
78
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
79
79
IsStarred: true,
80
-
RepoAt: subjectUri,
81
-
Stats: models.RepoStats{
82
-
StarCount: starCount,
83
-
},
80
+
SubjectAt: subjectUri,
81
+
StarCount: starCount,
84
82
})
85
83
86
84
return
···
117
115
118
116
s.notifier.DeleteStar(r.Context(), star)
119
117
120
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
118
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
121
119
IsStarred: false,
122
-
RepoAt: subjectUri,
123
-
Stats: models.RepoStats{
124
-
StarCount: starCount,
125
-
},
120
+
SubjectAt: subjectUri,
121
+
StarCount: starCount,
126
122
})
127
123
128
124
return
+40
-70
appview/state/state.go
+40
-70
appview/state/state.go
···
15
15
"tangled.org/core/appview/config"
16
16
"tangled.org/core/appview/db"
17
17
"tangled.org/core/appview/indexer"
18
+
"tangled.org/core/appview/mentions"
18
19
"tangled.org/core/appview/models"
19
20
"tangled.org/core/appview/notify"
20
21
dbnotify "tangled.org/core/appview/notify/db"
···
29
30
"tangled.org/core/jetstream"
30
31
"tangled.org/core/log"
31
32
tlog "tangled.org/core/log"
33
+
"tangled.org/core/orm"
32
34
"tangled.org/core/rbac"
33
35
"tangled.org/core/tid"
34
36
···
42
44
)
43
45
44
46
type State struct {
45
-
db *db.DB
46
-
notifier notify.Notifier
47
-
indexer *indexer.Indexer
48
-
oauth *oauth.OAuth
49
-
enforcer *rbac.Enforcer
50
-
pages *pages.Pages
51
-
idResolver *idresolver.Resolver
52
-
posthog posthog.Client
53
-
jc *jetstream.JetstreamClient
54
-
config *config.Config
55
-
repoResolver *reporesolver.RepoResolver
56
-
knotstream *eventconsumer.Consumer
57
-
spindlestream *eventconsumer.Consumer
58
-
logger *slog.Logger
59
-
validator *validator.Validator
47
+
db *db.DB
48
+
notifier notify.Notifier
49
+
indexer *indexer.Indexer
50
+
oauth *oauth.OAuth
51
+
enforcer *rbac.Enforcer
52
+
pages *pages.Pages
53
+
idResolver *idresolver.Resolver
54
+
mentionsResolver *mentions.Resolver
55
+
posthog posthog.Client
56
+
jc *jetstream.JetstreamClient
57
+
config *config.Config
58
+
repoResolver *reporesolver.RepoResolver
59
+
knotstream *eventconsumer.Consumer
60
+
spindlestream *eventconsumer.Consumer
61
+
logger *slog.Logger
62
+
validator *validator.Validator
60
63
}
61
64
62
65
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
78
81
return nil, fmt.Errorf("failed to create enforcer: %w", err)
79
82
}
80
83
81
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
84
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
82
85
if err != nil {
83
86
logger.Error("failed to create redis resolver", "err", err)
84
-
res = idresolver.DefaultResolver()
87
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
85
88
}
86
89
87
90
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
96
99
}
97
100
validator := validator.New(d, res, enforcer)
98
101
99
-
repoResolver := reporesolver.New(config, enforcer, res, d)
102
+
repoResolver := reporesolver.New(config, enforcer, d)
103
+
104
+
mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver"))
100
105
101
106
wrapper := db.DbWrapper{Execer: d}
102
107
jc, err := jetstream.NewJetstreamClient(
···
129
134
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
130
135
}
131
136
132
-
if err := BackfillDefaultDefs(d, res); err != nil {
137
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
133
138
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
134
139
}
135
140
···
178
183
enforcer,
179
184
pages,
180
185
res,
186
+
mentionsResolver,
181
187
posthog,
182
188
jc,
183
189
config,
···
196
202
return s.db.Close()
197
203
}
198
204
199
-
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
200
-
w.Header().Set("Content-Type", "image/svg+xml")
201
-
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
202
-
w.Header().Set("ETag", `"favicon-svg-v1"`)
203
-
204
-
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
205
-
w.WriteHeader(http.StatusNotModified)
206
-
return
207
-
}
208
-
209
-
s.pages.Favicon(w)
210
-
}
211
-
212
205
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
213
206
w.Header().Set("Content-Type", "text/plain")
214
207
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
219
212
w.Write([]byte(robotsTxt))
220
213
}
221
214
222
-
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
223
-
const manifestJson = `{
224
-
"name": "tangled",
225
-
"description": "tightly-knit social coding.",
226
-
"icons": [
227
-
{
228
-
"src": "/favicon.svg",
229
-
"sizes": "144x144"
230
-
}
231
-
],
232
-
"start_url": "/",
233
-
"id": "org.tangled",
234
-
235
-
"display": "standalone",
236
-
"background_color": "#111827",
237
-
"theme_color": "#111827"
238
-
}`
239
-
240
-
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
241
-
w.Header().Set("Content-Type", "application/json")
242
-
w.Write([]byte(manifestJson))
243
-
}
244
-
245
215
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
246
216
user := s.oauth.GetUser(r)
247
217
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
294
264
return
295
265
}
296
266
297
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
267
+
gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
268
if err != nil {
299
269
// non-fatal
300
270
}
···
318
288
319
289
regs, err := db.GetRegistrations(
320
290
s.db,
321
-
db.FilterEq("did", user.Did),
322
-
db.FilterEq("needs_upgrade", 1),
291
+
orm.FilterEq("did", user.Did),
292
+
orm.FilterEq("needs_upgrade", 1),
323
293
)
324
294
if err != nil {
325
295
l.Error("non-fatal: failed to get registrations", "err", err)
···
327
297
328
298
spindles, err := db.GetSpindles(
329
299
s.db,
330
-
db.FilterEq("owner", user.Did),
331
-
db.FilterEq("needs_upgrade", 1),
300
+
orm.FilterEq("owner", user.Did),
301
+
orm.FilterEq("needs_upgrade", 1),
332
302
)
333
303
if err != nil {
334
304
l.Error("non-fatal: failed to get spindles", "err", err)
···
386
356
387
357
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
388
358
if err != nil {
389
-
w.WriteHeader(http.StatusNotFound)
359
+
s.logger.Error("failed to get public keys", "err", err)
360
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
390
361
return
391
362
}
392
363
393
364
if len(pubKeys) == 0 {
394
-
w.WriteHeader(http.StatusNotFound)
365
+
w.WriteHeader(http.StatusNoContent)
395
366
return
396
367
}
397
368
···
498
469
// Check for existing repos
499
470
existingRepo, err := db.GetRepo(
500
471
s.db,
501
-
db.FilterEq("did", user.Did),
502
-
db.FilterEq("name", repoName),
472
+
orm.FilterEq("did", user.Did),
473
+
orm.FilterEq("name", repoName),
503
474
)
504
475
if err == nil && existingRepo != nil {
505
476
l.Info("repo exists")
···
516
487
Rkey: rkey,
517
488
Description: description,
518
489
Created: time.Now(),
519
-
Labels: models.DefaultLabelDefs(),
490
+
Labels: s.config.Label.DefaultLabelDefs,
520
491
}
521
492
record := repo.AsRecord()
522
493
···
632
603
aturi = ""
633
604
634
605
s.notifier.NewRepo(r.Context(), repo)
635
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
606
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
636
607
}
637
608
}
638
609
···
658
629
return err
659
630
}
660
631
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))
632
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
633
+
defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults))
664
634
if err != nil {
665
635
return err
666
636
}
···
669
639
return nil
670
640
}
671
641
672
-
labelDefs, err := models.FetchDefaultDefs(r)
642
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
673
643
if err != nil {
674
644
return err
675
645
}
+6
-6
appview/state/userutil/userutil.go
+6
-6
appview/state/userutil/userutil.go
···
10
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
11
)
12
12
13
-
func IsHandleNoAt(s string) bool {
13
+
func IsHandle(s string) bool {
14
14
// ref: https://atproto.com/specs/handle
15
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)
16
21
}
17
22
18
23
func UnflattenDid(s string) string {
···
45
50
return strings.Replace(s, ":", "-", 2)
46
51
}
47
52
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
53
}
54
54
55
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+21
-8
appview/strings/strings.go
+21
-8
appview/strings/strings.go
···
17
17
"tangled.org/core/appview/pages"
18
18
"tangled.org/core/appview/pages/markup"
19
19
"tangled.org/core/idresolver"
20
+
"tangled.org/core/orm"
20
21
"tangled.org/core/tid"
21
22
22
23
"github.com/bluesky-social/indigo/api/atproto"
···
108
109
strings, err := db.GetStrings(
109
110
s.Db,
110
111
0,
111
-
db.FilterEq("did", id.DID),
112
-
db.FilterEq("rkey", rkey),
112
+
orm.FilterEq("did", id.DID),
113
+
orm.FilterEq("rkey", rkey),
113
114
)
114
115
if err != nil {
115
116
l.Error("failed to fetch string", "err", err)
···
148
149
showRendered = r.URL.Query().Get("code") != "true"
149
150
}
150
151
152
+
starCount, err := db.GetStarCount(s.Db, string.AtUri())
153
+
if err != nil {
154
+
l.Error("failed to get star count", "err", err)
155
+
}
156
+
user := s.OAuth.GetUser(r)
157
+
isStarred := false
158
+
if user != nil {
159
+
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
160
+
}
161
+
151
162
s.Pages.SingleString(w, pages.SingleStringParams{
152
-
LoggedInUser: s.OAuth.GetUser(r),
163
+
LoggedInUser: user,
153
164
RenderToggle: renderToggle,
154
165
ShowRendered: showRendered,
155
-
String: string,
166
+
String: &string,
156
167
Stats: string.Stats(),
168
+
IsStarred: isStarred,
169
+
StarCount: starCount,
157
170
Owner: id,
158
171
})
159
172
}
···
187
200
all, err := db.GetStrings(
188
201
s.Db,
189
202
0,
190
-
db.FilterEq("did", id.DID),
191
-
db.FilterEq("rkey", rkey),
203
+
orm.FilterEq("did", id.DID),
204
+
orm.FilterEq("rkey", rkey),
192
205
)
193
206
if err != nil {
194
207
l.Error("failed to fetch string", "err", err)
···
396
409
397
410
if err := db.DeleteString(
398
411
s.Db,
399
-
db.FilterEq("did", user.Did),
400
-
db.FilterEq("rkey", rkey),
412
+
orm.FilterEq("did", user.Did),
413
+
orm.FilterEq("rkey", rkey),
401
414
); err != nil {
402
415
fail("Failed to delete string.", err)
403
416
return
+2
-1
appview/validator/issue.go
+2
-1
appview/validator/issue.go
···
6
6
7
7
"tangled.org/core/appview/db"
8
8
"tangled.org/core/appview/models"
9
+
"tangled.org/core/orm"
9
10
)
10
11
11
12
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
12
13
// if comments have parents, only ingest ones that are 1 level deep
13
14
if comment.ReplyTo != nil {
14
-
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
15
+
parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo))
15
16
if err != nil {
16
17
return fmt.Errorf("failed to fetch parent comment: %w", err)
17
18
}
+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
+
}
+182
cmd/dolly/main.go
+182
cmd/dolly/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"bytes"
5
+
"flag"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"os"
11
+
"path/filepath"
12
+
"strconv"
13
+
"strings"
14
+
"text/template"
15
+
16
+
"github.com/srwiley/oksvg"
17
+
"github.com/srwiley/rasterx"
18
+
"golang.org/x/image/draw"
19
+
"tangled.org/core/appview/pages"
20
+
"tangled.org/core/ico"
21
+
)
22
+
23
+
func main() {
24
+
var (
25
+
size string
26
+
fillColor string
27
+
output string
28
+
)
29
+
30
+
flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)")
31
+
flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)")
32
+
flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)")
33
+
flag.Parse()
34
+
35
+
width, height, err := parseSize(size)
36
+
if err != nil {
37
+
fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err)
38
+
os.Exit(1)
39
+
}
40
+
41
+
// Detect format from file extension
42
+
ext := strings.ToLower(filepath.Ext(output))
43
+
format := strings.TrimPrefix(ext, ".")
44
+
45
+
if format != "svg" && format != "png" && format != "ico" {
46
+
fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext)
47
+
os.Exit(1)
48
+
}
49
+
50
+
if fillColor != "currentColor" && !isValidHexColor(fillColor) {
51
+
fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor)
52
+
os.Exit(1)
53
+
}
54
+
55
+
svgData, err := dolly(fillColor)
56
+
if err != nil {
57
+
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
58
+
os.Exit(1)
59
+
}
60
+
61
+
// Create output directory if it doesn't exist
62
+
dir := filepath.Dir(output)
63
+
if dir != "" && dir != "." {
64
+
if err := os.MkdirAll(dir, 0755); err != nil {
65
+
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
66
+
os.Exit(1)
67
+
}
68
+
}
69
+
70
+
switch format {
71
+
case "svg":
72
+
err = saveSVG(svgData, output, width, height)
73
+
case "png":
74
+
err = savePNG(svgData, output, width, height)
75
+
case "ico":
76
+
err = saveICO(svgData, output, width, height)
77
+
}
78
+
79
+
if err != nil {
80
+
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
81
+
os.Exit(1)
82
+
}
83
+
84
+
fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height)
85
+
}
86
+
87
+
func dolly(hexColor string) ([]byte, error) {
88
+
tpl, err := template.New("dolly").
89
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
94
+
var svgData bytes.Buffer
95
+
if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{
96
+
FillColor: hexColor,
97
+
}); err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
return svgData.Bytes(), nil
102
+
}
103
+
104
+
func svgToImage(svgData []byte, w, h int) (image.Image, error) {
105
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
106
+
if err != nil {
107
+
return nil, fmt.Errorf("error parsing SVG: %v", err)
108
+
}
109
+
110
+
icon.SetTarget(0, 0, float64(w), float64(h))
111
+
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
112
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
113
+
scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
114
+
raster := rasterx.NewDasher(w, h, scanner)
115
+
icon.Draw(raster, 1.0)
116
+
117
+
return rgba, nil
118
+
}
119
+
120
+
func parseSize(size string) (int, int, error) {
121
+
parts := strings.Split(size, "x")
122
+
if len(parts) != 2 {
123
+
return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT")
124
+
}
125
+
126
+
width, err := strconv.Atoi(parts[0])
127
+
if err != nil {
128
+
return 0, 0, fmt.Errorf("invalid width: %v", err)
129
+
}
130
+
131
+
height, err := strconv.Atoi(parts[1])
132
+
if err != nil {
133
+
return 0, 0, fmt.Errorf("invalid height: %v", err)
134
+
}
135
+
136
+
if width <= 0 || height <= 0 {
137
+
return 0, 0, fmt.Errorf("width and height must be positive")
138
+
}
139
+
140
+
return width, height, nil
141
+
}
142
+
143
+
func isValidHexColor(hex string) bool {
144
+
if len(hex) != 7 || hex[0] != '#' {
145
+
return false
146
+
}
147
+
_, err := strconv.ParseUint(hex[1:], 16, 32)
148
+
return err == nil
149
+
}
150
+
151
+
func saveSVG(svgData []byte, filepath string, _, _ int) error {
152
+
return os.WriteFile(filepath, svgData, 0644)
153
+
}
154
+
155
+
func savePNG(svgData []byte, filepath string, width, height int) error {
156
+
img, err := svgToImage(svgData, width, height)
157
+
if err != nil {
158
+
return err
159
+
}
160
+
161
+
f, err := os.Create(filepath)
162
+
if err != nil {
163
+
return err
164
+
}
165
+
defer f.Close()
166
+
167
+
return png.Encode(f, img)
168
+
}
169
+
170
+
func saveICO(svgData []byte, filepath string, width, height int) error {
171
+
img, err := svgToImage(svgData, width, height)
172
+
if err != nil {
173
+
return err
174
+
}
175
+
176
+
icoData, err := ico.ImageToIco(img)
177
+
if err != nil {
178
+
return err
179
+
}
180
+
181
+
return os.WriteFile(filepath, icoData, 0644)
182
+
}
+1
-34
crypto/verify.go
+1
-34
crypto/verify.go
···
5
5
"crypto/sha256"
6
6
"encoding/base64"
7
7
"fmt"
8
-
"strings"
9
8
10
9
"github.com/hiddeco/sshsig"
11
10
"golang.org/x/crypto/ssh"
12
-
"tangled.org/core/types"
13
11
)
14
12
15
13
func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
···
28
26
// multiple algorithms but sha-512 is most secure, and git's ssh signing defaults
29
27
// to sha-512 for all key types anyway.
30
28
err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git")
31
-
return err, err == nil
32
-
}
33
29
34
-
// VerifyCommitSignature reconstructs the payload used to sign a commit. This is
35
-
// essentially the git cat-file output but without the gpgsig header.
36
-
//
37
-
// Caveats: signature verification will fail on commits with more than one parent,
38
-
// i.e. merge commits, because types.NiceDiff doesn't carry more than one Parent field
39
-
// and we are unable to reconstruct the payload correctly.
40
-
//
41
-
// Ideally this should directly operate on an *object.Commit.
42
-
func VerifyCommitSignature(pubKey string, commit types.NiceDiff) (error, bool) {
43
-
signature := commit.Commit.PGPSignature
44
-
45
-
author := bytes.NewBuffer([]byte{})
46
-
committer := bytes.NewBuffer([]byte{})
47
-
commit.Commit.Author.Encode(author)
48
-
commit.Commit.Committer.Encode(committer)
49
-
50
-
payload := strings.Builder{}
51
-
52
-
fmt.Fprintf(&payload, "tree %s\n", commit.Commit.Tree)
53
-
if commit.Commit.Parent != "" {
54
-
fmt.Fprintf(&payload, "parent %s\n", commit.Commit.Parent)
55
-
}
56
-
fmt.Fprintf(&payload, "author %s\n", author.String())
57
-
fmt.Fprintf(&payload, "committer %s\n", committer.String())
58
-
if commit.Commit.ChangedId != "" {
59
-
fmt.Fprintf(&payload, "change-id %s\n", commit.Commit.ChangedId)
60
-
}
61
-
fmt.Fprintf(&payload, "\n%s", commit.Commit.Message)
62
-
63
-
return VerifySignature([]byte(pubKey), []byte(signature), []byte(payload.String()))
30
+
return err, err == nil
64
31
}
65
32
66
33
// SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
+1527
docs/DOCS.md
+1527
docs/DOCS.md
···
1
+
---
2
+
title: Tangled docs
3
+
author: The Tangled Contributors
4
+
date: 21 Sun, Dec 2025
5
+
abstract: |
6
+
Tangled is a decentralized code hosting and collaboration
7
+
platform. Every component of Tangled is open-source and
8
+
self-hostable. [tangled.org](https://tangled.org) also
9
+
provides hosting and CI services that are free to use.
10
+
11
+
There are several models for decentralized code
12
+
collaboration platforms, ranging from ActivityPubโs
13
+
(Forgejo) federated model, to Radicleโs entirely P2P model.
14
+
Our approach attempts to be the best of both worlds by
15
+
adopting the AT Protocolโa protocol for building decentralized
16
+
social applications with a central identity
17
+
18
+
Our approach to this is the idea of โknotsโ. Knots are
19
+
lightweight, headless servers that enable users to host Git
20
+
repositories with ease. Knots are designed for either single
21
+
or multi-tenant use which is perfect for self-hosting on a
22
+
Raspberry Pi at home, or larger โcommunityโ servers. By
23
+
default, Tangled provides managed knots where you can host
24
+
your repositories for free.
25
+
26
+
The appview at tangled.org acts as a consolidated "view"
27
+
into the whole network, allowing users to access, clone and
28
+
contribute to repositories hosted across different knots
29
+
seamlessly.
30
+
---
31
+
32
+
# Quick start guide
33
+
34
+
## Login or sign up
35
+
36
+
You can [login](https://tangled.org) by using your AT Protocol
37
+
account. If you are unclear on what that means, simply head
38
+
to the [signup](https://tangled.org/signup) page and create
39
+
an account. By doing so, you will be choosing Tangled as
40
+
your account provider (you will be granted a handle of the
41
+
form `user.tngl.sh`).
42
+
43
+
In the AT Protocol network, users are free to choose their account
44
+
provider (known as a "Personal Data Service", or PDS), and
45
+
login to applications that support AT accounts.
46
+
47
+
You can think of it as "one account for all of the atmosphere"!
48
+
49
+
If you already have an AT account (you may have one if you
50
+
signed up to Bluesky, for example), you can login with the
51
+
same handle on Tangled (so just use `user.bsky.social` on
52
+
the login page).
53
+
54
+
## Add an SSH key
55
+
56
+
Once you are logged in, you can start creating repositories
57
+
and pushing code. Tangled supports pushing git repositories
58
+
over SSH.
59
+
60
+
First, you'll need to generate an SSH key if you don't
61
+
already have one:
62
+
63
+
```bash
64
+
ssh-keygen -t ed25519 -C "foo@bar.com"
65
+
```
66
+
67
+
When prompted, save the key to the default location
68
+
(`~/.ssh/id_ed25519`) and optionally set a passphrase.
69
+
70
+
Copy your public key to your clipboard:
71
+
72
+
```bash
73
+
# on X11
74
+
cat ~/.ssh/id_ed25519.pub | xclip -sel c
75
+
76
+
# on wayland
77
+
cat ~/.ssh/id_ed25519.pub | wl-copy
78
+
79
+
# on macos
80
+
cat ~/.ssh/id_ed25519.pub | pbcopy
81
+
```
82
+
83
+
Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
84
+
paste your public key, give it a descriptive name, and hit
85
+
save.
86
+
87
+
## Create a repository
88
+
89
+
Once your SSH key is added, create your first repository:
90
+
91
+
1. Hit the green `+` icon on the topbar, and select
92
+
repository
93
+
2. Enter a repository name
94
+
3. Add a description
95
+
4. Choose a knotserver to host this repository on
96
+
5. Hit create
97
+
98
+
Knots are self-hostable, lightweight Git servers that can
99
+
host your repository. Unlike traditional code forges, your
100
+
code can live on any server. Read the [Knots](TODO) section
101
+
for more.
102
+
103
+
## Configure SSH
104
+
105
+
To ensure Git uses the correct SSH key and connects smoothly
106
+
to Tangled, add this configuration to your `~/.ssh/config`
107
+
file:
108
+
109
+
```
110
+
Host tangled.org
111
+
Hostname tangled.org
112
+
User git
113
+
IdentityFile ~/.ssh/id_ed25519
114
+
AddressFamily inet
115
+
```
116
+
117
+
This tells SSH to use your specific key when connecting to
118
+
Tangled and prevents authentication issues if you have
119
+
multiple SSH keys.
120
+
121
+
Note that this configuration only works for knotservers that
122
+
are hosted by tangled.org. If you use a custom knot, refer
123
+
to the [Knots](TODO) section.
124
+
125
+
## Push your first repository
126
+
127
+
Initialize a new Git repository:
128
+
129
+
```bash
130
+
mkdir my-project
131
+
cd my-project
132
+
133
+
git init
134
+
echo "# My Project" > README.md
135
+
```
136
+
137
+
Add some content and push!
138
+
139
+
```bash
140
+
git add README.md
141
+
git commit -m "Initial commit"
142
+
git remote add origin git@tangled.org:user.tngl.sh/my-project
143
+
git push -u origin main
144
+
```
145
+
146
+
That's it! Your code is now hosted on Tangled.
147
+
148
+
## Migrating an existing repository
149
+
150
+
Moving your repositories from GitHub, GitLab, Bitbucket, or
151
+
any other Git forge to Tangled is straightforward. You'll
152
+
simply change your repository's remote URL. At the moment,
153
+
Tangled does not have any tooling to migrate data such as
154
+
GitHub issues or pull requests.
155
+
156
+
First, create a new repository on tangled.org as described
157
+
in the [Quick Start Guide](#create-a-repository).
158
+
159
+
Navigate to your existing local repository:
160
+
161
+
```bash
162
+
cd /path/to/your/existing/repo
163
+
```
164
+
165
+
You can inspect your existing Git remote like so:
166
+
167
+
```bash
168
+
git remote -v
169
+
```
170
+
171
+
You'll see something like:
172
+
173
+
```
174
+
origin git@github.com:username/my-project (fetch)
175
+
origin git@github.com:username/my-project (push)
176
+
```
177
+
178
+
Update the remote URL to point to tangled:
179
+
180
+
```bash
181
+
git remote set-url origin git@tangled.org:user.tngl.sh/my-project
182
+
```
183
+
184
+
Verify the change:
185
+
186
+
```bash
187
+
git remote -v
188
+
```
189
+
190
+
You should now see:
191
+
192
+
```
193
+
origin git@tangled.org:user.tngl.sh/my-project (fetch)
194
+
origin git@tangled.org:user.tngl.sh/my-project (push)
195
+
```
196
+
197
+
Push all your branches and tags to Tangled:
198
+
199
+
```bash
200
+
git push -u origin --all
201
+
git push -u origin --tags
202
+
```
203
+
204
+
Your repository is now migrated to Tangled! All commit
205
+
history, branches, and tags have been preserved.
206
+
207
+
## Mirroring a repository to Tangled
208
+
209
+
If you want to maintain your repository on multiple forges
210
+
simultaneously, for example, keeping your primary repository
211
+
on GitHub while mirroring to Tangled for backup or
212
+
redundancy, you can do so by adding multiple remotes.
213
+
214
+
You can configure your local repository to push to both
215
+
Tangled and, say, GitHub. You may already have the following
216
+
setup:
217
+
218
+
```
219
+
$ git remote -v
220
+
origin git@github.com:username/my-project (fetch)
221
+
origin git@github.com:username/my-project (push)
222
+
```
223
+
224
+
Now add Tangled as an additional push URL to the same
225
+
remote:
226
+
227
+
```bash
228
+
git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
229
+
```
230
+
231
+
You also need to re-add the original URL as a push
232
+
destination (Git replaces the push URL when you use `--add`
233
+
the first time):
234
+
235
+
```bash
236
+
git remote set-url --add --push origin git@github.com:username/my-project
237
+
```
238
+
239
+
Verify your configuration:
240
+
241
+
```
242
+
$ git remote -v
243
+
origin git@github.com:username/repo (fetch)
244
+
origin git@tangled.org:username/my-project (push)
245
+
origin git@github.com:username/repo (push)
246
+
```
247
+
248
+
Notice that there's one fetch URL (the primary remote) and
249
+
two push URLs. Now, whenever you push, Git will
250
+
automatically push to both remotes:
251
+
252
+
```bash
253
+
git push origin main
254
+
```
255
+
256
+
This single command pushes your `main` branch to both GitHub
257
+
and Tangled simultaneously.
258
+
259
+
To push all branches and tags:
260
+
261
+
```bash
262
+
git push origin --all
263
+
git push origin --tags
264
+
```
265
+
266
+
If you prefer more control over which remote you push to,
267
+
you can maintain separate remotes:
268
+
269
+
```bash
270
+
git remote add github git@github.com:username/my-project
271
+
git remote add tangled git@tangled.org:username/my-project
272
+
```
273
+
274
+
Then push to each explicitly:
275
+
276
+
```bash
277
+
git push github main
278
+
git push tangled main
279
+
```
280
+
281
+
# Knot self-hosting guide
282
+
283
+
So you want to run your own knot server? Great! Here are a few prerequisites:
284
+
285
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
286
+
2. A (sub)domain name. People generally use `knot.example.com`.
287
+
3. A valid SSL certificate for your domain.
288
+
289
+
## NixOS
290
+
291
+
Refer to the [knot
292
+
module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
293
+
for a full list of options. Sample configurations:
294
+
295
+
- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
296
+
- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25)
297
+
298
+
## Docker
299
+
300
+
Refer to
301
+
[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
302
+
Note that this is community maintained.
303
+
304
+
## Manual setup
305
+
306
+
First, clone this repository:
307
+
308
+
```
309
+
git clone https://tangled.org/@tangled.org/core
310
+
```
311
+
312
+
Then, build the `knot` CLI. This is the knot administration
313
+
and operation tool. For the purpose of this guide, we're
314
+
only concerned with these subcommands:
315
+
316
+
* `knot server`: the main knot server process, typically
317
+
run as a supervised service
318
+
* `knot guard`: handles role-based access control for git
319
+
over SSH (you'll never have to run this yourself)
320
+
* `knot keys`: fetches SSH keys associated with your knot;
321
+
we'll use this to generate the SSH
322
+
`AuthorizedKeysCommand`
323
+
324
+
```
325
+
cd core
326
+
export CGO_ENABLED=1
327
+
go build -o knot ./cmd/knot
328
+
```
329
+
330
+
Next, move the `knot` binary to a location owned by `root` --
331
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
332
+
333
+
```
334
+
sudo mv knot /usr/local/bin/knot
335
+
sudo chown root:root /usr/local/bin/knot
336
+
```
337
+
338
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really
339
+
specific permissions](https://stackoverflow.com/a/27638306). The
340
+
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
341
+
retrieve a user's public SSH keys dynamically for authentication. Let's
342
+
set that up.
343
+
344
+
```
345
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
346
+
Match User git
347
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
348
+
AuthorizedKeysCommandUser nobody
349
+
EOF
350
+
```
351
+
352
+
Then, reload `sshd`:
353
+
354
+
```
355
+
sudo systemctl reload ssh
356
+
```
357
+
358
+
Next, create the `git` user. We'll use the `git` user's home directory
359
+
to store repositories:
360
+
361
+
```
362
+
sudo adduser git
363
+
```
364
+
365
+
Create `/home/git/.knot.env` with the following, updating the values as
366
+
necessary. The `KNOT_SERVER_OWNER` should be set to your
367
+
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
368
+
369
+
```
370
+
KNOT_REPO_SCAN_PATH=/home/git
371
+
KNOT_SERVER_HOSTNAME=knot.example.com
372
+
APPVIEW_ENDPOINT=https://tangled.org
373
+
KNOT_SERVER_OWNER=did:plc:foobar
374
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
375
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376
+
```
377
+
378
+
If you run a Linux distribution that uses systemd, you can use the provided
379
+
service file to run the server. Copy
380
+
[`knotserver.service`](/systemd/knotserver.service)
381
+
to `/etc/systemd/system/`. Then, run:
382
+
383
+
```
384
+
systemctl enable knotserver
385
+
systemctl start knotserver
386
+
```
387
+
388
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
389
+
knot. Here's an example configuration for Nginx:
390
+
391
+
```
392
+
server {
393
+
listen 80;
394
+
listen [::]:80;
395
+
server_name knot.example.com;
396
+
397
+
location / {
398
+
proxy_pass http://localhost:5555;
399
+
proxy_set_header Host $host;
400
+
proxy_set_header X-Real-IP $remote_addr;
401
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
402
+
proxy_set_header X-Forwarded-Proto $scheme;
403
+
}
404
+
405
+
# wss endpoint for git events
406
+
location /events {
407
+
proxy_set_header X-Forwarded-For $remote_addr;
408
+
proxy_set_header Host $http_host;
409
+
proxy_set_header Upgrade websocket;
410
+
proxy_set_header Connection Upgrade;
411
+
proxy_pass http://localhost:5555;
412
+
}
413
+
# additional config for SSL/TLS go here.
414
+
}
415
+
416
+
```
417
+
418
+
Remember to use Let's Encrypt or similar to procure a certificate for your
419
+
knot domain.
420
+
421
+
You should now have a running knot server! You can finalize
422
+
your registration by hitting the `verify` button on the
423
+
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
424
+
a record on your PDS to announce the existence of the knot.
425
+
426
+
### Custom paths
427
+
428
+
(This section applies to manual setup only. Docker users should edit the mounts
429
+
in `docker-compose.yml` instead.)
430
+
431
+
Right now, the database and repositories of your knot lives in `/home/git`. You
432
+
can move these paths if you'd like to store them in another folder. Be careful
433
+
when adjusting these paths:
434
+
435
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
436
+
any possible side effects. Remember to restart it once you're done.
437
+
* Make backups before moving in case something goes wrong.
438
+
* Make sure the `git` user can read and write from the new paths.
439
+
440
+
#### Database
441
+
442
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
443
+
and we want to move it to `/home/git/database/knotserver.db`.
444
+
445
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
446
+
and `.db-wal` files if they exist.
447
+
448
+
```
449
+
mkdir /home/git/database
450
+
cp /home/git/knotserver.db* /home/git/database
451
+
```
452
+
453
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
454
+
the new file path (_not_ the directory):
455
+
456
+
```
457
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
458
+
```
459
+
460
+
#### Repositories
461
+
462
+
As an example, let's say the repositories are currently in `/home/git`, and we
463
+
want to move them into `/home/git/repositories`.
464
+
465
+
Create the new folder, then move the existing repositories (if there are any):
466
+
467
+
```
468
+
mkdir /home/git/repositories
469
+
# move all DIDs into the new folder; these will vary for you!
470
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
471
+
```
472
+
473
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
474
+
to the new directory:
475
+
476
+
```
477
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
478
+
```
479
+
480
+
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
481
+
repository path:
482
+
483
+
```
484
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
485
+
Match User git
486
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
487
+
AuthorizedKeysCommandUser nobody
488
+
EOF
489
+
```
490
+
491
+
Make sure to restart your SSH server!
492
+
493
+
#### MOTD (message of the day)
494
+
495
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
496
+
`/home/git/motd` file:
497
+
498
+
```
499
+
printf "Hi from this knot!\n" > /home/git/motd
500
+
```
501
+
502
+
Note that you should add a newline at the end if setting a non-empty message
503
+
since the knot won't do this for you.
504
+
505
+
# Spindles
506
+
507
+
## Pipelines
508
+
509
+
Spindle workflows allow you to write CI/CD pipelines in a
510
+
simple format. They're located in the `.tangled/workflows`
511
+
directory at the root of your repository, and are defined
512
+
using YAML.
513
+
514
+
The fields are:
515
+
516
+
- [Trigger](#trigger): A **required** field that defines
517
+
when a workflow should be triggered.
518
+
- [Engine](#engine): A **required** field that defines which
519
+
engine a workflow should run on.
520
+
- [Clone options](#clone-options): An **optional** field
521
+
that defines how the repository should be cloned.
522
+
- [Dependencies](#dependencies): An **optional** field that
523
+
allows you to list dependencies you may need.
524
+
- [Environment](#environment): An **optional** field that
525
+
allows you to define environment variables.
526
+
- [Steps](#steps): An **optional** field that allows you to
527
+
define what steps should run in the workflow.
528
+
529
+
### Trigger
530
+
531
+
The first thing to add to a workflow is the trigger, which
532
+
defines when a workflow runs. This is defined using a `when`
533
+
field, which takes in a list of conditions. Each condition
534
+
has the following fields:
535
+
536
+
- `event`: This is a **required** field that defines when
537
+
your workflow should run. It's a list that can take one or
538
+
more of the following values:
539
+
- `push`: The workflow should run every time a commit is
540
+
pushed to the repository.
541
+
- `pull_request`: The workflow should run every time a
542
+
pull request is made or updated.
543
+
- `manual`: The workflow can be triggered manually.
544
+
- `branch`: Defines which branches the workflow should run
545
+
for. If used with the `push` event, commits to the
546
+
branch(es) listed here will trigger the workflow. If used
547
+
with the `pull_request` event, updates to pull requests
548
+
targeting the branch(es) listed here will trigger the
549
+
workflow. This field has no effect with the `manual`
550
+
event. Supports glob patterns using `*` and `**` (e.g.,
551
+
`main`, `develop`, `release-*`). Either `branch` or `tag`
552
+
(or both) must be specified for `push` events.
553
+
- `tag`: Defines which tags the workflow should run for.
554
+
Only used with the `push` event - when tags matching the
555
+
pattern(s) listed here are pushed, the workflow will
556
+
trigger. This field has no effect with `pull_request` or
557
+
`manual` events. Supports glob patterns using `*` and `**`
558
+
(e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
559
+
`tag` (or both) must be specified for `push` events.
560
+
561
+
For example, if you'd like to define a workflow that runs
562
+
when commits are pushed to the `main` and `develop`
563
+
branches, or when pull requests that target the `main`
564
+
branch are updated, or manually, you can do so with:
565
+
566
+
```yaml
567
+
when:
568
+
- event: ["push", "manual"]
569
+
branch: ["main", "develop"]
570
+
- event: ["pull_request"]
571
+
branch: ["main"]
572
+
```
573
+
574
+
You can also trigger workflows on tag pushes. For instance,
575
+
to run a deployment workflow when tags matching `v*` are
576
+
pushed:
577
+
578
+
```yaml
579
+
when:
580
+
- event: ["push"]
581
+
tag: ["v*"]
582
+
```
583
+
584
+
You can even combine branch and tag patterns in a single
585
+
constraint (the workflow triggers if either matches):
586
+
587
+
```yaml
588
+
when:
589
+
- event: ["push"]
590
+
branch: ["main", "release-*"]
591
+
tag: ["v*", "stable"]
592
+
```
593
+
594
+
### Engine
595
+
596
+
Next is the engine on which the workflow should run, defined
597
+
using the **required** `engine` field. The currently
598
+
supported engines are:
599
+
600
+
- `nixery`: This uses an instance of
601
+
[Nixery](https://nixery.dev) to run steps, which allows
602
+
you to add [dependencies](#dependencies) from
603
+
Nixpkgs (https://github.com/NixOS/nixpkgs). You can
604
+
search for packages on https://search.nixos.org, and
605
+
there's a pretty good chance the package(s) you're looking
606
+
for will be there.
607
+
608
+
Example:
609
+
610
+
```yaml
611
+
engine: "nixery"
612
+
```
613
+
614
+
### Clone options
615
+
616
+
When a workflow starts, the first step is to clone the
617
+
repository. You can customize this behavior using the
618
+
**optional** `clone` field. It has the following fields:
619
+
620
+
- `skip`: Setting this to `true` will skip cloning the
621
+
repository. This can be useful if your workflow is doing
622
+
something that doesn't require anything from the
623
+
repository itself. This is `false` by default.
624
+
- `depth`: This sets the number of commits, or the "clone
625
+
depth", to fetch from the repository. For example, if you
626
+
set this to 2, the last 2 commits will be fetched. By
627
+
default, the depth is set to 1, meaning only the most
628
+
recent commit will be fetched, which is the commit that
629
+
triggered the workflow.
630
+
- `submodules`: If you use Git submodules
631
+
(https://git-scm.com/book/en/v2/Git-Tools-Submodules)
632
+
in your repository, setting this field to `true` will
633
+
recursively fetch all submodules. This is `false` by
634
+
default.
635
+
636
+
The default settings are:
637
+
638
+
```yaml
639
+
clone:
640
+
skip: false
641
+
depth: 1
642
+
submodules: false
643
+
```
644
+
645
+
### Dependencies
646
+
647
+
Usually when you're running a workflow, you'll need
648
+
additional dependencies. The `dependencies` field lets you
649
+
define which dependencies to get, and from where. It's a
650
+
key-value map, with the key being the registry to fetch
651
+
dependencies from, and the value being the list of
652
+
dependencies to fetch.
653
+
654
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a
655
+
package called `my_pkg` you've made from your own registry
656
+
at your repository at
657
+
`https://tangled.org/@example.com/my_pkg`. You can define
658
+
those dependencies like so:
659
+
660
+
```yaml
661
+
dependencies:
662
+
# nixpkgs
663
+
nixpkgs:
664
+
- nodejs
665
+
- go
666
+
# custom registry
667
+
git+https://tangled.org/@example.com/my_pkg:
668
+
- my_pkg
669
+
```
670
+
671
+
Now these dependencies are available to use in your
672
+
workflow!
673
+
674
+
### Environment
675
+
676
+
The `environment` field allows you define environment
677
+
variables that will be available throughout the entire
678
+
workflow. **Do not put secrets here, these environment
679
+
variables are visible to anyone viewing the repository. You
680
+
can add secrets for pipelines in your repository's
681
+
settings.**
682
+
683
+
Example:
684
+
685
+
```yaml
686
+
environment:
687
+
GOOS: "linux"
688
+
GOARCH: "arm64"
689
+
NODE_ENV: "production"
690
+
MY_ENV_VAR: "MY_ENV_VALUE"
691
+
```
692
+
693
+
### Steps
694
+
695
+
The `steps` field allows you to define what steps should run
696
+
in the workflow. It's a list of step objects, each with the
697
+
following fields:
698
+
699
+
- `name`: This field allows you to give your step a name.
700
+
This name is visible in your workflow runs, and is used to
701
+
describe what the step is doing.
702
+
- `command`: This field allows you to define a command to
703
+
run in that step. The step is run in a Bash shell, and the
704
+
logs from the command will be visible in the pipelines
705
+
page on the Tangled website. The
706
+
[dependencies](#dependencies) you added will be available
707
+
to use here.
708
+
- `environment`: Similar to the global
709
+
[environment](#environment) config, this **optional**
710
+
field is a key-value map that allows you to set
711
+
environment variables for the step. **Do not put secrets
712
+
here, these environment variables are visible to anyone
713
+
viewing the repository. You can add secrets for pipelines
714
+
in your repository's settings.**
715
+
716
+
Example:
717
+
718
+
```yaml
719
+
steps:
720
+
- name: "Build backend"
721
+
command: "go build"
722
+
environment:
723
+
GOOS: "darwin"
724
+
GOARCH: "arm64"
725
+
- name: "Build frontend"
726
+
command: "npm run build"
727
+
environment:
728
+
NODE_ENV: "production"
729
+
```
730
+
731
+
### Complete workflow
732
+
733
+
```yaml
734
+
# .tangled/workflows/build.yml
735
+
736
+
when:
737
+
- event: ["push", "manual"]
738
+
branch: ["main", "develop"]
739
+
- event: ["pull_request"]
740
+
branch: ["main"]
741
+
742
+
engine: "nixery"
743
+
744
+
# using the default values
745
+
clone:
746
+
skip: false
747
+
depth: 1
748
+
submodules: false
749
+
750
+
dependencies:
751
+
# nixpkgs
752
+
nixpkgs:
753
+
- nodejs
754
+
- go
755
+
# custom registry
756
+
git+https://tangled.org/@example.com/my_pkg:
757
+
- my_pkg
758
+
759
+
environment:
760
+
GOOS: "linux"
761
+
GOARCH: "arm64"
762
+
NODE_ENV: "production"
763
+
MY_ENV_VAR: "MY_ENV_VALUE"
764
+
765
+
steps:
766
+
- name: "Build backend"
767
+
command: "go build"
768
+
environment:
769
+
GOOS: "darwin"
770
+
GOARCH: "arm64"
771
+
- name: "Build frontend"
772
+
command: "npm run build"
773
+
environment:
774
+
NODE_ENV: "production"
775
+
```
776
+
777
+
If you want another example of a workflow, you can look at
778
+
the one [Tangled uses to build the
779
+
project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
780
+
781
+
## Self-hosting guide
782
+
783
+
### Prerequisites
784
+
785
+
* Go
786
+
* Docker (the only supported backend currently)
787
+
788
+
### Configuration
789
+
790
+
Spindle is configured using environment variables. The following environment variables are available:
791
+
792
+
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
793
+
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
794
+
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
795
+
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
796
+
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
797
+
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
798
+
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
799
+
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
800
+
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
801
+
802
+
### Running spindle
803
+
804
+
1. **Set the environment variables.** For example:
805
+
806
+
```shell
807
+
export SPINDLE_SERVER_HOSTNAME="your-hostname"
808
+
export SPINDLE_SERVER_OWNER="your-did"
809
+
```
810
+
811
+
2. **Build the Spindle binary.**
812
+
813
+
```shell
814
+
cd core
815
+
go mod download
816
+
go build -o cmd/spindle/spindle cmd/spindle/main.go
817
+
```
818
+
819
+
3. **Create the log directory.**
820
+
821
+
```shell
822
+
sudo mkdir -p /var/log/spindle
823
+
sudo chown $USER:$USER -R /var/log/spindle
824
+
```
825
+
826
+
4. **Run the Spindle binary.**
827
+
828
+
```shell
829
+
./cmd/spindle/spindle
830
+
```
831
+
832
+
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
833
+
834
+
## Architecture
835
+
836
+
Spindle is a small CI runner service. Here's a high-level overview of how it operates:
837
+
838
+
* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
839
+
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
840
+
* When a new repo record comes through (typically when you add a spindle to a
841
+
repo from the settings), spindle then resolves the underlying knot and
842
+
subscribes to repo events (see:
843
+
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
844
+
* The spindle engine then handles execution of the pipeline, with results and
845
+
logs beamed on the spindle event stream over WebSocket
846
+
847
+
### The engine
848
+
849
+
At present, the only supported backend is Docker (and Podman, if Docker
850
+
compatibility is enabled, so that `/run/docker.sock` is created). spindle
851
+
executes each step in the pipeline in a fresh container, with state persisted
852
+
across steps within the `/tangled/workspace` directory.
853
+
854
+
The base image for the container is constructed on the fly using
855
+
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
856
+
used packages.
857
+
858
+
The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
859
+
860
+
## Secrets with openbao
861
+
862
+
This document covers setting up spindle to use OpenBao for secrets
863
+
management via OpenBao Proxy instead of the default SQLite backend.
864
+
865
+
### Overview
866
+
867
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
868
+
authentication automatically using AppRole credentials, while spindle
869
+
connects to the local proxy instead of directly to the OpenBao server.
870
+
871
+
This approach provides better security, automatic token renewal, and
872
+
simplified application code.
873
+
874
+
### Installation
875
+
876
+
Install OpenBao from Nixpkgs:
877
+
878
+
```bash
879
+
nix shell nixpkgs#openbao # for a local server
880
+
```
881
+
882
+
### Setup
883
+
884
+
The setup process can is documented for both local development and production.
885
+
886
+
#### Local development
887
+
888
+
Start OpenBao in dev mode:
889
+
890
+
```bash
891
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
892
+
```
893
+
894
+
This starts OpenBao on `http://localhost:8201` with a root token.
895
+
896
+
Set up environment for bao CLI:
897
+
898
+
```bash
899
+
export BAO_ADDR=http://localhost:8200
900
+
export BAO_TOKEN=root
901
+
```
902
+
903
+
#### Production
904
+
905
+
You would typically use a systemd service with a
906
+
configuration file. Refer to
907
+
[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
908
+
for how this can be achieved using Nix.
909
+
910
+
Then, initialize the bao server:
911
+
912
+
```bash
913
+
bao operator init -key-shares=1 -key-threshold=1
914
+
```
915
+
916
+
This will print out an unseal key and a root key. Save them
917
+
somewhere (like a password manager). Then unseal the vault
918
+
to begin setting it up:
919
+
920
+
```bash
921
+
bao operator unseal <unseal_key>
922
+
```
923
+
924
+
All steps below remain the same across both dev and
925
+
production setups.
926
+
927
+
#### Configure openbao server
928
+
929
+
Create the spindle KV mount:
930
+
931
+
```bash
932
+
bao secrets enable -path=spindle -version=2 kv
933
+
```
934
+
935
+
Set up AppRole authentication and policy:
936
+
937
+
Create a policy file `spindle-policy.hcl`:
938
+
939
+
```hcl
940
+
# Full access to spindle KV v2 data
941
+
path "spindle/data/*" {
942
+
capabilities = ["create", "read", "update", "delete"]
943
+
}
944
+
945
+
# Access to metadata for listing and management
946
+
path "spindle/metadata/*" {
947
+
capabilities = ["list", "read", "delete", "update"]
948
+
}
949
+
950
+
# Allow listing at root level
951
+
path "spindle/" {
952
+
capabilities = ["list"]
953
+
}
954
+
955
+
# Required for connection testing and health checks
956
+
path "auth/token/lookup-self" {
957
+
capabilities = ["read"]
958
+
}
959
+
```
960
+
961
+
Apply the policy and create an AppRole:
962
+
963
+
```bash
964
+
bao policy write spindle-policy spindle-policy.hcl
965
+
bao auth enable approle
966
+
bao write auth/approle/role/spindle \
967
+
token_policies="spindle-policy" \
968
+
token_ttl=1h \
969
+
token_max_ttl=4h \
970
+
bind_secret_id=true \
971
+
secret_id_ttl=0 \
972
+
secret_id_num_uses=0
973
+
```
974
+
975
+
Get the credentials:
976
+
977
+
```bash
978
+
# Get role ID (static)
979
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
980
+
981
+
# Generate secret ID
982
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
983
+
984
+
echo "Role ID: $ROLE_ID"
985
+
echo "Secret ID: $SECRET_ID"
986
+
```
987
+
988
+
#### Create proxy configuration
989
+
990
+
Create the credential files:
991
+
992
+
```bash
993
+
# Create directory for OpenBao files
994
+
mkdir -p /tmp/openbao
995
+
996
+
# Save credentials
997
+
echo "$ROLE_ID" > /tmp/openbao/role-id
998
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
999
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
1000
+
```
1001
+
1002
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
1003
+
1004
+
```hcl
1005
+
# OpenBao server connection
1006
+
vault {
1007
+
address = "http://localhost:8200"
1008
+
}
1009
+
1010
+
# Auto-Auth using AppRole
1011
+
auto_auth {
1012
+
method "approle" {
1013
+
mount_path = "auth/approle"
1014
+
config = {
1015
+
role_id_file_path = "/tmp/openbao/role-id"
1016
+
secret_id_file_path = "/tmp/openbao/secret-id"
1017
+
}
1018
+
}
1019
+
1020
+
# Optional: write token to file for debugging
1021
+
sink "file" {
1022
+
config = {
1023
+
path = "/tmp/openbao/token"
1024
+
mode = 0640
1025
+
}
1026
+
}
1027
+
}
1028
+
1029
+
# Proxy listener for spindle
1030
+
listener "tcp" {
1031
+
address = "127.0.0.1:8201"
1032
+
tls_disable = true
1033
+
}
1034
+
1035
+
# Enable API proxy with auto-auth token
1036
+
api_proxy {
1037
+
use_auto_auth_token = true
1038
+
}
1039
+
1040
+
# Enable response caching
1041
+
cache {
1042
+
use_auto_auth_token = true
1043
+
}
1044
+
1045
+
# Logging
1046
+
log_level = "info"
1047
+
```
1048
+
1049
+
#### Start the proxy
1050
+
1051
+
Start OpenBao Proxy:
1052
+
1053
+
```bash
1054
+
bao proxy -config=/tmp/openbao/proxy.hcl
1055
+
```
1056
+
1057
+
The proxy will authenticate with OpenBao and start listening on
1058
+
`127.0.0.1:8201`.
1059
+
1060
+
#### Configure spindle
1061
+
1062
+
Set these environment variables for spindle:
1063
+
1064
+
```bash
1065
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
1066
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
1067
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1068
+
```
1069
+
1070
+
On startup, spindle will now connect to the local proxy,
1071
+
which handles all authentication automatically.
1072
+
1073
+
### Production setup for proxy
1074
+
1075
+
For production, you'll want to run the proxy as a service:
1076
+
1077
+
Place your production configuration in
1078
+
`/etc/openbao/proxy.hcl` with proper TLS settings for the
1079
+
vault connection.
1080
+
1081
+
### Verifying setup
1082
+
1083
+
Test the proxy directly:
1084
+
1085
+
```bash
1086
+
# Check proxy health
1087
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
1088
+
1089
+
# Test token lookup through proxy
1090
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
1091
+
```
1092
+
1093
+
Test OpenBao operations through the server:
1094
+
1095
+
```bash
1096
+
# List all secrets
1097
+
bao kv list spindle/
1098
+
1099
+
# Add a test secret via the spindle API, then check it exists
1100
+
bao kv list spindle/repos/
1101
+
1102
+
# Get a specific secret
1103
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
1104
+
```
1105
+
1106
+
### How it works
1107
+
1108
+
- Spindle connects to OpenBao Proxy on localhost (typically
1109
+
port 8200 or 8201)
1110
+
- The proxy authenticates with OpenBao using AppRole
1111
+
credentials
1112
+
- All spindle requests go through the proxy, which injects
1113
+
authentication tokens
1114
+
- Secrets are stored at
1115
+
`spindle/repos/{sanitized_repo_path}/{secret_key}`
1116
+
- Repository paths like `did:plc:alice/myrepo` become
1117
+
`did_plc_alice_myrepo`
1118
+
- The proxy handles all token renewal automatically
1119
+
- Spindle no longer manages tokens or authentication
1120
+
directly
1121
+
1122
+
### Troubleshooting
1123
+
1124
+
**Connection refused**: Check that the OpenBao Proxy is
1125
+
running and listening on the configured address.
1126
+
1127
+
**403 errors**: Verify the AppRole credentials are correct
1128
+
and the policy has the necessary permissions.
1129
+
1130
+
**404 route errors**: The spindle KV mount probably doesn't
1131
+
existโrun the mount creation step again.
1132
+
1133
+
**Proxy authentication failures**: Check the proxy logs and
1134
+
verify the role-id and secret-id files are readable and
1135
+
contain valid credentials.
1136
+
1137
+
**Secret not found after writing**: This can indicate policy
1138
+
permission issues. Verify the policy includes both
1139
+
`spindle/data/*` and `spindle/metadata/*` paths with
1140
+
appropriate capabilities.
1141
+
1142
+
Check proxy logs:
1143
+
1144
+
```bash
1145
+
# If running as systemd service
1146
+
journalctl -u openbao-proxy -f
1147
+
1148
+
# If running directly, check the console output
1149
+
```
1150
+
1151
+
Test AppRole authentication manually:
1152
+
1153
+
```bash
1154
+
bao write auth/approle/login \
1155
+
role_id="$(cat /tmp/openbao/role-id)" \
1156
+
secret_id="$(cat /tmp/openbao/secret-id)"
1157
+
```
1158
+
1159
+
# Migrating knots and spindles
1160
+
1161
+
Sometimes, non-backwards compatible changes are made to the
1162
+
knot/spindle XRPC APIs. If you host a knot or a spindle, you
1163
+
will need to follow this guide to upgrade. Typically, this
1164
+
only requires you to deploy the newest version.
1165
+
1166
+
This document is laid out in reverse-chronological order.
1167
+
Newer migration guides are listed first, and older guides
1168
+
are further down the page.
1169
+
1170
+
## Upgrading from v1.8.x
1171
+
1172
+
After v1.8.2, the HTTP API for knots and spindles has been
1173
+
deprecated and replaced with XRPC. Repositories on outdated
1174
+
knots will not be viewable from the appview. Upgrading is
1175
+
straightforward however.
1176
+
1177
+
For knots:
1178
+
1179
+
- Upgrade to the latest tag (v1.9.0 or above)
1180
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1181
+
hit the "retry" button to verify your knot
1182
+
1183
+
For spindles:
1184
+
1185
+
- Upgrade to the latest tag (v1.9.0 or above)
1186
+
- Head to the [spindle
1187
+
dashboard](https://tangled.org/settings/spindles) and hit the
1188
+
"retry" button to verify your spindle
1189
+
1190
+
## Upgrading from v1.7.x
1191
+
1192
+
After v1.7.0, knot secrets have been deprecated. You no
1193
+
longer need a secret from the appview to run a knot. All
1194
+
authorized commands to knots are managed via [Inter-Service
1195
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
1196
+
Knots will be read-only until upgraded.
1197
+
1198
+
Upgrading is quite easy, in essence:
1199
+
1200
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
1201
+
environment variable entirely
1202
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
1203
+
your DID. You can find your DID in the
1204
+
[settings](https://tangled.org/settings) page.
1205
+
- Restart your knot once you have replaced the environment
1206
+
variable
1207
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1208
+
hit the "retry" button to verify your knot. This simply
1209
+
writes a `sh.tangled.knot` record to your PDS.
1210
+
1211
+
If you use the nix module, simply bump the flake to the
1212
+
latest revision, and change your config block like so:
1213
+
1214
+
```diff
1215
+
services.tangled.knot = {
1216
+
enable = true;
1217
+
server = {
1218
+
- secretFile = /path/to/secret;
1219
+
+ owner = "did:plc:foo";
1220
+
};
1221
+
};
1222
+
```
1223
+
1224
+
# Hacking on Tangled
1225
+
1226
+
We highly recommend [installing
1227
+
Nix](https://nixos.org/download/) (the package manager)
1228
+
before working on the codebase. The Nix flake provides a lot
1229
+
of helpers to get started and most importantly, builds and
1230
+
dev shells are entirely deterministic.
1231
+
1232
+
To set up your dev environment:
1233
+
1234
+
```bash
1235
+
nix develop
1236
+
```
1237
+
1238
+
Non-Nix users can look at the `devShell` attribute in the
1239
+
`flake.nix` file to determine necessary dependencies.
1240
+
1241
+
## Running the appview
1242
+
1243
+
The Nix flake also exposes a few `app` attributes (run `nix
1244
+
flake show` to see a full list of what the flake provides),
1245
+
one of the apps runs the appview with the `air`
1246
+
live-reloader:
1247
+
1248
+
```bash
1249
+
TANGLED_DEV=true nix run .#watch-appview
1250
+
1251
+
# TANGLED_DB_PATH might be of interest to point to
1252
+
# different sqlite DBs
1253
+
1254
+
# in a separate shell, you can live-reload tailwind
1255
+
nix run .#watch-tailwind
1256
+
```
1257
+
1258
+
To authenticate with the appview, you will need Redis and
1259
+
OAuth JWKs to be set up:
1260
+
1261
+
```
1262
+
# OAuth JWKs should already be set up by the Nix devshell:
1263
+
echo $TANGLED_OAUTH_CLIENT_SECRET
1264
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1265
+
1266
+
echo $TANGLED_OAUTH_CLIENT_KID
1267
+
1761667908
1268
+
1269
+
# if not, you can set it up yourself:
1270
+
goat key generate -t P-256
1271
+
Key Type: P-256 / secp256r1 / ES256 private key
1272
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
1273
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
1274
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
1275
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
1276
+
1277
+
# the secret key from above
1278
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1279
+
1280
+
# Run Redis in a new shell to store OAuth sessions
1281
+
redis-server
1282
+
```
1283
+
1284
+
## Running knots and spindles
1285
+
1286
+
An end-to-end knot setup requires setting up a machine with
1287
+
`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1288
+
quite cumbersome. So the Nix flake provides a
1289
+
`nixosConfiguration` to do so.
1290
+
1291
+
<details>
1292
+
<summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1293
+
1294
+
In order to build Tangled's dev VM on macOS, you will
1295
+
first need to set up a Linux Nix builder. The recommended
1296
+
way to do so is to run a [`darwin.linux-builder`
1297
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
1298
+
and to register it in `nix.conf` as a builder for Linux
1299
+
with the same architecture as your Mac (`linux-aarch64` if
1300
+
you are using Apple Silicon).
1301
+
1302
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1303
+
> the Tangled repo so that it doesn't conflict with the other VM. For example,
1304
+
> you can do
1305
+
>
1306
+
> ```shell
1307
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
1308
+
> ```
1309
+
>
1310
+
> to store the builder VM in a temporary dir.
1311
+
>
1312
+
> You should read and follow [all the other intructions][darwin builder vm] to
1313
+
> avoid subtle problems.
1314
+
1315
+
Alternatively, you can use any other method to set up a
1316
+
Linux machine with Nix installed that you can `sudo ssh`
1317
+
into (in other words, root user on your Mac has to be able
1318
+
to ssh into the Linux machine without entering a password)
1319
+
and that has the same architecture as your Mac. See
1320
+
[remote builder
1321
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
1322
+
for how to register such a builder in `nix.conf`.
1323
+
1324
+
> WARNING: If you'd like to use
1325
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
1326
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
1327
+
> ssh` works can be tricky. It seems to be [possible with
1328
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1329
+
1330
+
</details>
1331
+
1332
+
To begin, grab your DID from http://localhost:3000/settings.
1333
+
Then, set `TANGLED_VM_KNOT_OWNER` and
1334
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
1335
+
lightweight NixOS VM like so:
1336
+
1337
+
```bash
1338
+
nix run --impure .#vm
1339
+
1340
+
# type `poweroff` at the shell to exit the VM
1341
+
```
1342
+
1343
+
This starts a knot on port 6444, a spindle on port 6555
1344
+
with `ssh` exposed on port 2222.
1345
+
1346
+
Once the services are running, head to
1347
+
http://localhost:3000/settings/knots and hit "Verify". It should
1348
+
verify the ownership of the services instantly if everything
1349
+
went smoothly.
1350
+
1351
+
You can push repositories to this VM with this ssh config
1352
+
block on your main machine:
1353
+
1354
+
```bash
1355
+
Host nixos-shell
1356
+
Hostname localhost
1357
+
Port 2222
1358
+
User git
1359
+
IdentityFile ~/.ssh/my_tangled_key
1360
+
```
1361
+
1362
+
Set up a remote called `local-dev` on a git repo:
1363
+
1364
+
```bash
1365
+
git remote add local-dev git@nixos-shell:user/repo
1366
+
git push local-dev main
1367
+
```
1368
+
1369
+
The above VM should already be running a spindle on
1370
+
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1371
+
hit "Verify". You can then configure each repository to use
1372
+
this spindle and run CI jobs.
1373
+
1374
+
Of interest when debugging spindles:
1375
+
1376
+
```
1377
+
# Service logs from journald:
1378
+
journalctl -xeu spindle
1379
+
1380
+
# CI job logs from disk:
1381
+
ls /var/log/spindle
1382
+
1383
+
# Debugging spindle database:
1384
+
sqlite3 /var/lib/spindle/spindle.db
1385
+
1386
+
# litecli has a nicer REPL interface:
1387
+
litecli /var/lib/spindle/spindle.db
1388
+
```
1389
+
1390
+
If for any reason you wish to disable either one of the
1391
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
1392
+
`services.tangled.spindle.enable` (or
1393
+
`services.tangled.knot.enable`) to `false`.
1394
+
1395
+
# Contribution guide
1396
+
1397
+
## Commit guidelines
1398
+
1399
+
We follow a commit style similar to the Go project. Please keep commits:
1400
+
1401
+
* **atomic**: each commit should represent one logical change
1402
+
* **descriptive**: the commit message should clearly describe what the
1403
+
change does and why it's needed
1404
+
1405
+
### Message format
1406
+
1407
+
```
1408
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
1409
+
1410
+
Optional longer description can go here, if necessary. Explain what the
1411
+
change does and why, especially if not obvious. Reference relevant
1412
+
issues or PRs when applicable. These can be links for now since we don't
1413
+
auto-link issues/PRs yet.
1414
+
```
1415
+
1416
+
Here are some examples:
1417
+
1418
+
```
1419
+
appview/state: fix token expiry check in middleware
1420
+
1421
+
The previous check did not account for clock drift, leading to premature
1422
+
token invalidation.
1423
+
```
1424
+
1425
+
```
1426
+
knotserver/git/service: improve error checking in upload-pack
1427
+
```
1428
+
1429
+
1430
+
### General notes
1431
+
1432
+
- PRs get merged "as-is" (fast-forward)โlike applying a patch-series
1433
+
using `git am`. At present, there is no squashingโso please author
1434
+
your commits as they would appear on `master`, following the above
1435
+
guidelines.
1436
+
- If there is a lot of nesting, for example "appview:
1437
+
pages/templates/repo/fragments: ...", these can be truncated down to
1438
+
just "appview: repo/fragments: ...". If the change affects a lot of
1439
+
subdirectories, you may abbreviate to just the top-level names, e.g.
1440
+
"appview: ..." or "knotserver: ...".
1441
+
- Keep commits lowercased with no trailing period.
1442
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
1443
+
"fixed bug" or "fixes bug").
1444
+
- Try to keep the summary line under 72 characters, but we aren't too
1445
+
fussed about this.
1446
+
- Follow the same formatting for PR titles if filled manually.
1447
+
- Don't include unrelated changes in the same commit.
1448
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
1449
+
before submitting if necessary.
1450
+
1451
+
## Code formatting
1452
+
1453
+
We use a variety of tools to format our code, and multiplex them with
1454
+
[`treefmt`](https://treefmt.com). All you need to do to format your changes
1455
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1456
+
1457
+
## Proposals for bigger changes
1458
+
1459
+
Small fixes like typos, minor bugs, or trivial refactors can be
1460
+
submitted directly as PRs.
1461
+
1462
+
For larger changesโespecially those introducing new features, significant
1463
+
refactoring, or altering system behaviorโplease open a proposal first. This
1464
+
helps us evaluate the scope, design, and potential impact before implementation.
1465
+
1466
+
Create a new issue titled:
1467
+
1468
+
```
1469
+
proposal: <affected scope>: <summary of change>
1470
+
```
1471
+
1472
+
In the description, explain:
1473
+
1474
+
- What the change is
1475
+
- Why it's needed
1476
+
- How you plan to implement it (roughly)
1477
+
- Any open questions or tradeoffs
1478
+
1479
+
We'll use the issue thread to discuss and refine the idea before moving
1480
+
forward.
1481
+
1482
+
## Developer Certificate of Origin (DCO)
1483
+
1484
+
We require all contributors to certify that they have the right to
1485
+
submit the code they're contributing. To do this, we follow the
1486
+
[Developer Certificate of Origin
1487
+
(DCO)](https://developercertificate.org/).
1488
+
1489
+
By signing your commits, you're stating that the contribution is your
1490
+
own work, or that you have the right to submit it under the project's
1491
+
license. This helps us keep things clean and legally sound.
1492
+
1493
+
To sign your commit, just add the `-s` flag when committing:
1494
+
1495
+
```sh
1496
+
git commit -s -m "your commit message"
1497
+
```
1498
+
1499
+
This appends a line like:
1500
+
1501
+
```
1502
+
Signed-off-by: Your Name <your.email@example.com>
1503
+
```
1504
+
1505
+
We won't merge commits if they aren't signed off. If you forget, you can
1506
+
amend the last commit like this:
1507
+
1508
+
```sh
1509
+
git commit --amend -s
1510
+
```
1511
+
1512
+
If you're submitting a PR with multiple commits, make sure each one is
1513
+
signed.
1514
+
1515
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
1516
+
to make it sign off commits in the tangled repo:
1517
+
1518
+
```shell
1519
+
# Safety check, should say "No matching config key..."
1520
+
jj config list templates.commit_trailers
1521
+
# The command below may need to be adjusted if the command above returned something.
1522
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
1523
+
```
1524
+
1525
+
Refer to the [jujutsu
1526
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1527
+
for more information.
-136
docs/contributing.md
-136
docs/contributing.md
···
1
-
# tangled contributing guide
2
-
3
-
## commit guidelines
4
-
5
-
We follow a commit style similar to the Go project. Please keep commits:
6
-
7
-
* **atomic**: each commit should represent one logical change
8
-
* **descriptive**: the commit message should clearly describe what the
9
-
change does and why it's needed
10
-
11
-
### message format
12
-
13
-
```
14
-
<service/top-level directory>/<affected package/directory>: <short summary of change>
15
-
16
-
17
-
Optional longer description can go here, if necessary. Explain what the
18
-
change does and why, especially if not obvious. Reference relevant
19
-
issues or PRs when applicable. These can be links for now since we don't
20
-
auto-link issues/PRs yet.
21
-
```
22
-
23
-
Here are some examples:
24
-
25
-
```
26
-
appview/state: fix token expiry check in middleware
27
-
28
-
The previous check did not account for clock drift, leading to premature
29
-
token invalidation.
30
-
```
31
-
32
-
```
33
-
knotserver/git/service: improve error checking in upload-pack
34
-
```
35
-
36
-
37
-
### general notes
38
-
39
-
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
40
-
using `git am`. At present, there is no squashing -- so please author
41
-
your commits as they would appear on `master`, following the above
42
-
guidelines.
43
-
- If there is a lot of nesting, for example "appview:
44
-
pages/templates/repo/fragments: ...", these can be truncated down to
45
-
just "appview: repo/fragments: ...". If the change affects a lot of
46
-
subdirectories, you may abbreviate to just the top-level names, e.g.
47
-
"appview: ..." or "knotserver: ...".
48
-
- Keep commits lowercased with no trailing period.
49
-
- Use the imperative mood in the summary line (e.g., "fix bug" not
50
-
"fixed bug" or "fixes bug").
51
-
- Try to keep the summary line under 72 characters, but we aren't too
52
-
fussed about this.
53
-
- Follow the same formatting for PR titles if filled manually.
54
-
- Don't include unrelated changes in the same commit.
55
-
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
56
-
before submitting if necessary.
57
-
58
-
## code formatting
59
-
60
-
We use a variety of tools to format our code, and multiplex them with
61
-
[`treefmt`](https://treefmt.com): all you need to do to format your changes
62
-
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
63
-
64
-
## proposals for bigger changes
65
-
66
-
Small fixes like typos, minor bugs, or trivial refactors can be
67
-
submitted directly as PRs.
68
-
69
-
For larger changesโespecially those introducing new features, significant
70
-
refactoring, or altering system behaviorโplease open a proposal first. This
71
-
helps us evaluate the scope, design, and potential impact before implementation.
72
-
73
-
### proposal format
74
-
75
-
Create a new issue titled:
76
-
77
-
```
78
-
proposal: <affected scope>: <summary of change>
79
-
```
80
-
81
-
In the description, explain:
82
-
83
-
- What the change is
84
-
- Why it's needed
85
-
- How you plan to implement it (roughly)
86
-
- Any open questions or tradeoffs
87
-
88
-
We'll use the issue thread to discuss and refine the idea before moving
89
-
forward.
90
-
91
-
## developer certificate of origin (DCO)
92
-
93
-
We require all contributors to certify that they have the right to
94
-
submit the code they're contributing. To do this, we follow the
95
-
[Developer Certificate of Origin
96
-
(DCO)](https://developercertificate.org/).
97
-
98
-
By signing your commits, you're stating that the contribution is your
99
-
own work, or that you have the right to submit it under the project's
100
-
license. This helps us keep things clean and legally sound.
101
-
102
-
To sign your commit, just add the `-s` flag when committing:
103
-
104
-
```sh
105
-
git commit -s -m "your commit message"
106
-
```
107
-
108
-
This appends a line like:
109
-
110
-
```
111
-
Signed-off-by: Your Name <your.email@example.com>
112
-
```
113
-
114
-
We won't merge commits if they aren't signed off. If you forget, you can
115
-
amend the last commit like this:
116
-
117
-
```sh
118
-
git commit --amend -s
119
-
```
120
-
121
-
If you're submitting a PR with multiple commits, make sure each one is
122
-
signed.
123
-
124
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
125
-
to make it sign off commits in the tangled repo:
126
-
127
-
```shell
128
-
# Safety check, should say "No matching config key..."
129
-
jj config list templates.commit_trailers
130
-
# The command below may need to be adjusted if the command above returned something.
131
-
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
132
-
```
133
-
134
-
Refer to the [jj
135
-
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
136
-
for more information.
-172
docs/hacking.md
-172
docs/hacking.md
···
1
-
# hacking on tangled
2
-
3
-
We highly recommend [installing
4
-
nix](https://nixos.org/download/) (the package manager)
5
-
before working on the codebase. The nix flake provides a lot
6
-
of helpers to get started and most importantly, builds and
7
-
dev shells are entirely deterministic.
8
-
9
-
To set up your dev environment:
10
-
11
-
```bash
12
-
nix develop
13
-
```
14
-
15
-
Non-nix users can look at the `devShell` attribute in the
16
-
`flake.nix` file to determine necessary dependencies.
17
-
18
-
## running the appview
19
-
20
-
The nix flake also exposes a few `app` attributes (run `nix
21
-
flake show` to see a full list of what the flake provides),
22
-
one of the apps runs the appview with the `air`
23
-
live-reloader:
24
-
25
-
```bash
26
-
TANGLED_DEV=true nix run .#watch-appview
27
-
28
-
# TANGLED_DB_PATH might be of interest to point to
29
-
# different sqlite DBs
30
-
31
-
# in a separate shell, you can live-reload tailwind
32
-
nix run .#watch-tailwind
33
-
```
34
-
35
-
To authenticate with the appview, you will need redis and
36
-
OAUTH JWKs to be setup:
37
-
38
-
```
39
-
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_CLIENT_SECRET
41
-
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
-
43
-
echo $TANGLED_OAUTH_CLIENT_KID
44
-
1761667908
45
-
46
-
# if not, you can set it up yourself:
47
-
goat key generate -t P-256
48
-
Key Type: P-256 / secp256r1 / ES256 private key
49
-
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
-
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
-
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
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
59
-
```
60
-
61
-
## running knots and spindles
62
-
63
-
An end-to-end knot setup requires setting up a machine with
64
-
`sshd`, `AuthorizedKeysCommand`, and git user, which is
65
-
quite cumbersome. So the nix flake provides a
66
-
`nixosConfiguration` to do so.
67
-
68
-
<details>
69
-
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
70
-
71
-
In order to build Tangled's dev VM on macOS, you will
72
-
first need to set up a Linux Nix builder. The recommended
73
-
way to do so is to run a [`darwin.linux-builder`
74
-
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
75
-
and to register it in `nix.conf` as a builder for Linux
76
-
with the same architecture as your Mac (`linux-aarch64` if
77
-
you are using Apple Silicon).
78
-
79
-
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
80
-
> the tangled repo so that it doesn't conflict with the other VM. For example,
81
-
> you can do
82
-
>
83
-
> ```shell
84
-
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
85
-
> ```
86
-
>
87
-
> to store the builder VM in a temporary dir.
88
-
>
89
-
> You should read and follow [all the other intructions][darwin builder vm] to
90
-
> avoid subtle problems.
91
-
92
-
Alternatively, you can use any other method to set up a
93
-
Linux machine with `nix` installed that you can `sudo ssh`
94
-
into (in other words, root user on your Mac has to be able
95
-
to ssh into the Linux machine without entering a password)
96
-
and that has the same architecture as your Mac. See
97
-
[remote builder
98
-
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
99
-
for how to register such a builder in `nix.conf`.
100
-
101
-
> WARNING: If you'd like to use
102
-
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
103
-
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
104
-
> ssh` works can be tricky. It seems to be [possible with
105
-
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
106
-
107
-
</details>
108
-
109
-
To begin, grab your DID from http://localhost:3000/settings.
110
-
Then, set `TANGLED_VM_KNOT_OWNER` and
111
-
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
112
-
lightweight NixOS VM like so:
113
-
114
-
```bash
115
-
nix run --impure .#vm
116
-
117
-
# type `poweroff` at the shell to exit the VM
118
-
```
119
-
120
-
This starts a knot on port 6000, a spindle on port 6555
121
-
with `ssh` exposed on port 2222.
122
-
123
-
Once the services are running, head to
124
-
http://localhost:3000/knots and hit verify. It should
125
-
verify the ownership of the services instantly if everything
126
-
went smoothly.
127
-
128
-
You can push repositories to this VM with this ssh config
129
-
block on your main machine:
130
-
131
-
```bash
132
-
Host nixos-shell
133
-
Hostname localhost
134
-
Port 2222
135
-
User git
136
-
IdentityFile ~/.ssh/my_tangled_key
137
-
```
138
-
139
-
Set up a remote called `local-dev` on a git repo:
140
-
141
-
```bash
142
-
git remote add local-dev git@nixos-shell:user/repo
143
-
git push local-dev main
144
-
```
145
-
146
-
### running a spindle
147
-
148
-
The above VM should already be running a spindle on
149
-
`localhost:6555`. Head to http://localhost:3000/spindles and
150
-
hit verify. You can then configure each repository to use
151
-
this spindle and run CI jobs.
152
-
153
-
Of interest when debugging spindles:
154
-
155
-
```
156
-
# service logs from journald:
157
-
journalctl -xeu spindle
158
-
159
-
# CI job logs from disk:
160
-
ls /var/log/spindle
161
-
162
-
# debugging spindle db:
163
-
sqlite3 /var/lib/spindle/spindle.db
164
-
165
-
# litecli has a nicer REPL interface:
166
-
litecli /var/lib/spindle/spindle.db
167
-
```
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`.
+93
docs/highlight.theme
+93
docs/highlight.theme
···
1
+
{
2
+
"text-color": null,
3
+
"background-color": null,
4
+
"line-number-color": null,
5
+
"line-number-background-color": null,
6
+
"text-styles": {
7
+
"Annotation": {
8
+
"text-color": null,
9
+
"background-color": null,
10
+
"bold": false,
11
+
"italic": true,
12
+
"underline": false
13
+
},
14
+
"ControlFlow": {
15
+
"text-color": null,
16
+
"background-color": null,
17
+
"bold": true,
18
+
"italic": false,
19
+
"underline": false
20
+
},
21
+
"Error": {
22
+
"text-color": null,
23
+
"background-color": null,
24
+
"bold": true,
25
+
"italic": false,
26
+
"underline": false
27
+
},
28
+
"Alert": {
29
+
"text-color": null,
30
+
"background-color": null,
31
+
"bold": true,
32
+
"italic": false,
33
+
"underline": false
34
+
},
35
+
"Preprocessor": {
36
+
"text-color": null,
37
+
"background-color": null,
38
+
"bold": true,
39
+
"italic": false,
40
+
"underline": false
41
+
},
42
+
"Information": {
43
+
"text-color": null,
44
+
"background-color": null,
45
+
"bold": false,
46
+
"italic": true,
47
+
"underline": false
48
+
},
49
+
"Warning": {
50
+
"text-color": null,
51
+
"background-color": null,
52
+
"bold": false,
53
+
"italic": true,
54
+
"underline": false
55
+
},
56
+
"Documentation": {
57
+
"text-color": null,
58
+
"background-color": null,
59
+
"bold": false,
60
+
"italic": true,
61
+
"underline": false
62
+
},
63
+
"DataType": {
64
+
"text-color": "#8f4e8b",
65
+
"background-color": null,
66
+
"bold": false,
67
+
"italic": false,
68
+
"underline": false
69
+
},
70
+
"Comment": {
71
+
"text-color": null,
72
+
"background-color": null,
73
+
"bold": false,
74
+
"italic": true,
75
+
"underline": false
76
+
},
77
+
"CommentVar": {
78
+
"text-color": null,
79
+
"background-color": null,
80
+
"bold": false,
81
+
"italic": true,
82
+
"underline": false
83
+
},
84
+
"Keyword": {
85
+
"text-color": null,
86
+
"background-color": null,
87
+
"bold": true,
88
+
"italic": false,
89
+
"underline": false
90
+
}
91
+
}
92
+
}
93
+
-214
docs/knot-hosting.md
-214
docs/knot-hosting.md
···
1
-
# knot self-hosting guide
2
-
3
-
So you want to run your own knot server? Great! Here are a few prerequisites:
4
-
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
-
2. A (sub)domain name. People generally use `knot.example.com`.
7
-
3. A valid SSL certificate for your domain.
8
-
9
-
There's a couple of ways to get started:
10
-
* NixOS: refer to
11
-
[flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
12
-
* Docker: Documented at
13
-
[@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker)
14
-
(community maintained: support is not guaranteed!)
15
-
* Manual: Documented below.
16
-
17
-
## manual setup
18
-
19
-
First, clone this repository:
20
-
21
-
```
22
-
git clone https://tangled.org/@tangled.org/core
23
-
```
24
-
25
-
Then, build the `knot` CLI. This is the knot administration and operation tool.
26
-
For the purpose of this guide, we're only concerned with these subcommands:
27
-
28
-
* `knot server`: the main knot server process, typically run as a
29
-
supervised service
30
-
* `knot guard`: handles role-based access control for git over SSH
31
-
(you'll never have to run this yourself)
32
-
* `knot keys`: fetches SSH keys associated with your knot; we'll use
33
-
this to generate the SSH `AuthorizedKeysCommand`
34
-
35
-
```
36
-
cd core
37
-
export CGO_ENABLED=1
38
-
go build -o knot ./cmd/knot
39
-
```
40
-
41
-
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
-
44
-
```
45
-
sudo mv knot /usr/local/bin/knot
46
-
sudo chown root:root /usr/local/bin/knot
47
-
```
48
-
49
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really
50
-
specific permissions](https://stackoverflow.com/a/27638306). The
51
-
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
52
-
retrieve a user's public SSH keys dynamically for authentication. Let's
53
-
set that up.
54
-
55
-
```
56
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
57
-
Match User git
58
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
59
-
AuthorizedKeysCommandUser nobody
60
-
EOF
61
-
```
62
-
63
-
Then, reload `sshd`:
64
-
65
-
```
66
-
sudo systemctl reload ssh
67
-
```
68
-
69
-
Next, create the `git` user. We'll use the `git` user's home directory
70
-
to store repositories:
71
-
72
-
```
73
-
sudo adduser git
74
-
```
75
-
76
-
Create `/home/git/.knot.env` with the following, updating the values as
77
-
necessary. The `KNOT_SERVER_OWNER` should be set to your
78
-
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
79
-
80
-
```
81
-
KNOT_REPO_SCAN_PATH=/home/git
82
-
KNOT_SERVER_HOSTNAME=knot.example.com
83
-
APPVIEW_ENDPOINT=https://tangled.sh
84
-
KNOT_SERVER_OWNER=did:plc:foobar
85
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
86
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
87
-
```
88
-
89
-
If you run a Linux distribution that uses systemd, you can use the provided
90
-
service file to run the server. Copy
91
-
[`knotserver.service`](/systemd/knotserver.service)
92
-
to `/etc/systemd/system/`. Then, run:
93
-
94
-
```
95
-
systemctl enable knotserver
96
-
systemctl start knotserver
97
-
```
98
-
99
-
The last step is to configure a reverse proxy like Nginx or Caddy to front your
100
-
knot. Here's an example configuration for Nginx:
101
-
102
-
```
103
-
server {
104
-
listen 80;
105
-
listen [::]:80;
106
-
server_name knot.example.com;
107
-
108
-
location / {
109
-
proxy_pass http://localhost:5555;
110
-
proxy_set_header Host $host;
111
-
proxy_set_header X-Real-IP $remote_addr;
112
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
113
-
proxy_set_header X-Forwarded-Proto $scheme;
114
-
}
115
-
116
-
# wss endpoint for git events
117
-
location /events {
118
-
proxy_set_header X-Forwarded-For $remote_addr;
119
-
proxy_set_header Host $http_host;
120
-
proxy_set_header Upgrade websocket;
121
-
proxy_set_header Connection Upgrade;
122
-
proxy_pass http://localhost:5555;
123
-
}
124
-
# additional config for SSL/TLS go here.
125
-
}
126
-
127
-
```
128
-
129
-
Remember to use Let's Encrypt or similar to procure a certificate for your
130
-
knot domain.
131
-
132
-
You should now have a running knot server! You can finalize
133
-
your registration by hitting the `verify` button on the
134
-
[/knots](https://tangled.org/knots) page. This simply creates
135
-
a record on your PDS to announce the existence of the knot.
136
-
137
-
### custom paths
138
-
139
-
(This section applies to manual setup only. Docker users should edit the mounts
140
-
in `docker-compose.yml` instead.)
141
-
142
-
Right now, the database and repositories of your knot lives in `/home/git`. You
143
-
can move these paths if you'd like to store them in another folder. Be careful
144
-
when adjusting these paths:
145
-
146
-
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
147
-
any possible side effects. Remember to restart it once you're done.
148
-
* Make backups before moving in case something goes wrong.
149
-
* Make sure the `git` user can read and write from the new paths.
150
-
151
-
#### database
152
-
153
-
As an example, let's say the current database is at `/home/git/knotserver.db`,
154
-
and we want to move it to `/home/git/database/knotserver.db`.
155
-
156
-
Copy the current database to the new location. Make sure to copy the `.db-shm`
157
-
and `.db-wal` files if they exist.
158
-
159
-
```
160
-
mkdir /home/git/database
161
-
cp /home/git/knotserver.db* /home/git/database
162
-
```
163
-
164
-
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
165
-
the new file path (_not_ the directory):
166
-
167
-
```
168
-
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
169
-
```
170
-
171
-
#### repositories
172
-
173
-
As an example, let's say the repositories are currently in `/home/git`, and we
174
-
want to move them into `/home/git/repositories`.
175
-
176
-
Create the new folder, then move the existing repositories (if there are any):
177
-
178
-
```
179
-
mkdir /home/git/repositories
180
-
# move all DIDs into the new folder; these will vary for you!
181
-
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
182
-
```
183
-
184
-
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
185
-
to the new directory:
186
-
187
-
```
188
-
KNOT_REPO_SCAN_PATH=/home/git/repositories
189
-
```
190
-
191
-
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
192
-
repository path:
193
-
194
-
```
195
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
196
-
Match User git
197
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
198
-
AuthorizedKeysCommandUser nobody
199
-
EOF
200
-
```
201
-
202
-
Make sure to restart your SSH server!
203
-
204
-
#### MOTD (message of the day)
205
-
206
-
To configure the MOTD used ("Welcome to this knot!" by default), edit the
207
-
`/home/git/motd` file:
208
-
209
-
```
210
-
printf "Hi from this knot!\n" > /home/git/motd
211
-
```
212
-
213
-
Note that you should add a newline at the end if setting a non-empty message
214
-
since the knot won't do this for you.
+6
docs/logo.html
+6
docs/logo.html
-59
docs/migrations.md
-59
docs/migrations.md
···
1
-
# Migrations
2
-
3
-
This document is laid out in reverse-chronological order.
4
-
Newer migration guides are listed first, and older guides
5
-
are further down the page.
6
-
7
-
## Upgrading from v1.8.x
8
-
9
-
After v1.8.2, the HTTP API for knot and spindles have been
10
-
deprecated and replaced with XRPC. Repositories on outdated
11
-
knots will not be viewable from the appview. Upgrading is
12
-
straightforward however.
13
-
14
-
For knots:
15
-
16
-
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.org/knots) and
18
-
hit the "retry" button to verify your knot
19
-
20
-
For spindles:
21
-
22
-
- Upgrade to latest tag (v1.9.0 or above)
23
-
- Head to the [spindle
24
-
dashboard](https://tangled.org/spindles) and hit the
25
-
"retry" button to verify your spindle
26
-
27
-
## Upgrading from v1.7.x
28
-
29
-
After v1.7.0, knot secrets have been deprecated. You no
30
-
longer need a secret from the appview to run a knot. All
31
-
authorized commands to knots are managed via [Inter-Service
32
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
-
Knots will be read-only until upgraded.
34
-
35
-
Upgrading is quite easy, in essence:
36
-
37
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
-
environment variable entirely
39
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
-
your DID. You can find your DID in the
41
-
[settings](https://tangled.org/settings) page.
42
-
- Restart your knot once you have replaced the environment
43
-
variable
44
-
- Head to the [knot dashboard](https://tangled.org/knots) and
45
-
hit the "retry" button to verify your knot. This simply
46
-
writes a `sh.tangled.knot` record to your PDS.
47
-
48
-
If you use the nix module, simply bump the flake to the
49
-
latest revision, and change your config block like so:
50
-
51
-
```diff
52
-
services.tangled-knot = {
53
-
enable = true;
54
-
server = {
55
-
- secretFile = /path/to/secret;
56
-
+ owner = "did:plc:foo";
57
-
};
58
-
};
59
-
```
+3
docs/mode.html
+3
docs/mode.html
+7
docs/search.html
+7
docs/search.html
···
1
+
<form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full">
2
+
<input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]">
3
+
<label>
4
+
<span style="display:none;">Search</span>
5
+
<input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal">
6
+
</label>
7
+
</form>
-25
docs/spindle/architecture.md
-25
docs/spindle/architecture.md
···
1
-
# spindle architecture
2
-
3
-
Spindle is a small CI runner service. Here's a high level overview of how it operates:
4
-
5
-
* listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
6
-
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
7
-
* when a new repo record comes through (typically when you add a spindle to a
8
-
repo from the settings), spindle then resolves the underlying knot and
9
-
subscribes to repo events (see:
10
-
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
11
-
* the spindle engine then handles execution of the pipeline, with results and
12
-
logs beamed on the spindle event stream over wss
13
-
14
-
### the engine
15
-
16
-
At present, the only supported backend is Docker (and Podman, if Docker
17
-
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18
-
executes each step in the pipeline in a fresh container, with state persisted
19
-
across steps within the `/tangled/workspace` directory.
20
-
21
-
The base image for the container is constructed on the fly using
22
-
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
23
-
used packages.
24
-
25
-
The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
-52
docs/spindle/hosting.md
···
1
-
# spindle self-hosting guide
2
-
3
-
## prerequisites
4
-
5
-
* Go
6
-
* Docker (the only supported backend currently)
7
-
8
-
## configuration
9
-
10
-
Spindle is configured using environment variables. The following environment variables are available:
11
-
12
-
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
13
-
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
14
-
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
15
-
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
16
-
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
17
-
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
18
-
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
19
-
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
20
-
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
21
-
22
-
## running spindle
23
-
24
-
1. **Set the environment variables.** For example:
25
-
26
-
```shell
27
-
export SPINDLE_SERVER_HOSTNAME="your-hostname"
28
-
export SPINDLE_SERVER_OWNER="your-did"
29
-
```
30
-
31
-
2. **Build the Spindle binary.**
32
-
33
-
```shell
34
-
cd core
35
-
go mod download
36
-
go build -o cmd/spindle/spindle cmd/spindle/main.go
37
-
```
38
-
39
-
3. **Create the log directory.**
40
-
41
-
```shell
42
-
sudo mkdir -p /var/log/spindle
43
-
sudo chown $USER:$USER -R /var/log/spindle
44
-
```
45
-
46
-
4. **Run the Spindle binary.**
47
-
48
-
```shell
49
-
./cmd/spindle/spindle
50
-
```
51
-
52
-
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
-285
docs/spindle/openbao.md
···
1
-
# spindle secrets with openbao
2
-
3
-
This document covers setting up Spindle to use OpenBao for secrets
4
-
management via OpenBao Proxy instead of the default SQLite backend.
5
-
6
-
## overview
7
-
8
-
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
-
authentication automatically using AppRole credentials, while Spindle
10
-
connects to the local proxy instead of directly to the OpenBao server.
11
-
12
-
This approach provides better security, automatic token renewal, and
13
-
simplified application code.
14
-
15
-
## installation
16
-
17
-
Install OpenBao from nixpkgs:
18
-
19
-
```bash
20
-
nix shell nixpkgs#openbao # for a local server
21
-
```
22
-
23
-
## setup
24
-
25
-
The setup process can is documented for both local development and production.
26
-
27
-
### local development
28
-
29
-
Start OpenBao in dev mode:
30
-
31
-
```bash
32
-
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
-
```
34
-
35
-
This starts OpenBao on `http://localhost:8201` with a root token.
36
-
37
-
Set up environment for bao CLI:
38
-
39
-
```bash
40
-
export BAO_ADDR=http://localhost:8200
41
-
export BAO_TOKEN=root
42
-
```
43
-
44
-
### production
45
-
46
-
You would typically use a systemd service with a configuration file. Refer to
47
-
[@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be
48
-
achieved using Nix.
49
-
50
-
Then, initialize the bao server:
51
-
```bash
52
-
bao operator init -key-shares=1 -key-threshold=1
53
-
```
54
-
55
-
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
-
```bash
57
-
bao operator unseal <unseal_key>
58
-
```
59
-
60
-
All steps below remain the same across both dev and production setups.
61
-
62
-
### configure openbao server
63
-
64
-
Create the spindle KV mount:
65
-
66
-
```bash
67
-
bao secrets enable -path=spindle -version=2 kv
68
-
```
69
-
70
-
Set up AppRole authentication and policy:
71
-
72
-
Create a policy file `spindle-policy.hcl`:
73
-
74
-
```hcl
75
-
# Full access to spindle KV v2 data
76
-
path "spindle/data/*" {
77
-
capabilities = ["create", "read", "update", "delete"]
78
-
}
79
-
80
-
# Access to metadata for listing and management
81
-
path "spindle/metadata/*" {
82
-
capabilities = ["list", "read", "delete", "update"]
83
-
}
84
-
85
-
# Allow listing at root level
86
-
path "spindle/" {
87
-
capabilities = ["list"]
88
-
}
89
-
90
-
# Required for connection testing and health checks
91
-
path "auth/token/lookup-self" {
92
-
capabilities = ["read"]
93
-
}
94
-
```
95
-
96
-
Apply the policy and create an AppRole:
97
-
98
-
```bash
99
-
bao policy write spindle-policy spindle-policy.hcl
100
-
bao auth enable approle
101
-
bao write auth/approle/role/spindle \
102
-
token_policies="spindle-policy" \
103
-
token_ttl=1h \
104
-
token_max_ttl=4h \
105
-
bind_secret_id=true \
106
-
secret_id_ttl=0 \
107
-
secret_id_num_uses=0
108
-
```
109
-
110
-
Get the credentials:
111
-
112
-
```bash
113
-
# Get role ID (static)
114
-
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
-
116
-
# Generate secret ID
117
-
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
-
119
-
echo "Role ID: $ROLE_ID"
120
-
echo "Secret ID: $SECRET_ID"
121
-
```
122
-
123
-
### create proxy configuration
124
-
125
-
Create the credential files:
126
-
127
-
```bash
128
-
# Create directory for OpenBao files
129
-
mkdir -p /tmp/openbao
130
-
131
-
# Save credentials
132
-
echo "$ROLE_ID" > /tmp/openbao/role-id
133
-
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
-
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
-
```
136
-
137
-
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
-
139
-
```hcl
140
-
# OpenBao server connection
141
-
vault {
142
-
address = "http://localhost:8200"
143
-
}
144
-
145
-
# Auto-Auth using AppRole
146
-
auto_auth {
147
-
method "approle" {
148
-
mount_path = "auth/approle"
149
-
config = {
150
-
role_id_file_path = "/tmp/openbao/role-id"
151
-
secret_id_file_path = "/tmp/openbao/secret-id"
152
-
}
153
-
}
154
-
155
-
# Optional: write token to file for debugging
156
-
sink "file" {
157
-
config = {
158
-
path = "/tmp/openbao/token"
159
-
mode = 0640
160
-
}
161
-
}
162
-
}
163
-
164
-
# Proxy listener for Spindle
165
-
listener "tcp" {
166
-
address = "127.0.0.1:8201"
167
-
tls_disable = true
168
-
}
169
-
170
-
# Enable API proxy with auto-auth token
171
-
api_proxy {
172
-
use_auto_auth_token = true
173
-
}
174
-
175
-
# Enable response caching
176
-
cache {
177
-
use_auto_auth_token = true
178
-
}
179
-
180
-
# Logging
181
-
log_level = "info"
182
-
```
183
-
184
-
### start the proxy
185
-
186
-
Start OpenBao Proxy:
187
-
188
-
```bash
189
-
bao proxy -config=/tmp/openbao/proxy.hcl
190
-
```
191
-
192
-
The proxy will authenticate with OpenBao and start listening on
193
-
`127.0.0.1:8201`.
194
-
195
-
### configure spindle
196
-
197
-
Set these environment variables for Spindle:
198
-
199
-
```bash
200
-
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
-
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
-
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
-
```
204
-
205
-
Start Spindle:
206
-
207
-
Spindle will now connect to the local proxy, which handles all
208
-
authentication automatically.
209
-
210
-
## production setup for proxy
211
-
212
-
For production, you'll want to run the proxy as a service:
213
-
214
-
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
-
proper TLS settings for the vault connection.
216
-
217
-
## verifying setup
218
-
219
-
Test the proxy directly:
220
-
221
-
```bash
222
-
# Check proxy health
223
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
-
225
-
# Test token lookup through proxy
226
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
-
```
228
-
229
-
Test OpenBao operations through the server:
230
-
231
-
```bash
232
-
# List all secrets
233
-
bao kv list spindle/
234
-
235
-
# Add a test secret via Spindle API, then check it exists
236
-
bao kv list spindle/repos/
237
-
238
-
# Get a specific secret
239
-
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
-
```
241
-
242
-
## how it works
243
-
244
-
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
-
- The proxy authenticates with OpenBao using AppRole credentials
246
-
- All Spindle requests go through the proxy, which injects authentication tokens
247
-
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
-
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
-
- The proxy handles all token renewal automatically
250
-
- Spindle no longer manages tokens or authentication directly
251
-
252
-
## troubleshooting
253
-
254
-
**Connection refused**: Check that the OpenBao Proxy is running and
255
-
listening on the configured address.
256
-
257
-
**403 errors**: Verify the AppRole credentials are correct and the policy
258
-
has the necessary permissions.
259
-
260
-
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
-
the mount creation step again.
262
-
263
-
**Proxy authentication failures**: Check the proxy logs and verify the
264
-
role-id and secret-id files are readable and contain valid credentials.
265
-
266
-
**Secret not found after writing**: This can indicate policy permission
267
-
issues. Verify the policy includes both `spindle/data/*` and
268
-
`spindle/metadata/*` paths with appropriate capabilities.
269
-
270
-
Check proxy logs:
271
-
272
-
```bash
273
-
# If running as systemd service
274
-
journalctl -u openbao-proxy -f
275
-
276
-
# If running directly, check the console output
277
-
```
278
-
279
-
Test AppRole authentication manually:
280
-
281
-
```bash
282
-
bao write auth/approle/login \
283
-
role_id="$(cat /tmp/openbao/role-id)" \
284
-
secret_id="$(cat /tmp/openbao/secret-id)"
285
-
```
-165
docs/spindle/pipeline.md
-165
docs/spindle/pipeline.md
···
1
-
# spindle pipelines
2
-
3
-
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
4
-
5
-
The fields are:
6
-
7
-
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
8
-
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
9
-
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
10
-
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
11
-
- [Environment](#environment): An **optional** field that allows you to define environment variables.
12
-
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
13
-
14
-
## Trigger
15
-
16
-
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
17
-
18
-
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
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
-
26
-
```yaml
27
-
when:
28
-
- event: ["push", "manual"]
29
-
branch: ["main", "develop"]
30
-
- event: ["pull_request"]
31
-
branch: ["main"]
32
-
```
33
-
34
-
## Engine
35
-
36
-
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
37
-
38
-
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
39
-
40
-
Example:
41
-
42
-
```yaml
43
-
engine: "nixery"
44
-
```
45
-
46
-
## Clone options
47
-
48
-
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
49
-
50
-
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
51
-
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
52
-
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
53
-
54
-
The default settings are:
55
-
56
-
```yaml
57
-
clone:
58
-
skip: false
59
-
depth: 1
60
-
submodules: false
61
-
```
62
-
63
-
## Dependencies
64
-
65
-
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
66
-
67
-
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
68
-
69
-
```yaml
70
-
dependencies:
71
-
# nixpkgs
72
-
nixpkgs:
73
-
- nodejs
74
-
- go
75
-
# custom registry
76
-
git+https://tangled.org/@example.com/my_pkg:
77
-
- my_pkg
78
-
```
79
-
80
-
Now these dependencies are available to use in your workflow!
81
-
82
-
## Environment
83
-
84
-
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
85
-
86
-
Example:
87
-
88
-
```yaml
89
-
environment:
90
-
GOOS: "linux"
91
-
GOARCH: "arm64"
92
-
NODE_ENV: "production"
93
-
MY_ENV_VAR: "MY_ENV_VALUE"
94
-
```
95
-
96
-
## Steps
97
-
98
-
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
99
-
100
-
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
101
-
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
102
-
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
103
-
104
-
Example:
105
-
106
-
```yaml
107
-
steps:
108
-
- name: "Build backend"
109
-
command: "go build"
110
-
environment:
111
-
GOOS: "darwin"
112
-
GOARCH: "arm64"
113
-
- name: "Build frontend"
114
-
command: "npm run build"
115
-
environment:
116
-
NODE_ENV: "production"
117
-
```
118
-
119
-
## Complete workflow
120
-
121
-
```yaml
122
-
# .tangled/workflows/build.yml
123
-
124
-
when:
125
-
- event: ["push", "manual"]
126
-
branch: ["main", "develop"]
127
-
- event: ["pull_request"]
128
-
branch: ["main"]
129
-
130
-
engine: "nixery"
131
-
132
-
# using the default values
133
-
clone:
134
-
skip: false
135
-
depth: 1
136
-
submodules: false
137
-
138
-
dependencies:
139
-
# nixpkgs
140
-
nixpkgs:
141
-
- nodejs
142
-
- go
143
-
# custom registry
144
-
git+https://tangled.org/@example.com/my_pkg:
145
-
- my_pkg
146
-
147
-
environment:
148
-
GOOS: "linux"
149
-
GOARCH: "arm64"
150
-
NODE_ENV: "production"
151
-
MY_ENV_VAR: "MY_ENV_VALUE"
152
-
153
-
steps:
154
-
- name: "Build backend"
155
-
command: "go build"
156
-
environment:
157
-
GOOS: "darwin"
158
-
GOARCH: "arm64"
159
-
- name: "Build frontend"
160
-
command: "npm run build"
161
-
environment:
162
-
NODE_ENV: "production"
163
-
```
164
-
165
-
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
+101
docs/styles.css
···
1
+
svg {
2
+
width: 16px;
3
+
height: 16px;
4
+
}
5
+
6
+
:root {
7
+
--syntax-alert: #d20f39;
8
+
--syntax-annotation: #fe640b;
9
+
--syntax-attribute: #df8e1d;
10
+
--syntax-basen: #40a02b;
11
+
--syntax-builtin: #1e66f5;
12
+
--syntax-controlflow: #8839ef;
13
+
--syntax-char: #04a5e5;
14
+
--syntax-constant: #fe640b;
15
+
--syntax-comment: #9ca0b0;
16
+
--syntax-commentvar: #7c7f93;
17
+
--syntax-documentation: #9ca0b0;
18
+
--syntax-datatype: #df8e1d;
19
+
--syntax-decval: #40a02b;
20
+
--syntax-error: #d20f39;
21
+
--syntax-extension: #4c4f69;
22
+
--syntax-float: #40a02b;
23
+
--syntax-function: #1e66f5;
24
+
--syntax-import: #40a02b;
25
+
--syntax-information: #04a5e5;
26
+
--syntax-keyword: #8839ef;
27
+
--syntax-operator: #179299;
28
+
--syntax-other: #8839ef;
29
+
--syntax-preprocessor: #ea76cb;
30
+
--syntax-specialchar: #04a5e5;
31
+
--syntax-specialstring: #ea76cb;
32
+
--syntax-string: #40a02b;
33
+
--syntax-variable: #8839ef;
34
+
--syntax-verbatimstring: #40a02b;
35
+
--syntax-warning: #df8e1d;
36
+
}
37
+
38
+
@media (prefers-color-scheme: dark) {
39
+
:root {
40
+
--syntax-alert: #f38ba8;
41
+
--syntax-annotation: #fab387;
42
+
--syntax-attribute: #f9e2af;
43
+
--syntax-basen: #a6e3a1;
44
+
--syntax-builtin: #89b4fa;
45
+
--syntax-controlflow: #cba6f7;
46
+
--syntax-char: #89dceb;
47
+
--syntax-constant: #fab387;
48
+
--syntax-comment: #6c7086;
49
+
--syntax-commentvar: #585b70;
50
+
--syntax-documentation: #6c7086;
51
+
--syntax-datatype: #f9e2af;
52
+
--syntax-decval: #a6e3a1;
53
+
--syntax-error: #f38ba8;
54
+
--syntax-extension: #cdd6f4;
55
+
--syntax-float: #a6e3a1;
56
+
--syntax-function: #89b4fa;
57
+
--syntax-import: #a6e3a1;
58
+
--syntax-information: #89dceb;
59
+
--syntax-keyword: #cba6f7;
60
+
--syntax-operator: #94e2d5;
61
+
--syntax-other: #cba6f7;
62
+
--syntax-preprocessor: #f5c2e7;
63
+
--syntax-specialchar: #89dceb;
64
+
--syntax-specialstring: #f5c2e7;
65
+
--syntax-string: #a6e3a1;
66
+
--syntax-variable: #cba6f7;
67
+
--syntax-verbatimstring: #a6e3a1;
68
+
--syntax-warning: #f9e2af;
69
+
}
70
+
}
71
+
72
+
/* pandoc syntax highlighting classes */
73
+
code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */
74
+
code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */
75
+
code span.at { color: var(--syntax-attribute); } /* attribute */
76
+
code span.bn { color: var(--syntax-basen); } /* basen */
77
+
code span.bu { color: var(--syntax-builtin); } /* builtin */
78
+
code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */
79
+
code span.ch { color: var(--syntax-char); } /* char */
80
+
code span.cn { color: var(--syntax-constant); } /* constant */
81
+
code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */
82
+
code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */
83
+
code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */
84
+
code span.dt { color: var(--syntax-datatype); } /* datatype */
85
+
code span.dv { color: var(--syntax-decval); } /* decval */
86
+
code span.er { color: var(--syntax-error); font-weight: bold; } /* error */
87
+
code span.ex { color: var(--syntax-extension); } /* extension */
88
+
code span.fl { color: var(--syntax-float); } /* float */
89
+
code span.fu { color: var(--syntax-function); } /* function */
90
+
code span.im { color: var(--syntax-import); font-weight: bold; } /* import */
91
+
code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */
92
+
code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */
93
+
code span.op { color: var(--syntax-operator); } /* operator */
94
+
code span.ot { color: var(--syntax-other); } /* other */
95
+
code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */
96
+
code span.sc { color: var(--syntax-specialchar); } /* specialchar */
97
+
code span.ss { color: var(--syntax-specialstring); } /* specialstring */
98
+
code span.st { color: var(--syntax-string); } /* string */
99
+
code span.va { color: var(--syntax-variable); } /* variable */
100
+
code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */
101
+
code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+157
docs/template.html
+157
docs/template.html
···
1
+
<!DOCTYPE html>
2
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$>
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<meta name="generator" content="pandoc" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
7
+
$for(author-meta)$
8
+
<meta name="author" content="$author-meta$" />
9
+
$endfor$
10
+
11
+
$if(date-meta)$
12
+
<meta name="dcterms.date" content="$date-meta$" />
13
+
$endif$
14
+
15
+
$if(keywords)$
16
+
<meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" />
17
+
$endif$
18
+
19
+
$if(description-meta)$
20
+
<meta name="description" content="$description-meta$" />
21
+
$endif$
22
+
23
+
<title>$pagetitle$</title>
24
+
25
+
<style>
26
+
$styles.css()$
27
+
</style>
28
+
29
+
$for(css)$
30
+
<link rel="stylesheet" href="$css$" />
31
+
$endfor$
32
+
33
+
$for(header-includes)$
34
+
$header-includes$
35
+
$endfor$
36
+
37
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
+
39
+
</head>
40
+
<body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh">
41
+
$for(include-before)$
42
+
$include-before$
43
+
$endfor$
44
+
45
+
$if(toc)$
46
+
<!-- mobile TOC trigger -->
47
+
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
+
<button
49
+
type="button"
50
+
popovertarget="mobile-toc-popover"
51
+
popovertargetaction="toggle"
52
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
+
>
54
+
${ menu.svg() }
55
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
+
</button>
57
+
</div>
58
+
59
+
<div
60
+
id="mobile-toc-popover"
61
+
popover
62
+
class="mobile-toc-popover
63
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
64
+
h-full overflow-y-auto shadow-sm
65
+
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
66
+
>
67
+
<div class="flex flex-col min-h-full">
68
+
<div class="flex-1 space-y-4">
69
+
<button
70
+
type="button"
71
+
popovertarget="mobile-toc-popover"
72
+
popovertargetaction="toggle"
73
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
74
+
${ x.svg() }
75
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
+
</button>
77
+
${ logo.html() }
78
+
${ search.html() }
79
+
${ table-of-contents:toc.html() }
80
+
</div>
81
+
${ single-page:mode.html() }
82
+
</div>
83
+
</div>
84
+
85
+
<!-- desktop sidebar toc -->
86
+
<nav
87
+
id="$idprefix$TOC"
88
+
role="doc-toc"
89
+
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
90
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
91
+
p-4 z-50 overflow-y-auto">
92
+
${ logo.html() }
93
+
${ search.html() }
94
+
<div class="flex-1">
95
+
$if(toc-title)$
96
+
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
97
+
$endif$
98
+
${ table-of-contents:toc.html() }
99
+
</div>
100
+
${ single-page:mode.html() }
101
+
</nav>
102
+
$endif$
103
+
104
+
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
105
+
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
106
+
$if(top)$
107
+
$-- only print title block if this is NOT the top page
108
+
$else$
109
+
$if(title)$
110
+
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
111
+
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
112
+
$if(subtitle)$
113
+
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
114
+
$endif$
115
+
$for(author)$
116
+
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
117
+
$endfor$
118
+
$if(date)$
119
+
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
120
+
$endif$
121
+
$endif$
122
+
</header>
123
+
$if(abstract)$
124
+
<article class="prose dark:prose-invert max-w-none">
125
+
$abstract$
126
+
</article>
127
+
$endif$
128
+
$endif$
129
+
130
+
<article class="prose dark:prose-invert max-w-none">
131
+
$body$
132
+
</article>
133
+
</main>
134
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
135
+
<div class="max-w-4xl mx-auto px-8 py-4">
136
+
<div class="flex justify-between gap-4">
137
+
<span class="flex-1">
138
+
$if(previous.url)$
139
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span>
140
+
<a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a>
141
+
$endif$
142
+
</span>
143
+
<span class="flex-1 text-right">
144
+
$if(next.url)$
145
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span>
146
+
<a href="$next.url$" accesskey="n" rel="next">$next.title$</a>
147
+
$endif$
148
+
</span>
149
+
</div>
150
+
</div>
151
+
</nav>
152
+
</div>
153
+
$for(include-after)$
154
+
$include-after$
155
+
$endfor$
156
+
</body>
157
+
</html>
+4
docs/toc.html
+4
docs/toc.html
+26
-9
flake.lock
+26
-9
flake.lock
···
1
1
{
2
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
+
},
3
19
"flake-compat": {
4
20
"flake": false,
5
21
"locked": {
···
19
35
"systems": "systems"
20
36
},
21
37
"locked": {
22
-
"lastModified": 1694529238,
23
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
38
+
"lastModified": 1731533236,
39
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
24
40
"owner": "numtide",
25
41
"repo": "flake-utils",
26
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
42
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
27
43
"type": "github"
28
44
},
29
45
"original": {
···
40
56
]
41
57
},
42
58
"locked": {
43
-
"lastModified": 1754078208,
44
-
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
59
+
"lastModified": 1763982521,
60
+
"narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=",
45
61
"owner": "nix-community",
46
62
"repo": "gomod2nix",
47
-
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
63
+
"rev": "02e63a239d6eabd595db56852535992c898eba72",
48
64
"type": "github"
49
65
},
50
66
"original": {
···
134
150
},
135
151
"nixpkgs": {
136
152
"locked": {
137
-
"lastModified": 1751984180,
138
-
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
153
+
"lastModified": 1766070988,
154
+
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
139
155
"owner": "nixos",
140
156
"repo": "nixpkgs",
141
-
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
157
+
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
142
158
"type": "github"
143
159
},
144
160
"original": {
···
150
166
},
151
167
"root": {
152
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
153
170
"flake-compat": "flake-compat",
154
171
"gomod2nix": "gomod2nix",
155
172
"htmx-src": "htmx-src",
+33
-15
flake.nix
+33
-15
flake.nix
···
33
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
34
flake = false;
35
35
};
36
+
actor-typeahead-src = {
37
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
+
flake = false;
39
+
};
36
40
ibm-plex-mono-src = {
37
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
42
flake = false;
···
54
58
inter-fonts-src,
55
59
sqlite-lib-src,
56
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
57
62
...
58
63
}: let
59
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
71
76
};
72
77
buildGoApplication =
73
78
(self.callPackage "${gomod2nix}/builder" {
74
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
75
80
}).buildGoApplication;
76
81
modules = ./nix/gomod2nix.toml;
77
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
78
-
inherit (pkgs) gcc;
79
83
inherit sqlite-lib-src;
80
84
};
81
85
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
82
86
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
87
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;
88
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
85
89
};
86
90
appview = self.callPackage ./nix/pkgs/appview.nix {};
91
+
docs = self.callPackage ./nix/pkgs/docs.nix {
92
+
inherit inter-fonts-src ibm-plex-mono-src lucide-src;
93
+
};
87
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
88
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
89
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
+
dolly = self.callPackage ./nix/pkgs/dolly.nix {};
90
98
});
91
99
in {
92
100
overlays.default = final: prev: {
93
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
101
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly;
94
102
};
95
103
96
104
packages = forAllSystems (system: let
···
99
107
staticPackages = mkPackageSet pkgs.pkgsStatic;
100
108
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
101
109
in {
102
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
110
+
inherit
111
+
(packages)
112
+
appview
113
+
appview-static-files
114
+
lexgen
115
+
goat
116
+
spindle
117
+
knot
118
+
knot-unwrapped
119
+
sqlite-lib
120
+
docs
121
+
dolly
122
+
;
103
123
104
124
pkgsStatic-appview = staticPackages.appview;
105
125
pkgsStatic-knot = staticPackages.knot;
106
126
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
107
127
pkgsStatic-spindle = staticPackages.spindle;
108
128
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
129
+
pkgsStatic-dolly = staticPackages.dolly;
109
130
110
131
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
111
132
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
112
133
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
113
134
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
135
+
pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly;
114
136
115
137
treefmt-wrapper = pkgs.treefmt.withConfig {
116
138
settings.formatter = {
···
151
173
nativeBuildInputs = [
152
174
pkgs.go
153
175
pkgs.air
154
-
pkgs.tilt
155
176
pkgs.gopls
156
177
pkgs.httpie
157
178
pkgs.litecli
···
179
200
air-watcher = name: arg:
180
201
pkgs.writeShellScriptBin "run"
181
202
''
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"
203
+
export PATH=${pkgs.go}/bin:$PATH
204
+
${pkgs.air}/bin/air -c ./.air/${name}.toml \
205
+
-build.args_bin "${arg}"
188
206
'';
189
207
tailwind-watcher =
190
208
pkgs.writeShellScriptBin "run"
···
283
301
}: {
284
302
imports = [./nix/modules/appview.nix];
285
303
286
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
304
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview;
287
305
};
288
306
nixosModules.knot = {
289
307
lib,
···
292
310
}: {
293
311
imports = [./nix/modules/knot.nix];
294
312
295
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
313
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot;
296
314
};
297
315
nixosModules.spindle = {
298
316
lib,
···
301
319
}: {
302
320
imports = [./nix/modules/spindle.nix];
303
321
304
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
322
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
305
323
};
306
324
};
307
325
}
+7
-16
go.mod
+7
-16
go.mod
···
1
1
module tangled.org/core
2
2
3
-
go 1.24.4
3
+
go 1.25.0
4
4
5
5
require (
6
6
github.com/Blank-Xu/sql-adapter v1.1.1
7
7
github.com/alecthomas/assert/v2 v2.11.0
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
+
github.com/blevesearch/bleve/v2 v2.5.3
10
11
github.com/bluekeyes/go-gitdiff v0.8.1
11
12
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
13
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
14
+
github.com/bmatcuk/doublestar/v4 v4.9.1
13
15
github.com/carlmjohnson/versioninfo v0.22.5
14
16
github.com/casbin/casbin/v2 v2.103.0
17
+
github.com/charmbracelet/log v0.4.2
15
18
github.com/cloudflare/cloudflare-go v0.115.0
16
19
github.com/cyphar/filepath-securejoin v0.4.1
17
20
github.com/dgraph-io/ristretto v0.2.0
···
29
32
github.com/hiddeco/sshsig v0.2.0
30
33
github.com/hpcloud/tail v1.0.0
31
34
github.com/ipfs/go-cid v0.5.0
32
-
github.com/lestrrat-go/jwx/v2 v2.1.6
33
35
github.com/mattn/go-sqlite3 v1.14.24
34
36
github.com/microcosm-cc/bluemonday v1.0.27
35
37
github.com/openbao/openbao/api/v2 v2.3.0
···
42
44
github.com/stretchr/testify v1.10.0
43
45
github.com/urfave/cli/v3 v3.3.3
44
46
github.com/whyrusleeping/cbor-gen v0.3.1
45
-
github.com/wyatt915/goldmark-treeblood v0.0.1
46
47
github.com/yuin/goldmark v1.7.13
48
+
github.com/yuin/goldmark-emoji v1.0.6
47
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
48
51
golang.org/x/crypto v0.40.0
49
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
53
golang.org/x/image v0.31.0
51
54
golang.org/x/net v0.42.0
52
-
golang.org/x/sync v0.17.0
53
55
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
54
56
gopkg.in/yaml.v3 v3.0.1
55
57
)
···
65
67
github.com/aymerick/douceur v0.2.0 // indirect
66
68
github.com/beorn7/perks v1.0.1 // indirect
67
69
github.com/bits-and-blooms/bitset v1.22.0 // indirect
68
-
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
69
70
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
70
71
github.com/blevesearch/geo v0.2.4 // indirect
71
72
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
83
84
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
84
85
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
85
86
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
86
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
87
87
github.com/casbin/govaluate v1.3.0 // indirect
88
88
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
89
89
github.com/cespare/xxhash/v2 v2.3.0 // indirect
90
90
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
91
91
github.com/charmbracelet/lipgloss v1.1.0 // indirect
92
-
github.com/charmbracelet/log v0.4.2 // indirect
93
92
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
93
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
94
github.com/charmbracelet/x/term v0.2.1 // indirect
···
98
97
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
98
github.com/containerd/log v0.1.0 // indirect
100
99
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
100
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
103
101
github.com/distribution/reference v0.6.0 // indirect
104
102
github.com/dlclark/regexp2 v1.11.5 // indirect
···
152
150
github.com/kevinburke/ssh_config v1.2.0 // indirect
153
151
github.com/klauspost/compress v1.18.0 // indirect
154
152
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
153
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
161
154
github.com/mattn/go-isatty v0.0.20 // indirect
162
155
github.com/mattn/go-runewidth v0.0.16 // indirect
···
191
184
github.com/prometheus/procfs v0.16.1 // indirect
192
185
github.com/rivo/uniseg v0.4.7 // indirect
193
186
github.com/ryanuber/go-glob v1.0.0 // indirect
194
-
github.com/segmentio/asm v1.2.0 // indirect
195
187
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
196
188
github.com/spaolacci/murmur3 v1.1.0 // indirect
197
189
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
198
190
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
199
191
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
200
-
github.com/wyatt915/treeblood v0.1.16 // indirect
201
192
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
202
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
203
193
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
204
194
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
205
195
go.etcd.io/bbolt v1.4.0 // indirect
···
213
203
go.uber.org/atomic v1.11.0 // indirect
214
204
go.uber.org/multierr v1.11.0 // indirect
215
205
go.uber.org/zap v1.27.0 // indirect
206
+
golang.org/x/sync v0.17.0 // indirect
216
207
golang.org/x/sys v0.34.0 // indirect
217
208
golang.org/x/text v0.29.0 // indirect
218
209
golang.org/x/time v0.12.0 // indirect
+4
-21
go.sum
+4
-21
go.sum
···
71
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
72
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
73
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
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=
76
77
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
77
78
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
78
79
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
124
125
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
125
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
126
127
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
128
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
130
129
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
131
130
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
328
327
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
329
328
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
330
329
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
330
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
344
331
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
345
332
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
464
451
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
465
452
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
466
453
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
454
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
470
455
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
471
456
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
510
495
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
511
496
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
512
497
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
513
-
github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs=
514
-
github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208=
515
-
github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y=
516
-
github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
517
498
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
518
499
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
519
500
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
524
505
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
525
506
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
526
507
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
508
+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
509
+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
527
510
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
528
511
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
529
512
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+54
-76
guard/guard.go
+54
-76
guard/guard.go
···
12
12
"os/exec"
13
13
"strings"
14
14
15
-
"github.com/bluesky-social/indigo/atproto/identity"
16
15
securejoin "github.com/cyphar/filepath-securejoin"
17
16
"github.com/urfave/cli/v3"
18
-
"tangled.org/core/idresolver"
17
+
"tangled.org/core/log"
19
18
)
20
19
21
20
func Command() *cli.Command {
···
54
53
}
55
54
56
55
func Run(ctx context.Context, cmd *cli.Command) error {
56
+
l := log.FromContext(ctx)
57
+
57
58
incomingUser := cmd.String("user")
58
59
gitDir := cmd.String("git-dir")
59
60
logPath := cmd.String("log-path")
60
61
endpoint := cmd.String("internal-api")
61
62
motdFile := cmd.String("motd-file")
62
63
63
-
stream := io.Discard
64
64
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
65
-
if err == nil {
66
-
stream = logFile
65
+
if err != nil {
66
+
l.Error("failed to open log file", "error", err)
67
+
return err
68
+
} else {
69
+
fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo})
70
+
l = slog.New(fileHandler)
67
71
}
68
-
69
-
fileHandler := slog.NewJSONHandler(stream, &slog.HandlerOptions{Level: slog.LevelInfo})
70
-
slog.SetDefault(slog.New(fileHandler))
71
72
72
73
var clientIP string
73
74
if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
···
78
79
}
79
80
80
81
if incomingUser == "" {
81
-
slog.Error("access denied: no user specified")
82
+
l.Error("access denied: no user specified")
82
83
fmt.Fprintln(os.Stderr, "access denied: no user specified")
83
84
os.Exit(-1)
84
85
}
85
86
86
87
sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
87
88
88
-
slog.Info("connection attempt",
89
+
l.Info("connection attempt",
89
90
"user", incomingUser,
90
91
"command", sshCommand,
91
92
"client", clientIP)
92
93
94
+
// TODO: greet user with their resolved handle instead of did
93
95
if sshCommand == "" {
94
-
slog.Info("access denied: no interactive shells", "user", incomingUser)
96
+
l.Info("access denied: no interactive shells", "user", incomingUser)
95
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
96
98
os.Exit(-1)
97
99
}
98
100
99
101
cmdParts := strings.Fields(sshCommand)
100
102
if len(cmdParts) < 2 {
101
-
slog.Error("invalid command format", "command", sshCommand)
103
+
l.Error("invalid command format", "command", sshCommand)
102
104
fmt.Fprintln(os.Stderr, "invalid command format")
103
105
os.Exit(-1)
104
106
}
105
107
106
108
gitCommand := cmdParts[0]
107
-
108
-
// did:foo/repo-name or
109
-
// handle/repo-name or
110
-
// any of the above with a leading slash (/)
111
-
112
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
113
-
slog.Info("command components", "components", components)
114
-
115
-
if len(components) != 2 {
116
-
slog.Error("invalid repo format", "components", components)
117
-
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
118
-
os.Exit(-1)
119
-
}
120
-
121
-
didOrHandle := components[0]
122
-
identity := resolveIdentity(ctx, didOrHandle)
123
-
did := identity.DID.String()
124
-
repoName := components[1]
125
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
109
+
repoPath := cmdParts[1]
126
110
127
111
validCommands := map[string]bool{
128
112
"git-receive-pack": true,
···
130
114
"git-upload-archive": true,
131
115
}
132
116
if !validCommands[gitCommand] {
133
-
slog.Error("access denied: invalid git command", "command", gitCommand)
117
+
l.Error("access denied: invalid git command", "command", gitCommand)
134
118
fmt.Fprintln(os.Stderr, "access denied: invalid git command")
135
119
return fmt.Errorf("access denied: invalid git command")
136
120
}
137
121
138
-
if gitCommand != "git-upload-pack" {
139
-
if !isPushPermitted(incomingUser, qualifiedRepoName, endpoint) {
140
-
slog.Error("access denied: user not allowed",
141
-
"did", incomingUser,
142
-
"reponame", qualifiedRepoName)
143
-
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
144
-
os.Exit(-1)
145
-
}
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)
146
128
}
147
129
148
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
149
131
150
-
slog.Info("processing command",
132
+
l.Info("processing command",
151
133
"user", incomingUser,
152
134
"command", gitCommand,
153
-
"repo", repoName,
135
+
"repo", repoPath,
154
136
"fullPath", fullPath,
155
137
"client", clientIP)
156
138
157
139
var motdReader io.Reader
158
140
if reader, err := os.Open(motdFile); err != nil {
159
141
if !errors.Is(err, os.ErrNotExist) {
160
-
slog.Error("failed to read motd file", "error", err)
142
+
l.Error("failed to read motd file", "error", err)
161
143
}
162
144
motdReader = strings.NewReader("Welcome to this knot!\n")
163
145
} else {
···
174
156
gitCmd.Stdin = os.Stdin
175
157
gitCmd.Env = append(os.Environ(),
176
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
177
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
178
159
)
179
160
180
161
if err := gitCmd.Run(); err != nil {
181
-
slog.Error("command failed", "error", err)
162
+
l.Error("command failed", "error", err)
182
163
fmt.Fprintf(os.Stderr, "command failed: %v\n", err)
183
164
return fmt.Errorf("command failed: %v", err)
184
165
}
185
166
186
-
slog.Info("command completed",
167
+
l.Info("command completed",
187
168
"user", incomingUser,
188
169
"command", gitCommand,
189
-
"repo", repoName,
170
+
"repo", repoPath,
190
171
"success", true)
191
172
192
173
return nil
193
174
}
194
175
195
-
func resolveIdentity(ctx context.Context, didOrHandle string) *identity.Identity {
196
-
resolver := idresolver.DefaultResolver()
197
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
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())
198
186
if err != nil {
199
-
slog.Error("Error resolving handle", "error", err, "handle", didOrHandle)
200
-
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
201
-
os.Exit(1)
187
+
return "", err
202
188
}
203
-
if ident.Handle.IsInvalidHandle() {
204
-
slog.Error("Error resolving handle", "invalid handle", didOrHandle)
205
-
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
206
-
os.Exit(1)
207
-
}
208
-
return ident
209
-
}
189
+
defer resp.Body.Close()
210
190
211
-
func isPushPermitted(user, qualifiedRepoName, endpoint string) bool {
212
-
u, _ := url.Parse(endpoint + "/push-allowed")
213
-
q := u.Query()
214
-
q.Add("user", user)
215
-
q.Add("repo", qualifiedRepoName)
216
-
u.RawQuery = q.Encode()
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
217
192
218
-
req, err := http.Get(u.String())
193
+
body, err := io.ReadAll(resp.Body)
219
194
if err != nil {
220
-
slog.Error("Error verifying permissions", "error", err)
221
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
222
-
os.Exit(1)
195
+
return "", err
223
196
}
224
-
225
-
slog.Info("checking push permission",
226
-
"url", u.String(),
227
-
"status", req.Status)
197
+
text := string(body)
228
198
229
-
return req.StatusCode == http.StatusNoContent
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
+
}
230
208
}
+4
-4
hook/hook.go
+4
-4
hook/hook.go
···
48
48
},
49
49
Commands: []*cli.Command{
50
50
{
51
-
Name: "post-recieve",
52
-
Usage: "sends a post-recieve hook to the knot (waits for stdin)",
53
-
Action: postRecieve,
51
+
Name: "post-receive",
52
+
Usage: "sends a post-receive hook to the knot (waits for stdin)",
53
+
Action: postReceive,
54
54
},
55
55
},
56
56
}
57
57
}
58
58
59
-
func postRecieve(ctx context.Context, cmd *cli.Command) error {
59
+
func postReceive(ctx context.Context, cmd *cli.Command) error {
60
60
gitDir := cmd.String("git-dir")
61
61
userDid := cmd.String("user-did")
62
62
userHandle := cmd.String("user-handle")
+1
-1
hook/setup.go
+1
-1
hook/setup.go
···
138
138
option_var="GIT_PUSH_OPTION_$i"
139
139
push_options+=(-push-option "${!option_var}")
140
140
done
141
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive
142
142
`, executablePath, config.internalApi)
143
143
144
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+88
ico/ico.go
+88
ico/ico.go
···
1
+
package ico
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/binary"
6
+
"fmt"
7
+
"image"
8
+
"image/png"
9
+
)
10
+
11
+
type IconDir struct {
12
+
Reserved uint16 // must be 0
13
+
Type uint16 // 1 for ICO, 2 for CUR
14
+
Count uint16 // number of images
15
+
}
16
+
17
+
type IconDirEntry struct {
18
+
Width uint8 // 0 means 256
19
+
Height uint8 // 0 means 256
20
+
ColorCount uint8
21
+
Reserved uint8 // must be 0
22
+
ColorPlanes uint16 // 0 or 1
23
+
BitsPerPixel uint16
24
+
SizeInBytes uint32
25
+
Offset uint32
26
+
}
27
+
28
+
func ImageToIco(img image.Image) ([]byte, error) {
29
+
// encode image as png
30
+
var pngBuf bytes.Buffer
31
+
if err := png.Encode(&pngBuf, img); err != nil {
32
+
return nil, fmt.Errorf("failed to encode PNG: %w", err)
33
+
}
34
+
pngData := pngBuf.Bytes()
35
+
36
+
// get image dimensions
37
+
bounds := img.Bounds()
38
+
width := bounds.Dx()
39
+
height := bounds.Dy()
40
+
41
+
// prepare output buffer
42
+
var icoBuf bytes.Buffer
43
+
44
+
iconDir := IconDir{
45
+
Reserved: 0,
46
+
Type: 1, // ICO format
47
+
Count: 1, // One image
48
+
}
49
+
50
+
w := uint8(width)
51
+
h := uint8(height)
52
+
53
+
// width/height of 256 should be stored as 0
54
+
if width == 256 {
55
+
w = 0
56
+
}
57
+
if height == 256 {
58
+
h = 0
59
+
}
60
+
61
+
iconDirEntry := IconDirEntry{
62
+
Width: w,
63
+
Height: h,
64
+
ColorCount: 0, // 0 for PNG (32-bit)
65
+
Reserved: 0,
66
+
ColorPlanes: 1,
67
+
BitsPerPixel: 32, // PNG with alpha
68
+
SizeInBytes: uint32(len(pngData)),
69
+
Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY
70
+
}
71
+
72
+
// write IconDir
73
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil {
74
+
return nil, fmt.Errorf("failed to write ICONDIR: %w", err)
75
+
}
76
+
77
+
// write IconDirEntry
78
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil {
79
+
return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err)
80
+
}
81
+
82
+
// write PNG data directly
83
+
if _, err := icoBuf.Write(pngData); err != nil {
84
+
return nil, fmt.Errorf("failed to write PNG data: %w", err)
85
+
}
86
+
87
+
return icoBuf.Bytes(), nil
88
+
}
+17
-8
idresolver/resolver.go
+17
-8
idresolver/resolver.go
···
17
17
directory identity.Directory
18
18
}
19
19
20
-
func BaseDirectory() identity.Directory {
20
+
func BaseDirectory(plcUrl string) identity.Directory {
21
21
base := identity.BaseDirectory{
22
-
PLCURL: identity.DefaultPLCURL,
22
+
PLCURL: plcUrl,
23
23
HTTPClient: http.Client{
24
24
Timeout: time.Second * 10,
25
25
Transport: &http.Transport{
···
42
42
return &base
43
43
}
44
44
45
-
func RedisDirectory(url string) (identity.Directory, error) {
45
+
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
46
46
hitTTL := time.Hour * 24
47
47
errTTL := time.Second * 30
48
48
invalidHandleTTL := time.Minute * 5
49
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
49
+
return redisdir.NewRedisDirectory(
50
+
BaseDirectory(plcUrl),
51
+
url,
52
+
hitTTL,
53
+
errTTL,
54
+
invalidHandleTTL,
55
+
10000,
56
+
)
50
57
}
51
58
52
-
func DefaultResolver() *Resolver {
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)
53
62
return &Resolver{
54
-
directory: identity.DefaultDirectory(),
63
+
directory: &cached,
55
64
}
56
65
}
57
66
58
-
func RedisResolver(redisUrl string) (*Resolver, error) {
59
-
directory, err := RedisDirectory(redisUrl)
67
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
+
directory, err := RedisDirectory(redisUrl, plcUrl)
60
69
if err != nil {
61
70
return nil, err
62
71
}
+39
input.css
+39
input.css
···
161
161
@apply no-underline;
162
162
}
163
163
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline font-bold;
166
+
}
167
+
164
168
.prose li {
165
169
@apply my-0 py-0;
166
170
}
···
241
245
details[data-callout] > summary::-webkit-details-marker {
242
246
display: none;
243
247
}
248
+
244
249
}
245
250
@layer utilities {
246
251
.error {
···
250
255
@apply py-1 text-gray-900 dark:text-gray-100;
251
256
}
252
257
}
258
+
253
259
}
254
260
255
261
/* Background */
···
924
930
text-decoration: underline;
925
931
}
926
932
}
933
+
934
+
actor-typeahead {
935
+
--color-background: #ffffff;
936
+
--color-border: #d1d5db;
937
+
--color-shadow: #000000;
938
+
--color-hover: #f9fafb;
939
+
--color-avatar-fallback: #e5e7eb;
940
+
--radius: 0.0;
941
+
--padding-menu: 0.0rem;
942
+
z-index: 1000;
943
+
}
944
+
945
+
actor-typeahead::part(handle) {
946
+
color: #111827;
947
+
}
948
+
949
+
actor-typeahead::part(menu) {
950
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
951
+
}
952
+
953
+
@media (prefers-color-scheme: dark) {
954
+
actor-typeahead {
955
+
--color-background: #1f2937;
956
+
--color-border: #4b5563;
957
+
--color-shadow: #000000;
958
+
--color-hover: #374151;
959
+
--color-avatar-fallback: #4b5563;
960
+
}
961
+
962
+
actor-typeahead::part(handle) {
963
+
color: #f9fafb;
964
+
}
965
+
}
+15
-4
jetstream/jetstream.go
+15
-4
jetstream/jetstream.go
···
72
72
// existing instances of the closure when j.WantedDids is mutated
73
73
return func(ctx context.Context, evt *models.Event) error {
74
74
75
+
j.mu.RLock()
75
76
// empty filter => all dids allowed
76
-
if len(j.wantedDids) == 0 {
77
-
return processFunc(ctx, evt)
77
+
matches := len(j.wantedDids) == 0
78
+
if !matches {
79
+
if _, ok := j.wantedDids[evt.Did]; ok {
80
+
matches = true
81
+
}
78
82
}
83
+
j.mu.RUnlock()
79
84
80
-
if _, ok := j.wantedDids[evt.Did]; ok {
85
+
if matches {
81
86
return processFunc(ctx, evt)
82
87
} else {
83
88
return nil
···
122
127
123
128
go func() {
124
129
if j.waitForDid {
125
-
for len(j.wantedDids) == 0 {
130
+
for {
131
+
j.mu.RLock()
132
+
hasDid := len(j.wantedDids) != 0
133
+
j.mu.RUnlock()
134
+
if hasDid {
135
+
break
136
+
}
126
137
time.Sleep(time.Second)
127
138
}
128
139
}
+1
knotserver/config/config.go
+1
knotserver/config/config.go
···
19
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
21
Hostname string `env:"HOSTNAME, required"`
22
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
22
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
24
Owner string `env:"OWNER, required"`
24
25
LogDids bool `env:"LOG_DIDS, default=true"`
+81
knotserver/db/db.go
+81
knotserver/db/db.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"log/slog"
7
+
"strings"
8
+
9
+
_ "github.com/mattn/go-sqlite3"
10
+
"tangled.org/core/log"
11
+
)
12
+
13
+
type DB struct {
14
+
db *sql.DB
15
+
logger *slog.Logger
16
+
}
17
+
18
+
func Setup(ctx context.Context, dbPath string) (*DB, error) {
19
+
// https://github.com/mattn/go-sqlite3#connection-string
20
+
opts := []string{
21
+
"_foreign_keys=1",
22
+
"_journal_mode=WAL",
23
+
"_synchronous=NORMAL",
24
+
"_auto_vacuum=incremental",
25
+
}
26
+
27
+
logger := log.FromContext(ctx)
28
+
logger = log.SubLogger(logger, "db")
29
+
30
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
31
+
if err != nil {
32
+
return nil, err
33
+
}
34
+
35
+
conn, err := db.Conn(ctx)
36
+
if err != nil {
37
+
return nil, err
38
+
}
39
+
defer conn.Close()
40
+
41
+
_, err = conn.ExecContext(ctx, `
42
+
create table if not exists known_dids (
43
+
did text primary key
44
+
);
45
+
46
+
create table if not exists public_keys (
47
+
id integer primary key autoincrement,
48
+
did text not null,
49
+
key text not null,
50
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
51
+
unique(did, key),
52
+
foreign key (did) references known_dids(did) on delete cascade
53
+
);
54
+
55
+
create table if not exists _jetstream (
56
+
id integer primary key autoincrement,
57
+
last_time_us integer not null
58
+
);
59
+
60
+
create table if not exists events (
61
+
rkey text not null,
62
+
nsid text not null,
63
+
event text not null, -- json
64
+
created integer not null default (strftime('%s', 'now')),
65
+
primary key (rkey, nsid)
66
+
);
67
+
68
+
create table if not exists migrations (
69
+
id integer primary key autoincrement,
70
+
name text unique
71
+
);
72
+
`)
73
+
if err != nil {
74
+
return nil, err
75
+
}
76
+
77
+
return &DB{
78
+
db: db,
79
+
logger: logger,
80
+
}, nil
81
+
}
-64
knotserver/db/init.go
-64
knotserver/db/init.go
···
1
-
package db
2
-
3
-
import (
4
-
"database/sql"
5
-
"strings"
6
-
7
-
_ "github.com/mattn/go-sqlite3"
8
-
)
9
-
10
-
type DB struct {
11
-
db *sql.DB
12
-
}
13
-
14
-
func Setup(dbPath string) (*DB, error) {
15
-
// https://github.com/mattn/go-sqlite3#connection-string
16
-
opts := []string{
17
-
"_foreign_keys=1",
18
-
"_journal_mode=WAL",
19
-
"_synchronous=NORMAL",
20
-
"_auto_vacuum=incremental",
21
-
}
22
-
23
-
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
24
-
if err != nil {
25
-
return nil, err
26
-
}
27
-
28
-
// NOTE: If any other migration is added here, you MUST
29
-
// copy the pattern in appview: use a single sql.Conn
30
-
// for every migration.
31
-
32
-
_, err = db.Exec(`
33
-
create table if not exists known_dids (
34
-
did text primary key
35
-
);
36
-
37
-
create table if not exists public_keys (
38
-
id integer primary key autoincrement,
39
-
did text not null,
40
-
key text not null,
41
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
42
-
unique(did, key),
43
-
foreign key (did) references known_dids(did) on delete cascade
44
-
);
45
-
46
-
create table if not exists _jetstream (
47
-
id integer primary key autoincrement,
48
-
last_time_us integer not null
49
-
);
50
-
51
-
create table if not exists events (
52
-
rkey text not null,
53
-
nsid text not null,
54
-
event text not null, -- json
55
-
created integer not null default (strftime('%s', 'now')),
56
-
primary key (rkey, nsid)
57
-
);
58
-
`)
59
-
if err != nil {
60
-
return nil, err
61
-
}
62
-
63
-
return &DB{db: db}, nil
64
-
}
+1
-17
knotserver/git/diff.go
+1
-17
knotserver/git/diff.go
···
77
77
nd.Diff = append(nd.Diff, ndiff)
78
78
}
79
79
80
-
nd.Stat.FilesChanged = len(diffs)
81
-
nd.Commit.This = c.Hash.String()
82
-
nd.Commit.PGPSignature = c.PGPSignature
83
-
nd.Commit.Committer = c.Committer
84
-
nd.Commit.Tree = c.TreeHash.String()
85
-
86
-
if parent.Hash.IsZero() {
87
-
nd.Commit.Parent = ""
88
-
} else {
89
-
nd.Commit.Parent = parent.Hash.String()
90
-
}
91
-
nd.Commit.Author = c.Author
92
-
nd.Commit.Message = c.Message
93
-
94
-
if v, ok := c.ExtraHeaders["change-id"]; ok {
95
-
nd.Commit.ChangedId = string(v)
96
-
}
80
+
nd.Commit.FromGoGitCommit(c)
97
81
98
82
return &nd, nil
99
83
}
+38
-2
knotserver/git/fork.go
+38
-2
knotserver/git/fork.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
+
"log/slog"
7
+
"net/url"
6
8
"os/exec"
9
+
"path/filepath"
7
10
8
11
"github.com/go-git/go-git/v5"
9
12
"github.com/go-git/go-git/v5/config"
13
+
knotconfig "tangled.org/core/knotserver/config"
10
14
)
11
15
12
-
func Fork(repoPath, source string) error {
13
-
cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath)
16
+
func Fork(repoPath, source string, cfg *knotconfig.Config) error {
17
+
u, err := url.Parse(source)
18
+
if err != nil {
19
+
return fmt.Errorf("failed to parse source URL: %w", err)
20
+
}
21
+
22
+
if o := optimizeClone(u, cfg); o != nil {
23
+
u = o
24
+
}
25
+
26
+
cloneCmd := exec.Command("git", "clone", "--bare", u.String(), repoPath)
14
27
if err := cloneCmd.Run(); err != nil {
15
28
return fmt.Errorf("failed to bare clone repository: %w", err)
16
29
}
···
21
34
}
22
35
23
36
return nil
37
+
}
38
+
39
+
func optimizeClone(u *url.URL, cfg *knotconfig.Config) *url.URL {
40
+
// only optimize if it's the same host
41
+
if u.Host != cfg.Server.Hostname {
42
+
return nil
43
+
}
44
+
45
+
local := filepath.Join(cfg.Repo.ScanPath, u.Path)
46
+
47
+
// sanity check: is there a git repo there?
48
+
if _, err := PlainOpen(local); err != nil {
49
+
return nil
50
+
}
51
+
52
+
// create optimized file:// URL
53
+
optimized := &url.URL{
54
+
Scheme: "file",
55
+
Path: local,
56
+
}
57
+
58
+
slog.Debug("performing local clone", "url", optimized.String())
59
+
return optimized
24
60
}
25
61
26
62
func (g *GitRepo) Sync() error {
+60
-2
knotserver/git/git.go
+60
-2
knotserver/git/git.go
···
3
3
import (
4
4
"archive/tar"
5
5
"bytes"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"io/fs"
···
12
13
"time"
13
14
14
15
"github.com/go-git/go-git/v5"
16
+
"github.com/go-git/go-git/v5/config"
15
17
"github.com/go-git/go-git/v5/plumbing"
16
18
"github.com/go-git/go-git/v5/plumbing/object"
17
19
)
18
20
19
21
var (
20
-
ErrBinaryFile = fmt.Errorf("binary file")
21
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
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")
22
27
)
23
28
24
29
type GitRepo struct {
···
188
193
defer reader.Close()
189
194
190
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
191
249
}
192
250
193
251
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+13
-1
knotserver/git/service/service.go
+13
-1
knotserver/git/service/service.go
···
95
95
return c.RunService(cmd)
96
96
}
97
97
98
+
func (c *ServiceCommand) UploadArchive() error {
99
+
cmd := exec.Command("git", []string{
100
+
"upload-archive",
101
+
".",
102
+
}...)
103
+
104
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
105
+
cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol))
106
+
cmd.Dir = c.Dir
107
+
108
+
return c.RunService(cmd)
109
+
}
110
+
98
111
func (c *ServiceCommand) UploadPack() error {
99
112
cmd := exec.Command("git", []string{
100
-
"-c", "uploadpack.allowFilter=true",
101
113
"upload-pack",
102
114
"--stateless-rpc",
103
115
".",
+4
-13
knotserver/git/tree.go
+4
-13
knotserver/git/tree.go
···
7
7
"path"
8
8
"time"
9
9
10
+
"github.com/go-git/go-git/v5/plumbing/filemode"
10
11
"github.com/go-git/go-git/v5/plumbing/object"
11
12
"tangled.org/core/types"
12
13
)
···
53
54
}
54
55
55
56
for _, e := range subtree.Entries {
56
-
mode, _ := e.Mode.ToOSFileMode()
57
57
sz, _ := subtree.Size(e.Name)
58
-
59
58
fpath := path.Join(parent, e.Name)
60
59
61
60
var lastCommit *types.LastCommitInfo
···
69
68
70
69
nts = append(nts, types.NiceTree{
71
70
Name: e.Name,
72
-
Mode: mode.String(),
73
-
IsFile: e.Mode.IsFile(),
71
+
Mode: e.Mode.String(),
74
72
Size: sz,
75
73
LastCommit: lastCommit,
76
74
})
···
126
124
default:
127
125
}
128
126
129
-
mode, err := e.Mode.ToOSFileMode()
130
-
if err != nil {
131
-
// TODO: log this
132
-
continue
133
-
}
134
-
135
127
if e.Mode.IsFile() {
136
-
err = cb(e, currentTree, root)
137
-
if errors.Is(err, TerminateWalk) {
128
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
138
129
return err
139
130
}
140
131
}
141
132
142
133
// e is a directory
143
-
if mode.IsDir() {
134
+
if e.Mode == filemode.Dir {
144
135
subtree, err := currentTree.Tree(e.Name)
145
136
if err != nil {
146
137
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+47
knotserver/git.go
+47
knotserver/git.go
···
56
56
}
57
57
}
58
58
59
+
func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) {
60
+
did := chi.URLParam(r, "did")
61
+
name := chi.URLParam(r, "name")
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
+
if err != nil {
64
+
gitError(w, err.Error(), http.StatusInternalServerError)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
+
return
67
+
}
68
+
69
+
const expectedContentType = "application/x-git-upload-archive-request"
70
+
contentType := r.Header.Get("Content-Type")
71
+
if contentType != expectedContentType {
72
+
gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
73
+
}
74
+
75
+
var bodyReader io.ReadCloser = r.Body
76
+
if r.Header.Get("Content-Encoding") == "gzip" {
77
+
gzipReader, err := gzip.NewReader(r.Body)
78
+
if err != nil {
79
+
gitError(w, err.Error(), http.StatusInternalServerError)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err)
81
+
return
82
+
}
83
+
defer gzipReader.Close()
84
+
bodyReader = gzipReader
85
+
}
86
+
87
+
w.Header().Set("Content-Type", "application/x-git-upload-archive-result")
88
+
89
+
h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo)
90
+
91
+
cmd := service.ServiceCommand{
92
+
GitProtocol: r.Header.Get("Git-Protocol"),
93
+
Dir: repo,
94
+
Stdout: w,
95
+
Stdin: bodyReader,
96
+
}
97
+
98
+
w.WriteHeader(http.StatusOK)
99
+
100
+
if err := cmd.UploadArchive(); err != nil {
101
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
102
+
return
103
+
}
104
+
}
105
+
59
106
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
107
did := chi.URLParam(r, "did")
61
108
name := chi.URLParam(r, "name")
+4
-8
knotserver/ingester.go
+4
-8
knotserver/ingester.go
···
16
16
"github.com/bluesky-social/jetstream/pkg/models"
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
18
"tangled.org/core/api/tangled"
19
-
"tangled.org/core/idresolver"
20
19
"tangled.org/core/knotserver/db"
21
20
"tangled.org/core/knotserver/git"
22
21
"tangled.org/core/log"
···
120
119
}
121
120
122
121
// resolve this aturi to extract the repo record
123
-
resolver := idresolver.DefaultResolver()
124
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
122
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
125
123
if err != nil || ident.Handle.IsInvalidHandle() {
126
124
return fmt.Errorf("failed to resolve handle: %w", err)
127
125
}
···
163
161
164
162
var pipeline workflow.RawPipeline
165
163
for _, e := range workflowDir {
166
-
if !e.IsFile {
164
+
if !e.IsFile() {
167
165
continue
168
166
}
169
167
···
233
231
return err
234
232
}
235
233
236
-
resolver := idresolver.DefaultResolver()
237
-
238
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
234
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
239
235
if err != nil || subjectId.Handle.IsInvalidHandle() {
240
236
return err
241
237
}
242
238
243
239
// TODO: fix this for good, we need to fetch the record here unfortunately
244
240
// resolve this aturi to extract the repo record
245
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
241
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
246
242
if err != nil || owner.Handle.IsInvalidHandle() {
247
243
return fmt.Errorf("failed to resolve handle: %w", err)
248
244
}
+146
-49
knotserver/internal.go
+146
-49
knotserver/internal.go
···
27
27
)
28
28
29
29
type InternalHandle struct {
30
-
db *db.DB
31
-
c *config.Config
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
30
+
db *db.DB
31
+
c *config.Config
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
res *idresolver.Resolver
35
36
}
36
37
37
38
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
67
68
writeJSON(w, data)
68
69
}
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
+
70
131
type PushOptions struct {
71
132
skipCi bool
72
133
verboseCi bool
···
121
182
// non-fatal
122
183
}
123
184
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
-
}
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
134
189
}
135
190
136
191
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
143
198
writeJSON(w, resp)
144
199
}
145
200
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
201
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
179
202
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
180
203
if err != nil {
···
220
243
return errors.Join(errs, h.db.InsertEvent(event, h.n))
221
244
}
222
245
223
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
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 {
224
254
if pushOptions.skipCi {
225
255
return nil
226
256
}
···
247
277
248
278
var pipeline workflow.RawPipeline
249
279
for _, e := range workflowDir {
250
-
if !e.IsFile {
280
+
if !e.IsFile() {
251
281
continue
252
282
}
253
283
···
315
345
return h.db.InsertEvent(event, h.n)
316
346
}
317
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
+
318
412
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
319
413
r := chi.NewRouter()
320
414
l := log.FromContext(ctx)
321
415
l = log.SubLogger(l, "internal")
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
322
417
323
418
h := InternalHandle{
324
419
db,
···
326
421
e,
327
422
l,
328
423
n,
424
+
res,
329
425
}
330
426
331
427
r.Get("/push-allowed", h.PushAllowed)
332
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
333
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
334
431
r.Mount("/debug", middleware.Profiler())
335
432
+2
-1
knotserver/router.go
+2
-1
knotserver/router.go
···
36
36
l: log.FromContext(ctx),
37
37
jc: jc,
38
38
n: n,
39
-
resolver: idresolver.DefaultResolver(),
39
+
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
40
40
}
41
41
42
42
err := e.AddKnot(rbac.ThisServer)
···
82
82
r.Route("/{name}", func(r chi.Router) {
83
83
// routes for git operations
84
84
r.Get("/info/refs", h.InfoRefs)
85
+
r.Post("/git-upload-archive", h.UploadArchive)
85
86
r.Post("/git-upload-pack", h.UploadPack)
86
87
r.Post("/git-receive-pack", h.ReceivePack)
87
88
})
+1
-1
knotserver/server.go
+1
-1
knotserver/server.go
+1
-1
knotserver/xrpc/create_repo.go
+1
-1
knotserver/xrpc/create_repo.go
···
84
84
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
85
85
86
86
if data.Source != nil && *data.Source != "" {
87
-
err = git.Fork(repoPath, *data.Source)
87
+
err = git.Fork(repoPath, *data.Source, h.Config)
88
88
if err != nil {
89
89
l.Error("forking repo", "error", err.Error())
90
90
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+21
-2
knotserver/xrpc/repo_blob.go
+21
-2
knotserver/xrpc/repo_blob.go
···
42
42
return
43
43
}
44
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
+
45
63
contents, err := gr.RawContent(treePath)
46
64
if err != nil {
47
65
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
101
119
var encoding string
102
120
103
121
isBinary := !isTextual(mimeType)
122
+
size := int64(len(contents))
104
123
105
124
if isBinary {
106
125
content = base64.StdEncoding.EncodeToString(contents)
···
113
132
response := tangled.RepoBlob_Output{
114
133
Ref: ref,
115
134
Path: treePath,
116
-
Content: content,
135
+
Content: &content,
117
136
Encoding: &encoding,
118
-
Size: &[]int64{int64(len(contents))}[0],
137
+
Size: &size,
119
138
IsBinary: &isBinary,
120
139
}
121
140
+6
-1
knotserver/xrpc/repo_log.go
+6
-1
knotserver/xrpc/repo_log.go
···
62
62
return
63
63
}
64
64
65
+
tcommits := make([]types.Commit, len(commits))
66
+
for i, c := range commits {
67
+
tcommits[i].FromGoGitCommit(c)
68
+
}
69
+
65
70
// Create response using existing types.RepoLogResponse
66
71
response := types.RepoLogResponse{
67
-
Commits: commits,
72
+
Commits: tcommits,
68
73
Ref: ref,
69
74
Page: (offset / limit) + 1,
70
75
PerPage: limit,
+3
-5
knotserver/xrpc/repo_tree.go
+3
-5
knotserver/xrpc/repo_tree.go
···
67
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
68
for i, file := range files {
69
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,
70
+
Name: file.Name,
71
+
Mode: file.Mode,
72
+
Size: file.Size,
75
73
}
76
74
77
75
if file.LastCommit != nil {
+14
lexicons/issue/comment.json
+14
lexicons/issue/comment.json
···
29
29
"replyTo": {
30
30
"type": "string",
31
31
"format": "at-uri"
32
+
},
33
+
"mentions": {
34
+
"type": "array",
35
+
"items": {
36
+
"type": "string",
37
+
"format": "did"
38
+
}
39
+
},
40
+
"references": {
41
+
"type": "array",
42
+
"items": {
43
+
"type": "string",
44
+
"format": "at-uri"
45
+
}
32
46
}
33
47
}
34
48
}
+14
lexicons/issue/issue.json
+14
lexicons/issue/issue.json
···
24
24
"createdAt": {
25
25
"type": "string",
26
26
"format": "datetime"
27
+
},
28
+
"mentions": {
29
+
"type": "array",
30
+
"items": {
31
+
"type": "string",
32
+
"format": "did"
33
+
}
34
+
},
35
+
"references": {
36
+
"type": "array",
37
+
"items": {
38
+
"type": "string",
39
+
"format": "at-uri"
40
+
}
27
41
}
28
42
}
29
43
}
+14
lexicons/pulls/comment.json
+14
lexicons/pulls/comment.json
···
25
25
"createdAt": {
26
26
"type": "string",
27
27
"format": "datetime"
28
+
},
29
+
"mentions": {
30
+
"type": "array",
31
+
"items": {
32
+
"type": "string",
33
+
"format": "did"
34
+
}
35
+
},
36
+
"references": {
37
+
"type": "array",
38
+
"items": {
39
+
"type": "string",
40
+
"format": "at-uri"
41
+
}
28
42
}
29
43
}
30
44
}
+24
-2
lexicons/pulls/pull.json
+24
-2
lexicons/pulls/pull.json
···
12
12
"required": [
13
13
"target",
14
14
"title",
15
-
"patch",
15
+
"patchBlob",
16
16
"createdAt"
17
17
],
18
18
"properties": {
···
27
27
"type": "string"
28
28
},
29
29
"patch": {
30
-
"type": "string"
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": [
36
+
"text/x-patch"
37
+
],
38
+
"description": "patch content"
31
39
},
32
40
"source": {
33
41
"type": "ref",
···
36
44
"createdAt": {
37
45
"type": "string",
38
46
"format": "datetime"
47
+
},
48
+
"mentions": {
49
+
"type": "array",
50
+
"items": {
51
+
"type": "string",
52
+
"format": "did"
53
+
}
54
+
},
55
+
"references": {
56
+
"type": "array",
57
+
"items": {
58
+
"type": "string",
59
+
"format": "at-uri"
60
+
}
39
61
}
40
62
}
41
63
}
+49
-5
lexicons/repo/blob.json
+49
-5
lexicons/repo/blob.json
···
6
6
"type": "query",
7
7
"parameters": {
8
8
"type": "params",
9
-
"required": ["repo", "ref", "path"],
9
+
"required": [
10
+
"repo",
11
+
"ref",
12
+
"path"
13
+
],
10
14
"properties": {
11
15
"repo": {
12
16
"type": "string",
···
31
35
"encoding": "application/json",
32
36
"schema": {
33
37
"type": "object",
34
-
"required": ["ref", "path", "content"],
38
+
"required": [
39
+
"ref",
40
+
"path"
41
+
],
35
42
"properties": {
36
43
"ref": {
37
44
"type": "string",
···
48
55
"encoding": {
49
56
"type": "string",
50
57
"description": "Content encoding",
51
-
"enum": ["utf-8", "base64"]
58
+
"enum": [
59
+
"utf-8",
60
+
"base64"
61
+
]
52
62
},
53
63
"size": {
54
64
"type": "integer",
···
61
71
"mimeType": {
62
72
"type": "string",
63
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"
64
79
},
65
80
"lastCommit": {
66
81
"type": "ref",
···
90
105
},
91
106
"lastCommit": {
92
107
"type": "object",
93
-
"required": ["hash", "message", "when"],
108
+
"required": [
109
+
"hash",
110
+
"message",
111
+
"when"
112
+
],
94
113
"properties": {
95
114
"hash": {
96
115
"type": "string",
···
117
136
},
118
137
"signature": {
119
138
"type": "object",
120
-
"required": ["name", "email", "when"],
139
+
"required": [
140
+
"name",
141
+
"email",
142
+
"when"
143
+
],
121
144
"properties": {
122
145
"name": {
123
146
"type": "string",
···
131
154
"type": "string",
132
155
"format": "datetime",
133
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"
134
178
}
135
179
}
136
180
}
+15
lexicons/repo/repo.json
+15
lexicons/repo/repo.json
···
32
32
"minGraphemes": 1,
33
33
"maxGraphemes": 140
34
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
+
},
35
50
"source": {
36
51
"type": "string",
37
52
"format": "uri",
+1
-9
lexicons/repo/tree.json
+1
-9
lexicons/repo/tree.json
···
91
91
},
92
92
"treeEntry": {
93
93
"type": "object",
94
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
94
+
"required": ["name", "mode", "size"],
95
95
"properties": {
96
96
"name": {
97
97
"type": "string",
···
104
104
"size": {
105
105
"type": "integer",
106
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
107
},
116
108
"last_commit": {
117
109
"type": "ref",
+5
-32
nix/gomod2nix.toml
+5
-32
nix/gomod2nix.toml
···
109
109
version = "v0.0.0-20241210005130-ea96859b93d1"
110
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
111
111
[mod."github.com/bmatcuk/doublestar/v4"]
112
-
version = "v4.7.1"
113
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
112
+
version = "v4.9.1"
113
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
114
114
[mod."github.com/carlmjohnson/versioninfo"]
115
115
version = "v0.22.5"
116
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
165
165
[mod."github.com/davecgh/go-spew"]
166
166
version = "v1.1.2-0.20180830191138-d8f796af33cc"
167
167
hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc="
168
-
[mod."github.com/decred/dcrd/dcrec/secp256k1/v4"]
169
-
version = "v4.4.0"
170
-
hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg="
171
168
[mod."github.com/dgraph-io/ristretto"]
172
169
version = "v0.2.0"
173
170
hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw="
···
373
370
[mod."github.com/klauspost/cpuid/v2"]
374
371
version = "v2.3.0"
375
372
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
376
-
[mod."github.com/lestrrat-go/blackmagic"]
377
-
version = "v1.0.4"
378
-
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
379
-
[mod."github.com/lestrrat-go/httpcc"]
380
-
version = "v1.0.1"
381
-
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
382
-
[mod."github.com/lestrrat-go/httprc"]
383
-
version = "v1.0.6"
384
-
hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM="
385
-
[mod."github.com/lestrrat-go/iter"]
386
-
version = "v1.0.2"
387
-
hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw="
388
-
[mod."github.com/lestrrat-go/jwx/v2"]
389
-
version = "v2.1.6"
390
-
hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc="
391
-
[mod."github.com/lestrrat-go/option"]
392
-
version = "v1.0.1"
393
-
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
394
373
[mod."github.com/lucasb-eyer/go-colorful"]
395
374
version = "v1.2.0"
396
375
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
···
511
490
[mod."github.com/ryanuber/go-glob"]
512
491
version = "v1.0.0"
513
492
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
514
-
[mod."github.com/segmentio/asm"]
515
-
version = "v1.2.0"
516
-
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
517
493
[mod."github.com/sergi/go-diff"]
518
494
version = "v1.1.0"
519
495
hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY="
···
548
524
[mod."github.com/whyrusleeping/cbor-gen"]
549
525
version = "v0.3.1"
550
526
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
551
-
[mod."github.com/wyatt915/goldmark-treeblood"]
552
-
version = "v0.0.1"
553
-
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
554
-
[mod."github.com/wyatt915/treeblood"]
555
-
version = "v0.1.16"
556
-
hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw="
557
527
[mod."github.com/xo/terminfo"]
558
528
version = "v0.0.0-20220910002029-abceb7e1c41e"
559
529
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
560
530
[mod."github.com/yuin/goldmark"]
561
531
version = "v1.7.13"
562
532
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
533
+
[mod."github.com/yuin/goldmark-emoji"]
534
+
version = "v1.0.6"
535
+
hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY="
563
536
[mod."github.com/yuin/goldmark-highlighting/v2"]
564
537
version = "v2.0.0-20230729083705-37449abec8cc"
565
538
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+285
-18
nix/modules/appview.nix
+285
-18
nix/modules/appview.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled-appview;
6
+
cfg = config.services.tangled.appview;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled-appview = {
10
+
services.tangled.appview = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
14
14
description = "Enable tangled appview";
15
15
};
16
+
16
17
package = mkOption {
17
18
type = types.package;
18
19
description = "Package to use for the appview";
19
20
};
21
+
22
+
# core configuration
20
23
port = mkOption {
21
-
type = types.int;
24
+
type = types.port;
22
25
default = 3000;
23
26
description = "Port to run the appview on";
24
27
};
25
-
cookie_secret = mkOption {
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 {
26
36
type = types.str;
27
-
default = "00000000000000000000000000000000";
28
-
description = "Cookie secret";
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
+
};
29
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
+
30
228
environmentFile = mkOption {
31
229
type = with types; nullOr path;
32
230
default = null;
33
-
example = "/etc/tangled-appview.env";
231
+
example = "/etc/appview.env";
34
232
description = ''
35
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
234
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
-
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.
41
245
'';
42
246
};
43
247
};
44
248
};
45
249
46
250
config = mkIf cfg.enable {
47
-
systemd.services.tangled-appview = {
251
+
services.redis.servers.appview = {
252
+
enable = true;
253
+
port = 6379;
254
+
};
255
+
256
+
systemd.services.appview = {
48
257
description = "tangled appview service";
49
258
wantedBy = ["multi-user.target"];
259
+
after = ["redis-appview.service" "network-online.target"];
260
+
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
50
262
51
263
serviceConfig = {
52
-
ListenStream = "0.0.0.0:${toString cfg.port}";
264
+
Type = "simple";
53
265
ExecStart = "${cfg.package}/bin/appview";
54
266
Restart = "always";
55
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
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"];
56
280
};
57
281
58
-
environment = {
59
-
TANGLED_DB_PATH = "appview.db";
60
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
-
};
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
+
};
62
329
};
63
330
};
64
331
}
+76
-4
nix/modules/knot.nix
+76
-4
nix/modules/knot.nix
···
4
4
lib,
5
5
...
6
6
}: let
7
-
cfg = config.services.tangled-knot;
7
+
cfg = config.services.tangled.knot;
8
8
in
9
9
with lib; {
10
10
options = {
11
-
services.tangled-knot = {
11
+
services.tangled.knot = {
12
12
enable = mkOption {
13
13
type = types.bool;
14
14
default = false;
···
51
51
description = "Path where repositories are scanned from";
52
52
};
53
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
+
54
75
mainBranch = mkOption {
55
76
type = types.str;
56
77
default = "main";
57
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";
58
93
};
59
94
};
60
95
···
111
146
description = "Hostname for the server (required)";
112
147
};
113
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
+
114
167
dev = mkOption {
115
168
type = types.bool;
116
169
default = false;
···
142
195
Match User ${cfg.gitUser}
143
196
AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
144
197
AuthorizedKeysCommandUser nobody
198
+
ChallengeResponseAuthentication no
199
+
PasswordAuthentication no
145
200
'';
146
201
};
147
202
···
178
233
mkdir -p "${cfg.stateDir}/.config/git"
179
234
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
235
[user]
181
-
name = Git User
182
-
email = git@example.com
236
+
name = ${cfg.git.userName}
237
+
email = ${cfg.git.userEmail}
183
238
[receive]
184
239
advertisePushOptions = true
240
+
[uploadpack]
241
+
allowFilter = true
185
242
EOF
186
243
${setMotd}
187
244
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
193
250
WorkingDirectory = cfg.stateDir;
194
251
Environment = [
195
252
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
253
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
196
254
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
255
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
256
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
197
257
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
198
258
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
199
259
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
260
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
261
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
262
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
263
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
202
264
"KNOT_SERVER_OWNER=${cfg.server.owner}"
265
+
"KNOT_SERVER_LOG_DIDS=${
266
+
if cfg.server.logDids
267
+
then "true"
268
+
else "false"
269
+
}"
270
+
"KNOT_SERVER_DEV=${
271
+
if cfg.server.dev
272
+
then "true"
273
+
else "false"
274
+
}"
203
275
];
204
276
ExecStart = "${cfg.package}/bin/knot server";
205
277
Restart = "always";
+10
-3
nix/modules/spindle.nix
+10
-3
nix/modules/spindle.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled-spindle;
6
+
cfg = config.services.tangled.spindle;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled-spindle = {
10
+
services.tangled.spindle = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
···
35
35
type = types.str;
36
36
example = "my.spindle.com";
37
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";
38
44
};
39
45
40
46
jetstreamEndpoint = mkOption {
···
119
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
120
126
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
121
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
122
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
128
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
123
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
124
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
132
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+8
-1
nix/pkgs/appview-static-files.nix
+8
-1
nix/pkgs/appview-static-files.nix
···
5
5
lucide-src,
6
6
inter-fonts-src,
7
7
ibm-plex-mono-src,
8
+
actor-typeahead-src,
8
9
sqlite-lib,
9
10
tailwindcss,
11
+
dolly,
10
12
src,
11
13
}:
12
14
runCommandLocal "appview-static-files" {
···
16
18
(allow file-read* (subpath "/System/Library/OpenSSL"))
17
19
'';
18
20
} ''
19
-
mkdir -p $out/{fonts,icons} && cd $out
21
+
mkdir -p $out/{fonts,icons,logos} && cd $out
20
22
cp -f ${htmx-src} htmx.min.js
21
23
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
22
24
cp -rf ${lucide-src}/*.svg icons/
···
24
26
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
27
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
28
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
29
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
30
+
31
+
${dolly}/bin/dolly -output logos/dolly.png -size 180x180
32
+
${dolly}/bin/dolly -output logos/dolly.ico -size 48x48
33
+
${dolly}/bin/dolly -output logos/dolly.svg -color currentColor
27
34
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
35
# for whatever reason (produces broken css), so we are doing this instead
29
36
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+57
nix/pkgs/docs.nix
+57
nix/pkgs/docs.nix
···
1
+
{
2
+
pandoc,
3
+
tailwindcss,
4
+
runCommandLocal,
5
+
inter-fonts-src,
6
+
ibm-plex-mono-src,
7
+
lucide-src,
8
+
dolly,
9
+
src,
10
+
}:
11
+
runCommandLocal "docs" {} ''
12
+
mkdir -p working
13
+
14
+
# copy templates, themes, styles, filters to working directory
15
+
cp ${src}/docs/*.html working/
16
+
cp ${src}/docs/*.theme working/
17
+
cp ${src}/docs/*.css working/
18
+
19
+
# icons
20
+
cp -rf ${lucide-src}/*.svg working/
21
+
22
+
# logo
23
+
${dolly}/bin/dolly -output working/dolly.svg -color currentColor
24
+
25
+
# content - chunked
26
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
27
+
-o $out/ \
28
+
-t chunkedhtml \
29
+
--variable toc \
30
+
--variable-json single-page=false \
31
+
--toc-depth=2 \
32
+
--css=stylesheet.css \
33
+
--chunk-template="%i.html" \
34
+
--highlight-style=working/highlight.theme \
35
+
--template=working/template.html
36
+
37
+
# content - single page
38
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
39
+
-o $out/single-page.html \
40
+
--toc \
41
+
--variable toc \
42
+
--variable single-page \
43
+
--toc-depth=2 \
44
+
--css=stylesheet.css \
45
+
--highlight-style=working/highlight.theme \
46
+
--template=working/template.html
47
+
48
+
# fonts
49
+
mkdir -p $out/static/fonts
50
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/
51
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/
52
+
cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/
53
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/
54
+
55
+
# styles
56
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css
57
+
''
+21
nix/pkgs/dolly.nix
+21
nix/pkgs/dolly.nix
···
1
+
{
2
+
buildGoApplication,
3
+
modules,
4
+
src,
5
+
}:
6
+
buildGoApplication {
7
+
pname = "dolly";
8
+
version = "0.1.0";
9
+
inherit src modules;
10
+
11
+
# patch the static dir
12
+
postUnpack = ''
13
+
pushd source
14
+
mkdir -p appview/pages/static
15
+
touch appview/pages/static/x
16
+
popd
17
+
'';
18
+
19
+
doCheck = false;
20
+
subPackages = ["cmd/dolly"];
21
+
}
+1
-1
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+7
-5
nix/pkgs/sqlite-lib.nix
+7
-5
nix/pkgs/sqlite-lib.nix
···
1
1
{
2
-
gcc,
3
2
stdenv,
4
3
sqlite-lib-src,
5
4
}:
6
5
stdenv.mkDerivation {
7
6
name = "sqlite-lib";
8
7
src = sqlite-lib-src;
9
-
nativeBuildInputs = [gcc];
8
+
10
9
buildPhase = ''
11
-
gcc -c sqlite3.c
12
-
ar rcs libsqlite3.a sqlite3.o
13
-
ranlib libsqlite3.a
10
+
$CC -c sqlite3.c
11
+
$AR rcs libsqlite3.a sqlite3.o
12
+
$RANLIB libsqlite3.a
13
+
'';
14
+
15
+
installPhase = ''
14
16
mkdir -p $out/include $out/lib
15
17
cp *.h $out/include
16
18
cp libsqlite3.a $out/lib
+25
-12
nix/vm.nix
+25
-12
nix/vm.nix
···
8
8
var = builtins.getEnv name;
9
9
in
10
10
if var == ""
11
-
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details"
12
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";
13
22
in
14
23
nixpkgs.lib.nixosSystem {
15
24
inherit system;
···
39
48
# knot
40
49
{
41
50
from = "host";
42
-
host.port = 6000;
43
-
guest.port = 6000;
51
+
host.port = 6444;
52
+
guest.port = 6444;
44
53
}
45
54
# spindle
46
55
{
···
73
82
time.timeZone = "Europe/London";
74
83
services.getty.autologinUser = "root";
75
84
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
-
services.tangled-knot = {
85
+
services.tangled.knot = {
77
86
enable = true;
78
87
motd = "Welcome to the development knot!\n";
79
88
server = {
80
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
81
-
hostname = "localhost:6000";
82
-
listenAddr = "0.0.0.0:6000";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444";
91
+
plcUrl = plcUrl;
92
+
jetstreamEndpoint = jetstream;
93
+
listenAddr = "0.0.0.0:6444";
83
94
};
84
95
};
85
-
services.tangled-spindle = {
96
+
services.tangled.spindle = {
86
97
enable = true;
87
98
server = {
88
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
89
-
hostname = "localhost:6555";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
+
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
90
103
listenAddr = "0.0.0.0:6555";
91
104
dev = true;
92
105
queueSize = 100;
···
99
112
users = {
100
113
# So we don't have to deal with permission clashing between
101
114
# blank disk VMs and existing state
102
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
115
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
104
117
105
118
# TODO: separate spindle user
106
119
};
···
120
133
serviceConfig.PermissionsStartOnly = true;
121
134
};
122
135
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);
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);
125
138
};
126
139
})
127
140
];
+122
orm/orm.go
+122
orm/orm.go
···
1
+
package orm
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"fmt"
7
+
"log/slog"
8
+
"reflect"
9
+
"strings"
10
+
)
11
+
12
+
type migrationFn = func(*sql.Tx) error
13
+
14
+
func RunMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
15
+
logger = logger.With("migration", name)
16
+
17
+
tx, err := c.BeginTx(context.Background(), nil)
18
+
if err != nil {
19
+
return err
20
+
}
21
+
defer tx.Rollback()
22
+
23
+
var exists bool
24
+
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
25
+
if err != nil {
26
+
return err
27
+
}
28
+
29
+
if !exists {
30
+
// run migration
31
+
err = migrationFn(tx)
32
+
if err != nil {
33
+
logger.Error("failed to run migration", "err", err)
34
+
return err
35
+
}
36
+
37
+
// mark migration as complete
38
+
_, err = tx.Exec("insert into migrations (name) values (?)", name)
39
+
if err != nil {
40
+
logger.Error("failed to mark migration as complete", "err", err)
41
+
return err
42
+
}
43
+
44
+
// commit the transaction
45
+
if err := tx.Commit(); err != nil {
46
+
return err
47
+
}
48
+
49
+
logger.Info("migration applied successfully")
50
+
} else {
51
+
logger.Warn("skipped migration, already applied")
52
+
}
53
+
54
+
return nil
55
+
}
56
+
57
+
type Filter struct {
58
+
Key string
59
+
arg any
60
+
Cmp string
61
+
}
62
+
63
+
func newFilter(key, cmp string, arg any) Filter {
64
+
return Filter{
65
+
Key: key,
66
+
arg: arg,
67
+
Cmp: cmp,
68
+
}
69
+
}
70
+
71
+
func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) }
72
+
func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) }
73
+
func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) }
74
+
func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) }
75
+
func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) }
76
+
func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) }
77
+
func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) }
78
+
func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) }
79
+
func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) }
80
+
func FilterContains(key string, arg any) Filter {
81
+
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
82
+
}
83
+
84
+
func (f Filter) Condition() string {
85
+
rv := reflect.ValueOf(f.arg)
86
+
kind := rv.Kind()
87
+
88
+
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
89
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
90
+
if rv.Len() == 0 {
91
+
// always false
92
+
return "1 = 0"
93
+
}
94
+
95
+
placeholders := make([]string, rv.Len())
96
+
for i := range placeholders {
97
+
placeholders[i] = "?"
98
+
}
99
+
100
+
return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", "))
101
+
}
102
+
103
+
return fmt.Sprintf("%s %s ?", f.Key, f.Cmp)
104
+
}
105
+
106
+
func (f Filter) Arg() []any {
107
+
rv := reflect.ValueOf(f.arg)
108
+
kind := rv.Kind()
109
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
110
+
if rv.Len() == 0 {
111
+
return nil
112
+
}
113
+
114
+
out := make([]any, rv.Len())
115
+
for i := range rv.Len() {
116
+
out[i] = rv.Index(i).Interface()
117
+
}
118
+
return out
119
+
}
120
+
121
+
return []any{f.arg}
122
+
}
-1
patchutil/patchutil.go
-1
patchutil/patchutil.go
+8
rbac/rbac.go
+8
rbac/rbac.go
···
285
285
return e.E.Enforce(user, domain, repo, "repo:delete")
286
286
}
287
287
288
+
func (e *Enforcer) IsRepoOwner(user, domain, repo string) (bool, error) {
289
+
return e.E.Enforce(user, domain, repo, "repo:owner")
290
+
}
291
+
292
+
func (e *Enforcer) IsRepoCollaborator(user, domain, repo string) (bool, error) {
293
+
return e.E.Enforce(user, domain, repo, "repo:collaborator")
294
+
}
295
+
288
296
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
289
297
return e.E.Enforce(user, domain, repo, "repo:push")
290
298
}
+3
-3
readme.md
+3
-3
readme.md
···
10
10
11
11
## docs
12
12
13
-
* [knot hosting guide](/docs/knot-hosting.md)
14
-
* [contributing guide](/docs/contributing.md) **please read before opening a PR!**
15
-
* [hacking on tangled](/docs/hacking.md)
13
+
- [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide)
14
+
- [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!**
15
+
- [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled)
16
16
17
17
## security
18
18
+31
sets/gen.go
+31
sets/gen.go
···
1
+
package sets
2
+
3
+
import (
4
+
"math/rand"
5
+
"reflect"
6
+
"testing/quick"
7
+
)
8
+
9
+
func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value {
10
+
s := New[T]()
11
+
12
+
var zero T
13
+
itemType := reflect.TypeOf(zero)
14
+
15
+
for {
16
+
if s.Len() >= size {
17
+
break
18
+
}
19
+
20
+
item, ok := quick.Value(itemType, rand)
21
+
if !ok {
22
+
continue
23
+
}
24
+
25
+
if val, ok := item.Interface().(T); ok {
26
+
s.Insert(val)
27
+
}
28
+
}
29
+
30
+
return reflect.ValueOf(s)
31
+
}
+35
sets/readme.txt
+35
sets/readme.txt
···
1
+
sets
2
+
----
3
+
set datastructure for go with generics and iterators. the
4
+
api is supposed to mimic rust's std::collections::HashSet api.
5
+
6
+
s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4}))
7
+
s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6}))
8
+
9
+
union := sets.Collect(s1.Union(s2))
10
+
intersect := sets.Collect(s1.Intersection(s2))
11
+
diff := sets.Collect(s1.Difference(s2))
12
+
symdiff := sets.Collect(s1.SymmetricDifference(s2))
13
+
14
+
s1.Len() // 4
15
+
s1.Contains(1) // true
16
+
s1.IsEmpty() // false
17
+
s1.IsSubset(s2) // true
18
+
s1.IsSuperset(s2) // false
19
+
s1.IsDisjoint(s2) // false
20
+
21
+
if exists := s1.Insert(1); exists {
22
+
// already existed in set
23
+
}
24
+
25
+
if existed := s1.Remove(1); existed {
26
+
// existed in set, now removed
27
+
}
28
+
29
+
30
+
testing
31
+
-------
32
+
includes property-based tests using the wonderful
33
+
testing/quick module!
34
+
35
+
go test -v
+174
sets/set.go
+174
sets/set.go
···
1
+
package sets
2
+
3
+
import (
4
+
"iter"
5
+
"maps"
6
+
)
7
+
8
+
type Set[T comparable] struct {
9
+
data map[T]struct{}
10
+
}
11
+
12
+
func New[T comparable]() Set[T] {
13
+
return Set[T]{
14
+
data: make(map[T]struct{}),
15
+
}
16
+
}
17
+
18
+
func (s *Set[T]) Insert(item T) bool {
19
+
_, exists := s.data[item]
20
+
s.data[item] = struct{}{}
21
+
return !exists
22
+
}
23
+
24
+
func Singleton[T comparable](item T) Set[T] {
25
+
n := New[T]()
26
+
_ = n.Insert(item)
27
+
return n
28
+
}
29
+
30
+
func (s *Set[T]) Remove(item T) bool {
31
+
_, exists := s.data[item]
32
+
if exists {
33
+
delete(s.data, item)
34
+
}
35
+
return exists
36
+
}
37
+
38
+
func (s Set[T]) Contains(item T) bool {
39
+
_, exists := s.data[item]
40
+
return exists
41
+
}
42
+
43
+
func (s Set[T]) Len() int {
44
+
return len(s.data)
45
+
}
46
+
47
+
func (s Set[T]) IsEmpty() bool {
48
+
return len(s.data) == 0
49
+
}
50
+
51
+
func (s *Set[T]) Clear() {
52
+
s.data = make(map[T]struct{})
53
+
}
54
+
55
+
func (s Set[T]) All() iter.Seq[T] {
56
+
return func(yield func(T) bool) {
57
+
for item := range s.data {
58
+
if !yield(item) {
59
+
return
60
+
}
61
+
}
62
+
}
63
+
}
64
+
65
+
func (s Set[T]) Clone() Set[T] {
66
+
return Set[T]{
67
+
data: maps.Clone(s.data),
68
+
}
69
+
}
70
+
71
+
func (s Set[T]) Union(other Set[T]) iter.Seq[T] {
72
+
if s.Len() >= other.Len() {
73
+
return chain(s.All(), other.Difference(s))
74
+
} else {
75
+
return chain(other.All(), s.Difference(other))
76
+
}
77
+
}
78
+
79
+
func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] {
80
+
return func(yield func(T) bool) {
81
+
for _, seq := range seqs {
82
+
for item := range seq {
83
+
if !yield(item) {
84
+
return
85
+
}
86
+
}
87
+
}
88
+
}
89
+
}
90
+
91
+
func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] {
92
+
return func(yield func(T) bool) {
93
+
for item := range s.data {
94
+
if other.Contains(item) {
95
+
if !yield(item) {
96
+
return
97
+
}
98
+
}
99
+
}
100
+
}
101
+
}
102
+
103
+
func (s Set[T]) Difference(other Set[T]) iter.Seq[T] {
104
+
return func(yield func(T) bool) {
105
+
for item := range s.data {
106
+
if !other.Contains(item) {
107
+
if !yield(item) {
108
+
return
109
+
}
110
+
}
111
+
}
112
+
}
113
+
}
114
+
115
+
func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] {
116
+
return func(yield func(T) bool) {
117
+
for item := range s.data {
118
+
if !other.Contains(item) {
119
+
if !yield(item) {
120
+
return
121
+
}
122
+
}
123
+
}
124
+
for item := range other.data {
125
+
if !s.Contains(item) {
126
+
if !yield(item) {
127
+
return
128
+
}
129
+
}
130
+
}
131
+
}
132
+
}
133
+
134
+
func (s Set[T]) IsSubset(other Set[T]) bool {
135
+
for item := range s.data {
136
+
if !other.Contains(item) {
137
+
return false
138
+
}
139
+
}
140
+
return true
141
+
}
142
+
143
+
func (s Set[T]) IsSuperset(other Set[T]) bool {
144
+
return other.IsSubset(s)
145
+
}
146
+
147
+
func (s Set[T]) IsDisjoint(other Set[T]) bool {
148
+
for item := range s.data {
149
+
if other.Contains(item) {
150
+
return false
151
+
}
152
+
}
153
+
return true
154
+
}
155
+
156
+
func (s Set[T]) Equal(other Set[T]) bool {
157
+
if s.Len() != other.Len() {
158
+
return false
159
+
}
160
+
for item := range s.data {
161
+
if !other.Contains(item) {
162
+
return false
163
+
}
164
+
}
165
+
return true
166
+
}
167
+
168
+
func Collect[T comparable](seq iter.Seq[T]) Set[T] {
169
+
result := New[T]()
170
+
for item := range seq {
171
+
result.Insert(item)
172
+
}
173
+
return result
174
+
}
+411
sets/set_test.go
+411
sets/set_test.go
···
1
+
package sets
2
+
3
+
import (
4
+
"slices"
5
+
"testing"
6
+
"testing/quick"
7
+
)
8
+
9
+
func TestNew(t *testing.T) {
10
+
s := New[int]()
11
+
if s.Len() != 0 {
12
+
t.Errorf("New set should be empty, got length %d", s.Len())
13
+
}
14
+
if !s.IsEmpty() {
15
+
t.Error("New set should be empty")
16
+
}
17
+
}
18
+
19
+
func TestFromSlice(t *testing.T) {
20
+
s := Collect(slices.Values([]int{1, 2, 3, 2, 1}))
21
+
if s.Len() != 3 {
22
+
t.Errorf("Expected length 3, got %d", s.Len())
23
+
}
24
+
if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) {
25
+
t.Error("Set should contain all unique elements from slice")
26
+
}
27
+
}
28
+
29
+
func TestInsert(t *testing.T) {
30
+
s := New[string]()
31
+
32
+
if !s.Insert("hello") {
33
+
t.Error("First insert should return true")
34
+
}
35
+
if s.Insert("hello") {
36
+
t.Error("Duplicate insert should return false")
37
+
}
38
+
if s.Len() != 1 {
39
+
t.Errorf("Expected length 1, got %d", s.Len())
40
+
}
41
+
}
42
+
43
+
func TestRemove(t *testing.T) {
44
+
s := Collect(slices.Values([]int{1, 2, 3}))
45
+
46
+
if !s.Remove(2) {
47
+
t.Error("Remove existing element should return true")
48
+
}
49
+
if s.Remove(2) {
50
+
t.Error("Remove non-existing element should return false")
51
+
}
52
+
if s.Contains(2) {
53
+
t.Error("Element should be removed")
54
+
}
55
+
if s.Len() != 2 {
56
+
t.Errorf("Expected length 2, got %d", s.Len())
57
+
}
58
+
}
59
+
60
+
func TestContains(t *testing.T) {
61
+
s := Collect(slices.Values([]int{1, 2, 3}))
62
+
63
+
if !s.Contains(1) {
64
+
t.Error("Should contain 1")
65
+
}
66
+
if s.Contains(4) {
67
+
t.Error("Should not contain 4")
68
+
}
69
+
}
70
+
71
+
func TestClear(t *testing.T) {
72
+
s := Collect(slices.Values([]int{1, 2, 3}))
73
+
s.Clear()
74
+
75
+
if !s.IsEmpty() {
76
+
t.Error("Set should be empty after clear")
77
+
}
78
+
if s.Len() != 0 {
79
+
t.Errorf("Expected length 0, got %d", s.Len())
80
+
}
81
+
}
82
+
83
+
func TestIterator(t *testing.T) {
84
+
s := Collect(slices.Values([]int{1, 2, 3}))
85
+
var items []int
86
+
87
+
for item := range s.All() {
88
+
items = append(items, item)
89
+
}
90
+
91
+
slices.Sort(items)
92
+
expected := []int{1, 2, 3}
93
+
if !slices.Equal(items, expected) {
94
+
t.Errorf("Expected %v, got %v", expected, items)
95
+
}
96
+
}
97
+
98
+
func TestClone(t *testing.T) {
99
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
100
+
s2 := s1.Clone()
101
+
102
+
if !s1.Equal(s2) {
103
+
t.Error("Cloned set should be equal to original")
104
+
}
105
+
106
+
s2.Insert(4)
107
+
if s1.Contains(4) {
108
+
t.Error("Modifying clone should not affect original")
109
+
}
110
+
}
111
+
112
+
func TestUnion(t *testing.T) {
113
+
s1 := Collect(slices.Values([]int{1, 2}))
114
+
s2 := Collect(slices.Values([]int{2, 3}))
115
+
116
+
result := Collect(s1.Union(s2))
117
+
expected := Collect(slices.Values([]int{1, 2, 3}))
118
+
119
+
if !result.Equal(expected) {
120
+
t.Errorf("Expected %v, got %v", expected, result)
121
+
}
122
+
}
123
+
124
+
func TestIntersection(t *testing.T) {
125
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
126
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
127
+
128
+
expected := Collect(slices.Values([]int{2, 3}))
129
+
result := Collect(s1.Intersection(s2))
130
+
131
+
if !result.Equal(expected) {
132
+
t.Errorf("Expected %v, got %v", expected, result)
133
+
}
134
+
}
135
+
136
+
func TestDifference(t *testing.T) {
137
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
138
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
139
+
140
+
expected := Collect(slices.Values([]int{1}))
141
+
result := Collect(s1.Difference(s2))
142
+
143
+
if !result.Equal(expected) {
144
+
t.Errorf("Expected %v, got %v", expected, result)
145
+
}
146
+
}
147
+
148
+
func TestSymmetricDifference(t *testing.T) {
149
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
150
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
151
+
152
+
expected := Collect(slices.Values([]int{1, 4}))
153
+
result := Collect(s1.SymmetricDifference(s2))
154
+
155
+
if !result.Equal(expected) {
156
+
t.Errorf("Expected %v, got %v", expected, result)
157
+
}
158
+
}
159
+
160
+
func TestSymmetricDifferenceCommutativeProperty(t *testing.T) {
161
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
162
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
163
+
164
+
result1 := Collect(s1.SymmetricDifference(s2))
165
+
result2 := Collect(s2.SymmetricDifference(s1))
166
+
167
+
if !result1.Equal(result2) {
168
+
t.Errorf("Expected %v, got %v", result1, result2)
169
+
}
170
+
}
171
+
172
+
func TestIsSubset(t *testing.T) {
173
+
s1 := Collect(slices.Values([]int{1, 2}))
174
+
s2 := Collect(slices.Values([]int{1, 2, 3}))
175
+
176
+
if !s1.IsSubset(s2) {
177
+
t.Error("s1 should be subset of s2")
178
+
}
179
+
if s2.IsSubset(s1) {
180
+
t.Error("s2 should not be subset of s1")
181
+
}
182
+
}
183
+
184
+
func TestIsSuperset(t *testing.T) {
185
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
186
+
s2 := Collect(slices.Values([]int{1, 2}))
187
+
188
+
if !s1.IsSuperset(s2) {
189
+
t.Error("s1 should be superset of s2")
190
+
}
191
+
if s2.IsSuperset(s1) {
192
+
t.Error("s2 should not be superset of s1")
193
+
}
194
+
}
195
+
196
+
func TestIsDisjoint(t *testing.T) {
197
+
s1 := Collect(slices.Values([]int{1, 2}))
198
+
s2 := Collect(slices.Values([]int{3, 4}))
199
+
s3 := Collect(slices.Values([]int{2, 3}))
200
+
201
+
if !s1.IsDisjoint(s2) {
202
+
t.Error("s1 and s2 should be disjoint")
203
+
}
204
+
if s1.IsDisjoint(s3) {
205
+
t.Error("s1 and s3 should not be disjoint")
206
+
}
207
+
}
208
+
209
+
func TestEqual(t *testing.T) {
210
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
211
+
s2 := Collect(slices.Values([]int{3, 2, 1}))
212
+
s3 := Collect(slices.Values([]int{1, 2}))
213
+
214
+
if !s1.Equal(s2) {
215
+
t.Error("s1 and s2 should be equal")
216
+
}
217
+
if s1.Equal(s3) {
218
+
t.Error("s1 and s3 should not be equal")
219
+
}
220
+
}
221
+
222
+
func TestCollect(t *testing.T) {
223
+
s1 := Collect(slices.Values([]int{1, 2}))
224
+
s2 := Collect(slices.Values([]int{2, 3}))
225
+
226
+
unionSet := Collect(s1.Union(s2))
227
+
if unionSet.Len() != 3 {
228
+
t.Errorf("Expected union set length 3, got %d", unionSet.Len())
229
+
}
230
+
if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) {
231
+
t.Error("Union set should contain 1, 2, and 3")
232
+
}
233
+
234
+
diffSet := Collect(s1.Difference(s2))
235
+
if diffSet.Len() != 1 {
236
+
t.Errorf("Expected difference set length 1, got %d", diffSet.Len())
237
+
}
238
+
if !diffSet.Contains(1) {
239
+
t.Error("Difference set should contain 1")
240
+
}
241
+
}
242
+
243
+
func TestPropertySingleonLen(t *testing.T) {
244
+
f := func(item int) bool {
245
+
single := Singleton(item)
246
+
return single.Len() == 1
247
+
}
248
+
249
+
if err := quick.Check(f, nil); err != nil {
250
+
t.Error(err)
251
+
}
252
+
}
253
+
254
+
func TestPropertyInsertIdempotent(t *testing.T) {
255
+
f := func(s Set[int], item int) bool {
256
+
clone := s.Clone()
257
+
258
+
clone.Insert(item)
259
+
firstLen := clone.Len()
260
+
261
+
clone.Insert(item)
262
+
secondLen := clone.Len()
263
+
264
+
return firstLen == secondLen
265
+
}
266
+
267
+
if err := quick.Check(f, nil); err != nil {
268
+
t.Error(err)
269
+
}
270
+
}
271
+
272
+
func TestPropertyUnionCommutative(t *testing.T) {
273
+
f := func(s1 Set[int], s2 Set[int]) bool {
274
+
union1 := Collect(s1.Union(s2))
275
+
union2 := Collect(s2.Union(s1))
276
+
return union1.Equal(union2)
277
+
}
278
+
279
+
if err := quick.Check(f, nil); err != nil {
280
+
t.Error(err)
281
+
}
282
+
}
283
+
284
+
func TestPropertyIntersectionCommutative(t *testing.T) {
285
+
f := func(s1 Set[int], s2 Set[int]) bool {
286
+
inter1 := Collect(s1.Intersection(s2))
287
+
inter2 := Collect(s2.Intersection(s1))
288
+
return inter1.Equal(inter2)
289
+
}
290
+
291
+
if err := quick.Check(f, nil); err != nil {
292
+
t.Error(err)
293
+
}
294
+
}
295
+
296
+
func TestPropertyCloneEquals(t *testing.T) {
297
+
f := func(s Set[int]) bool {
298
+
clone := s.Clone()
299
+
return s.Equal(clone)
300
+
}
301
+
302
+
if err := quick.Check(f, nil); err != nil {
303
+
t.Error(err)
304
+
}
305
+
}
306
+
307
+
func TestPropertyIntersectionIsSubset(t *testing.T) {
308
+
f := func(s1 Set[int], s2 Set[int]) bool {
309
+
inter := Collect(s1.Intersection(s2))
310
+
return inter.IsSubset(s1) && inter.IsSubset(s2)
311
+
}
312
+
313
+
if err := quick.Check(f, nil); err != nil {
314
+
t.Error(err)
315
+
}
316
+
}
317
+
318
+
func TestPropertyUnionIsSuperset(t *testing.T) {
319
+
f := func(s1 Set[int], s2 Set[int]) bool {
320
+
union := Collect(s1.Union(s2))
321
+
return union.IsSuperset(s1) && union.IsSuperset(s2)
322
+
}
323
+
324
+
if err := quick.Check(f, nil); err != nil {
325
+
t.Error(err)
326
+
}
327
+
}
328
+
329
+
func TestPropertyDifferenceDisjoint(t *testing.T) {
330
+
f := func(s1 Set[int], s2 Set[int]) bool {
331
+
diff := Collect(s1.Difference(s2))
332
+
return diff.IsDisjoint(s2)
333
+
}
334
+
335
+
if err := quick.Check(f, nil); err != nil {
336
+
t.Error(err)
337
+
}
338
+
}
339
+
340
+
func TestPropertySymmetricDifferenceCommutative(t *testing.T) {
341
+
f := func(s1 Set[int], s2 Set[int]) bool {
342
+
symDiff1 := Collect(s1.SymmetricDifference(s2))
343
+
symDiff2 := Collect(s2.SymmetricDifference(s1))
344
+
return symDiff1.Equal(symDiff2)
345
+
}
346
+
347
+
if err := quick.Check(f, nil); err != nil {
348
+
t.Error(err)
349
+
}
350
+
}
351
+
352
+
func TestPropertyRemoveWorks(t *testing.T) {
353
+
f := func(s Set[int], item int) bool {
354
+
clone := s.Clone()
355
+
clone.Insert(item)
356
+
clone.Remove(item)
357
+
return !clone.Contains(item)
358
+
}
359
+
360
+
if err := quick.Check(f, nil); err != nil {
361
+
t.Error(err)
362
+
}
363
+
}
364
+
365
+
func TestPropertyClearEmpty(t *testing.T) {
366
+
f := func(s Set[int]) bool {
367
+
s.Clear()
368
+
return s.IsEmpty() && s.Len() == 0
369
+
}
370
+
371
+
if err := quick.Check(f, nil); err != nil {
372
+
t.Error(err)
373
+
}
374
+
}
375
+
376
+
func TestPropertyIsSubsetReflexive(t *testing.T) {
377
+
f := func(s Set[int]) bool {
378
+
return s.IsSubset(s)
379
+
}
380
+
381
+
if err := quick.Check(f, nil); err != nil {
382
+
t.Error(err)
383
+
}
384
+
}
385
+
386
+
func TestPropertyDeMorganUnion(t *testing.T) {
387
+
f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool {
388
+
// create a universe that contains both sets
389
+
u := universe.Clone()
390
+
for item := range s1.All() {
391
+
u.Insert(item)
392
+
}
393
+
for item := range s2.All() {
394
+
u.Insert(item)
395
+
}
396
+
397
+
// (A u B)' = A' n B'
398
+
union := Collect(s1.Union(s2))
399
+
complementUnion := Collect(u.Difference(union))
400
+
401
+
complementS1 := Collect(u.Difference(s1))
402
+
complementS2 := Collect(u.Difference(s2))
403
+
intersectionComplements := Collect(complementS1.Intersection(complementS2))
404
+
405
+
return complementUnion.Equal(intersectionComplements)
406
+
}
407
+
408
+
if err := quick.Check(f, nil); err != nil {
409
+
t.Error(err)
410
+
}
411
+
}
+1
spindle/config/config.go
+1
spindle/config/config.go
···
13
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
14
Hostname string `env:"HOSTNAME, required"`
15
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
16
17
Dev bool `env:"DEV, default=false"`
17
18
Owner string `env:"OWNER, required"`
18
19
Secrets Secrets `env:",prefix=SECRETS_"`
+1
spindle/db/repos.go
+1
spindle/db/repos.go
+34
-23
spindle/engine/engine.go
+34
-23
spindle/engine/engine.go
···
3
3
import (
4
4
"context"
5
5
"errors"
6
-
"fmt"
7
6
"log/slog"
7
+
"sync"
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"golang.org/x/sync/errgroup"
11
10
"tangled.org/core/notifier"
12
11
"tangled.org/core/spindle/config"
13
12
"tangled.org/core/spindle/db"
···
31
30
}
32
31
}
33
32
34
-
eg, ctx := errgroup.WithContext(ctx)
33
+
var wg sync.WaitGroup
35
34
for eng, wfs := range pipeline.Workflows {
36
35
workflowTimeout := eng.WorkflowTimeout()
37
36
l.Info("using workflow timeout", "timeout", workflowTimeout)
38
37
39
38
for _, w := range wfs {
40
-
eg.Go(func() error {
39
+
wg.Add(1)
40
+
go func() {
41
+
defer wg.Done()
42
+
41
43
wid := models.WorkflowId{
42
44
PipelineId: pipelineId,
43
45
Name: w.Name,
···
45
47
46
48
err := db.StatusRunning(wid, n)
47
49
if err != nil {
48
-
return err
50
+
l.Error("failed to set workflow status to running", "wid", wid, "err", err)
51
+
return
49
52
}
50
53
51
54
err = eng.SetupWorkflow(ctx, wid, &w)
···
61
64
62
65
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
63
66
if dbErr != nil {
64
-
return dbErr
67
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
65
68
}
66
-
return err
69
+
return
67
70
}
68
71
defer eng.DestroyWorkflow(ctx, wid)
69
72
70
-
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid)
73
+
secretValues := make([]string, len(allSecrets))
74
+
for i, s := range allSecrets {
75
+
secretValues[i] = s.Value
76
+
}
77
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues)
71
78
if err != nil {
72
79
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
73
80
wfLogger = nil
···
79
86
defer cancel()
80
87
81
88
for stepIdx, step := range w.Steps {
89
+
// log start of step
82
90
if wfLogger != nil {
83
-
ctl := wfLogger.ControlWriter(stepIdx, step)
84
-
ctl.Write([]byte(step.Name()))
91
+
wfLogger.
92
+
ControlWriter(stepIdx, step, models.StepStatusStart).
93
+
Write([]byte{0})
85
94
}
86
95
87
96
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
97
+
98
+
// log end of step
99
+
if wfLogger != nil {
100
+
wfLogger.
101
+
ControlWriter(stepIdx, step, models.StepStatusEnd).
102
+
Write([]byte{0})
103
+
}
104
+
88
105
if err != nil {
89
106
if errors.Is(err, ErrTimedOut) {
90
107
dbErr := db.StatusTimeout(wid, n)
91
108
if dbErr != nil {
92
-
return dbErr
109
+
l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
93
110
}
94
111
} else {
95
112
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
96
113
if dbErr != nil {
97
-
return dbErr
114
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
98
115
}
99
116
}
100
-
101
-
return fmt.Errorf("starting steps image: %w", err)
117
+
return
102
118
}
103
119
}
104
120
105
121
err = db.StatusSuccess(wid, n)
106
122
if err != nil {
107
-
return err
123
+
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
108
124
}
109
-
110
-
return nil
111
-
})
125
+
}()
112
126
}
113
127
}
114
128
115
-
if err := eg.Wait(); err != nil {
116
-
l.Error("failed to run one or more workflows", "err", err)
117
-
} else {
118
-
l.Error("successfully ran full pipeline")
119
-
}
129
+
wg.Wait()
130
+
l.Info("all workflows completed")
120
131
}
+12
-11
spindle/engines/nixery/engine.go
+12
-11
spindle/engines/nixery/engine.go
···
73
73
type addlFields struct {
74
74
image string
75
75
container string
76
-
env map[string]string
77
76
}
78
77
79
78
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
···
103
102
swf.Steps = append(swf.Steps, sstep)
104
103
}
105
104
swf.Name = twf.Name
106
-
addl.env = dwf.Environment
105
+
swf.Environment = dwf.Environment
107
106
addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery)
108
107
109
108
setup := &setupSteps{}
110
109
111
110
setup.addStep(nixConfStep())
112
-
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
111
+
setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
113
112
// this step could be empty
114
113
if s := dependencyStep(dwf.Dependencies); s != nil {
115
114
setup.addStep(*s)
···
288
287
289
288
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
290
289
addl := w.Data.(addlFields)
291
-
workflowEnvs := ConstructEnvs(addl.env)
290
+
workflowEnvs := ConstructEnvs(w.Environment)
292
291
// TODO(winter): should SetupWorkflow also have secret access?
293
292
// IMO yes, but probably worth thinking on.
294
293
for _, s := range secrets {
295
294
workflowEnvs.AddEnv(s.Key, s.Value)
296
295
}
297
296
298
-
step := w.Steps[idx].(Step)
297
+
step := w.Steps[idx]
299
298
300
299
select {
301
300
case <-ctx.Done():
···
304
303
}
305
304
306
305
envs := append(EnvVars(nil), workflowEnvs...)
307
-
for k, v := range step.environment {
308
-
envs.AddEnv(k, v)
306
+
if nixStep, ok := step.(Step); ok {
307
+
for k, v := range nixStep.environment {
308
+
envs.AddEnv(k, v)
309
+
}
309
310
}
310
311
envs.AddEnv("HOME", homeDir)
311
312
312
313
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
313
-
Cmd: []string{"bash", "-c", step.command},
314
+
Cmd: []string{"bash", "-c", step.Command()},
314
315
AttachStdout: true,
315
316
AttachStderr: true,
316
317
Env: envs,
···
333
334
// Docker doesn't provide an API to kill an exec run
334
335
// (sure, we could grab the PID and kill it ourselves,
335
336
// but that's wasted effort)
336
-
e.l.Warn("step timed out", "step", step.Name)
337
+
e.l.Warn("step timed out", "step", step.Name())
337
338
338
339
<-tailDone
339
340
···
381
382
defer logs.Close()
382
383
383
384
_, err = stdcopy.StdCopy(
384
-
wfLogger.DataWriter("stdout"),
385
-
wfLogger.DataWriter("stderr"),
385
+
wfLogger.DataWriter(stepIdx, "stdout"),
386
+
wfLogger.DataWriter(stepIdx, "stderr"),
386
387
logs.Reader,
387
388
)
388
389
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
-73
spindle/engines/nixery/setup_steps.go
-73
spindle/engines/nixery/setup_steps.go
···
2
2
3
3
import (
4
4
"fmt"
5
-
"path"
6
5
"strings"
7
-
8
-
"tangled.org/core/api/tangled"
9
-
"tangled.org/core/workflow"
10
6
)
11
7
12
8
func nixConfStep() Step {
···
17
13
command: setupCmd,
18
14
name: "Configure Nix",
19
15
}
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
16
}
90
17
91
18
// dependencyStep processes dependencies defined in the workflow.
+3
-7
spindle/ingester.go
+3
-7
spindle/ingester.go
···
9
9
10
10
"tangled.org/core/api/tangled"
11
11
"tangled.org/core/eventconsumer"
12
-
"tangled.org/core/idresolver"
13
12
"tangled.org/core/rbac"
14
13
"tangled.org/core/spindle/db"
15
14
···
142
141
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
143
142
var err error
144
143
did := e.Did
145
-
resolver := idresolver.DefaultResolver()
146
144
147
145
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
146
···
190
188
}
191
189
192
190
// add collaborators to rbac
193
-
owner, err := resolver.ResolveIdent(ctx, did)
191
+
owner, err := s.res.ResolveIdent(ctx, did)
194
192
if err != nil || owner.Handle.IsInvalidHandle() {
195
193
return err
196
194
}
···
225
223
return err
226
224
}
227
225
228
-
resolver := idresolver.DefaultResolver()
229
-
230
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
231
227
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
228
return err
233
229
}
···
240
236
241
237
// TODO: get rid of this entirely
242
238
// resolve this aturi to extract the repo record
243
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
244
240
if err != nil || owner.Handle.IsInvalidHandle() {
245
241
return fmt.Errorf("failed to resolve handle: %w", err)
246
242
}
+150
spindle/models/clone.go
+150
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.Repo, 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 repo metadata.
105
+
func BuildRepoURL(repo *tangled.Pipeline_TriggerRepo, devMode bool) string {
106
+
if repo == nil {
107
+
return ""
108
+
}
109
+
110
+
scheme := "https://"
111
+
if devMode {
112
+
scheme = "http://"
113
+
}
114
+
115
+
// Get host from knot
116
+
host := repo.Knot
117
+
118
+
// In dev mode, replace localhost with host.docker.internal for Docker networking
119
+
if devMode && strings.Contains(host, "localhost") {
120
+
host = strings.ReplaceAll(host, "localhost", "host.docker.internal")
121
+
}
122
+
123
+
// Build URL: {scheme}{knot}/{did}/{repo}
124
+
return fmt.Sprintf("%s%s/%s/%s", scheme, host, repo.Did, repo.Repo)
125
+
}
126
+
127
+
// buildFetchArgs constructs the arguments for git fetch based on clone options
128
+
func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string {
129
+
args := []string{}
130
+
131
+
// Set fetch depth (default to 1 for shallow clone)
132
+
depth := clone.Depth
133
+
if depth == 0 {
134
+
depth = 1
135
+
}
136
+
args = append(args, fmt.Sprintf("--depth=%d", depth))
137
+
138
+
// Add submodules if requested
139
+
if clone.Submodules {
140
+
args = append(args, "--recurse-submodules=yes")
141
+
}
142
+
143
+
// Add remote and SHA
144
+
args = append(args, "origin")
145
+
if sha != "" {
146
+
args = append(args, sha)
147
+
}
148
+
149
+
return args
150
+
}
+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
+
}
+20
-11
spindle/models/logger.go
+20
-11
spindle/models/logger.go
···
12
12
type WorkflowLogger struct {
13
13
file *os.File
14
14
encoder *json.Encoder
15
+
mask *SecretMask
15
16
}
16
17
17
-
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
18
+
func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) {
18
19
path := LogFilePath(baseDir, wid)
19
20
20
21
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
25
26
return &WorkflowLogger{
26
27
file: file,
27
28
encoder: json.NewEncoder(file),
29
+
mask: NewSecretMask(secretValues),
28
30
}, nil
29
31
}
30
32
···
37
39
return l.file.Close()
38
40
}
39
41
40
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
42
+
func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer {
41
43
return &dataWriter{
42
44
logger: l,
45
+
idx: idx,
43
46
stream: stream,
44
47
}
45
48
}
46
49
47
-
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
50
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer {
48
51
return &controlWriter{
49
-
logger: l,
50
-
idx: idx,
51
-
step: step,
52
+
logger: l,
53
+
idx: idx,
54
+
step: step,
55
+
stepStatus: stepStatus,
52
56
}
53
57
}
54
58
55
59
type dataWriter struct {
56
60
logger *WorkflowLogger
61
+
idx int
57
62
stream string
58
63
}
59
64
60
65
func (w *dataWriter) Write(p []byte) (int, error) {
61
66
line := strings.TrimRight(string(p), "\r\n")
62
-
entry := NewDataLogLine(line, w.stream)
67
+
if w.logger.mask != nil {
68
+
line = w.logger.mask.Mask(line)
69
+
}
70
+
entry := NewDataLogLine(w.idx, line, w.stream)
63
71
if err := w.logger.encoder.Encode(entry); err != nil {
64
72
return 0, err
65
73
}
···
67
75
}
68
76
69
77
type controlWriter struct {
70
-
logger *WorkflowLogger
71
-
idx int
72
-
step Step
78
+
logger *WorkflowLogger
79
+
idx int
80
+
step Step
81
+
stepStatus StepStatus
73
82
}
74
83
75
84
func (w *controlWriter) Write(_ []byte) (int, error) {
76
-
entry := NewControlLogLine(w.idx, w.step)
85
+
entry := NewControlLogLine(w.idx, w.step, w.stepStatus)
77
86
if err := w.logger.encoder.Encode(entry); err != nil {
78
87
return 0, err
79
88
}
+23
-8
spindle/models/models.go
+23
-8
spindle/models/models.go
···
4
4
"fmt"
5
5
"regexp"
6
6
"slices"
7
+
"time"
7
8
8
9
"tangled.org/core/api/tangled"
9
10
···
76
77
var (
77
78
// step log data
78
79
LogKindData LogKind = "data"
79
-
// indicates start/end of a step
80
+
// indicates status of a step
80
81
LogKindControl LogKind = "control"
81
82
)
82
83
84
+
// step status indicator in control log lines
85
+
type StepStatus string
86
+
87
+
var (
88
+
StepStatusStart StepStatus = "start"
89
+
StepStatusEnd StepStatus = "end"
90
+
)
91
+
83
92
type LogLine struct {
84
-
Kind LogKind `json:"kind"`
85
-
Content string `json:"content"`
93
+
Kind LogKind `json:"kind"`
94
+
Content string `json:"content"`
95
+
Time time.Time `json:"time"`
96
+
StepId int `json:"step_id"`
86
97
87
98
// fields if kind is "data"
88
99
Stream string `json:"stream,omitempty"`
89
100
90
101
// fields if kind is "control"
91
-
StepId int `json:"step_id,omitempty"`
92
-
StepKind StepKind `json:"step_kind,omitempty"`
93
-
StepCommand string `json:"step_command,omitempty"`
102
+
StepStatus StepStatus `json:"step_status,omitempty"`
103
+
StepKind StepKind `json:"step_kind,omitempty"`
104
+
StepCommand string `json:"step_command,omitempty"`
94
105
}
95
106
96
-
func NewDataLogLine(content, stream string) LogLine {
107
+
func NewDataLogLine(idx int, content, stream string) LogLine {
97
108
return LogLine{
98
109
Kind: LogKindData,
110
+
Time: time.Now(),
99
111
Content: content,
112
+
StepId: idx,
100
113
Stream: stream,
101
114
}
102
115
}
103
116
104
-
func NewControlLogLine(idx int, step Step) LogLine {
117
+
func NewControlLogLine(idx int, step Step, status StepStatus) LogLine {
105
118
return LogLine{
106
119
Kind: LogKindControl,
120
+
Time: time.Now(),
107
121
Content: step.Name(),
108
122
StepId: idx,
123
+
StepStatus: status,
109
124
StepKind: step.Kind(),
110
125
StepCommand: step.Command(),
111
126
}
+4
-3
spindle/models/pipeline.go
+4
-3
spindle/models/pipeline.go
+77
spindle/models/pipeline_env.go
+77
spindle/models/pipeline_env.go
···
1
+
package models
2
+
3
+
import (
4
+
"strings"
5
+
6
+
"github.com/go-git/go-git/v5/plumbing"
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/workflow"
9
+
)
10
+
11
+
// PipelineEnvVars extracts environment variables from pipeline trigger metadata.
12
+
// These are framework-provided variables that are injected into workflow steps.
13
+
func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId, devMode bool) map[string]string {
14
+
if tr == nil {
15
+
return nil
16
+
}
17
+
18
+
env := make(map[string]string)
19
+
20
+
// Standard CI environment variable
21
+
env["CI"] = "true"
22
+
23
+
env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey
24
+
25
+
// Repo info
26
+
if tr.Repo != nil {
27
+
env["TANGLED_REPO_KNOT"] = tr.Repo.Knot
28
+
env["TANGLED_REPO_DID"] = tr.Repo.Did
29
+
env["TANGLED_REPO_NAME"] = tr.Repo.Repo
30
+
env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch
31
+
env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo, devMode)
32
+
}
33
+
34
+
switch workflow.TriggerKind(tr.Kind) {
35
+
case workflow.TriggerKindPush:
36
+
if tr.Push != nil {
37
+
refName := plumbing.ReferenceName(tr.Push.Ref)
38
+
refType := "branch"
39
+
if refName.IsTag() {
40
+
refType = "tag"
41
+
}
42
+
43
+
env["TANGLED_REF"] = tr.Push.Ref
44
+
env["TANGLED_REF_NAME"] = refName.Short()
45
+
env["TANGLED_REF_TYPE"] = refType
46
+
env["TANGLED_SHA"] = tr.Push.NewSha
47
+
env["TANGLED_COMMIT_SHA"] = tr.Push.NewSha
48
+
}
49
+
50
+
case workflow.TriggerKindPullRequest:
51
+
if tr.PullRequest != nil {
52
+
// For PRs, the "ref" is the source branch
53
+
env["TANGLED_REF"] = "refs/heads/" + tr.PullRequest.SourceBranch
54
+
env["TANGLED_REF_NAME"] = tr.PullRequest.SourceBranch
55
+
env["TANGLED_REF_TYPE"] = "branch"
56
+
env["TANGLED_SHA"] = tr.PullRequest.SourceSha
57
+
env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha
58
+
59
+
// PR-specific variables
60
+
env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch
61
+
env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch
62
+
env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha
63
+
env["TANGLED_PR_ACTION"] = tr.PullRequest.Action
64
+
}
65
+
66
+
case workflow.TriggerKindManual:
67
+
// Manual triggers may not have ref/sha info
68
+
// Include any manual inputs if present
69
+
if tr.Manual != nil {
70
+
for _, pair := range tr.Manual.Inputs {
71
+
env["TANGLED_INPUT_"+strings.ToUpper(pair.Key)] = pair.Value
72
+
}
73
+
}
74
+
}
75
+
76
+
return env
77
+
}
+260
spindle/models/pipeline_env_test.go
+260
spindle/models/pipeline_env_test.go
···
1
+
package models
2
+
3
+
import (
4
+
"testing"
5
+
6
+
"tangled.org/core/api/tangled"
7
+
"tangled.org/core/workflow"
8
+
)
9
+
10
+
func TestPipelineEnvVars_PushBranch(t *testing.T) {
11
+
tr := &tangled.Pipeline_TriggerMetadata{
12
+
Kind: string(workflow.TriggerKindPush),
13
+
Push: &tangled.Pipeline_PushTriggerData{
14
+
NewSha: "abc123def456",
15
+
OldSha: "000000000000",
16
+
Ref: "refs/heads/main",
17
+
},
18
+
Repo: &tangled.Pipeline_TriggerRepo{
19
+
Knot: "example.com",
20
+
Did: "did:plc:user123",
21
+
Repo: "my-repo",
22
+
DefaultBranch: "main",
23
+
},
24
+
}
25
+
id := PipelineId{
26
+
Knot: "example.com",
27
+
Rkey: "123123",
28
+
}
29
+
env := PipelineEnvVars(tr, id, false)
30
+
31
+
// Check standard CI variable
32
+
if env["CI"] != "true" {
33
+
t.Errorf("Expected CI='true', got '%s'", env["CI"])
34
+
}
35
+
36
+
// Check ref variables
37
+
if env["TANGLED_REF"] != "refs/heads/main" {
38
+
t.Errorf("Expected TANGLED_REF='refs/heads/main', got '%s'", env["TANGLED_REF"])
39
+
}
40
+
if env["TANGLED_REF_NAME"] != "main" {
41
+
t.Errorf("Expected TANGLED_REF_NAME='main', got '%s'", env["TANGLED_REF_NAME"])
42
+
}
43
+
if env["TANGLED_REF_TYPE"] != "branch" {
44
+
t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"])
45
+
}
46
+
47
+
// Check SHA variables
48
+
if env["TANGLED_SHA"] != "abc123def456" {
49
+
t.Errorf("Expected TANGLED_SHA='abc123def456', got '%s'", env["TANGLED_SHA"])
50
+
}
51
+
if env["TANGLED_COMMIT_SHA"] != "abc123def456" {
52
+
t.Errorf("Expected TANGLED_COMMIT_SHA='abc123def456', got '%s'", env["TANGLED_COMMIT_SHA"])
53
+
}
54
+
55
+
// Check repo variables
56
+
if env["TANGLED_REPO_KNOT"] != "example.com" {
57
+
t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"])
58
+
}
59
+
if env["TANGLED_REPO_DID"] != "did:plc:user123" {
60
+
t.Errorf("Expected TANGLED_REPO_DID='did:plc:user123', got '%s'", env["TANGLED_REPO_DID"])
61
+
}
62
+
if env["TANGLED_REPO_NAME"] != "my-repo" {
63
+
t.Errorf("Expected TANGLED_REPO_NAME='my-repo', got '%s'", env["TANGLED_REPO_NAME"])
64
+
}
65
+
if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" {
66
+
t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"])
67
+
}
68
+
if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" {
69
+
t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"])
70
+
}
71
+
}
72
+
73
+
func TestPipelineEnvVars_PushTag(t *testing.T) {
74
+
tr := &tangled.Pipeline_TriggerMetadata{
75
+
Kind: string(workflow.TriggerKindPush),
76
+
Push: &tangled.Pipeline_PushTriggerData{
77
+
NewSha: "abc123def456",
78
+
OldSha: "000000000000",
79
+
Ref: "refs/tags/v1.2.3",
80
+
},
81
+
Repo: &tangled.Pipeline_TriggerRepo{
82
+
Knot: "example.com",
83
+
Did: "did:plc:user123",
84
+
Repo: "my-repo",
85
+
},
86
+
}
87
+
id := PipelineId{
88
+
Knot: "example.com",
89
+
Rkey: "123123",
90
+
}
91
+
env := PipelineEnvVars(tr, id, false)
92
+
93
+
if env["TANGLED_REF"] != "refs/tags/v1.2.3" {
94
+
t.Errorf("Expected TANGLED_REF='refs/tags/v1.2.3', got '%s'", env["TANGLED_REF"])
95
+
}
96
+
if env["TANGLED_REF_NAME"] != "v1.2.3" {
97
+
t.Errorf("Expected TANGLED_REF_NAME='v1.2.3', got '%s'", env["TANGLED_REF_NAME"])
98
+
}
99
+
if env["TANGLED_REF_TYPE"] != "tag" {
100
+
t.Errorf("Expected TANGLED_REF_TYPE='tag', got '%s'", env["TANGLED_REF_TYPE"])
101
+
}
102
+
}
103
+
104
+
func TestPipelineEnvVars_PullRequest(t *testing.T) {
105
+
tr := &tangled.Pipeline_TriggerMetadata{
106
+
Kind: string(workflow.TriggerKindPullRequest),
107
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
108
+
SourceBranch: "feature-branch",
109
+
TargetBranch: "main",
110
+
SourceSha: "pr-sha-789",
111
+
Action: "opened",
112
+
},
113
+
Repo: &tangled.Pipeline_TriggerRepo{
114
+
Knot: "example.com",
115
+
Did: "did:plc:user123",
116
+
Repo: "my-repo",
117
+
},
118
+
}
119
+
id := PipelineId{
120
+
Knot: "example.com",
121
+
Rkey: "123123",
122
+
}
123
+
env := PipelineEnvVars(tr, id, false)
124
+
125
+
// Check ref variables for PR
126
+
if env["TANGLED_REF"] != "refs/heads/feature-branch" {
127
+
t.Errorf("Expected TANGLED_REF='refs/heads/feature-branch', got '%s'", env["TANGLED_REF"])
128
+
}
129
+
if env["TANGLED_REF_NAME"] != "feature-branch" {
130
+
t.Errorf("Expected TANGLED_REF_NAME='feature-branch', got '%s'", env["TANGLED_REF_NAME"])
131
+
}
132
+
if env["TANGLED_REF_TYPE"] != "branch" {
133
+
t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"])
134
+
}
135
+
136
+
// Check SHA variables
137
+
if env["TANGLED_SHA"] != "pr-sha-789" {
138
+
t.Errorf("Expected TANGLED_SHA='pr-sha-789', got '%s'", env["TANGLED_SHA"])
139
+
}
140
+
if env["TANGLED_COMMIT_SHA"] != "pr-sha-789" {
141
+
t.Errorf("Expected TANGLED_COMMIT_SHA='pr-sha-789', got '%s'", env["TANGLED_COMMIT_SHA"])
142
+
}
143
+
144
+
// Check PR-specific variables
145
+
if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" {
146
+
t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"])
147
+
}
148
+
if env["TANGLED_PR_TARGET_BRANCH"] != "main" {
149
+
t.Errorf("Expected TANGLED_PR_TARGET_BRANCH='main', got '%s'", env["TANGLED_PR_TARGET_BRANCH"])
150
+
}
151
+
if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" {
152
+
t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"])
153
+
}
154
+
if env["TANGLED_PR_ACTION"] != "opened" {
155
+
t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"])
156
+
}
157
+
}
158
+
159
+
func TestPipelineEnvVars_ManualWithInputs(t *testing.T) {
160
+
tr := &tangled.Pipeline_TriggerMetadata{
161
+
Kind: string(workflow.TriggerKindManual),
162
+
Manual: &tangled.Pipeline_ManualTriggerData{
163
+
Inputs: []*tangled.Pipeline_Pair{
164
+
{Key: "version", Value: "1.0.0"},
165
+
{Key: "environment", Value: "production"},
166
+
},
167
+
},
168
+
Repo: &tangled.Pipeline_TriggerRepo{
169
+
Knot: "example.com",
170
+
Did: "did:plc:user123",
171
+
Repo: "my-repo",
172
+
},
173
+
}
174
+
id := PipelineId{
175
+
Knot: "example.com",
176
+
Rkey: "123123",
177
+
}
178
+
env := PipelineEnvVars(tr, id, false)
179
+
180
+
// Check manual input variables
181
+
if env["TANGLED_INPUT_VERSION"] != "1.0.0" {
182
+
t.Errorf("Expected TANGLED_INPUT_VERSION='1.0.0', got '%s'", env["TANGLED_INPUT_VERSION"])
183
+
}
184
+
if env["TANGLED_INPUT_ENVIRONMENT"] != "production" {
185
+
t.Errorf("Expected TANGLED_INPUT_ENVIRONMENT='production', got '%s'", env["TANGLED_INPUT_ENVIRONMENT"])
186
+
}
187
+
188
+
// Manual triggers shouldn't have ref/sha variables
189
+
if _, ok := env["TANGLED_REF"]; ok {
190
+
t.Error("Manual trigger should not have TANGLED_REF")
191
+
}
192
+
if _, ok := env["TANGLED_SHA"]; ok {
193
+
t.Error("Manual trigger should not have TANGLED_SHA")
194
+
}
195
+
}
196
+
197
+
func TestPipelineEnvVars_DevMode(t *testing.T) {
198
+
tr := &tangled.Pipeline_TriggerMetadata{
199
+
Kind: string(workflow.TriggerKindPush),
200
+
Push: &tangled.Pipeline_PushTriggerData{
201
+
NewSha: "abc123",
202
+
Ref: "refs/heads/main",
203
+
},
204
+
Repo: &tangled.Pipeline_TriggerRepo{
205
+
Knot: "localhost:3000",
206
+
Did: "did:plc:user123",
207
+
Repo: "my-repo",
208
+
},
209
+
}
210
+
id := PipelineId{
211
+
Knot: "example.com",
212
+
Rkey: "123123",
213
+
}
214
+
env := PipelineEnvVars(tr, id, true)
215
+
216
+
// Dev mode should use http:// and replace localhost with host.docker.internal
217
+
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
218
+
if env["TANGLED_REPO_URL"] != expectedURL {
219
+
t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"])
220
+
}
221
+
}
222
+
223
+
func TestPipelineEnvVars_NilTrigger(t *testing.T) {
224
+
id := PipelineId{
225
+
Knot: "example.com",
226
+
Rkey: "123123",
227
+
}
228
+
env := PipelineEnvVars(nil, id, false)
229
+
230
+
if env != nil {
231
+
t.Error("Expected nil env for nil trigger")
232
+
}
233
+
}
234
+
235
+
func TestPipelineEnvVars_NilPushData(t *testing.T) {
236
+
tr := &tangled.Pipeline_TriggerMetadata{
237
+
Kind: string(workflow.TriggerKindPush),
238
+
Push: nil,
239
+
Repo: &tangled.Pipeline_TriggerRepo{
240
+
Knot: "example.com",
241
+
Did: "did:plc:user123",
242
+
Repo: "my-repo",
243
+
},
244
+
}
245
+
id := PipelineId{
246
+
Knot: "example.com",
247
+
Rkey: "123123",
248
+
}
249
+
env := PipelineEnvVars(tr, id, false)
250
+
251
+
// Should still have repo variables
252
+
if env["TANGLED_REPO_KNOT"] != "example.com" {
253
+
t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"])
254
+
}
255
+
256
+
// Should not have ref/sha variables
257
+
if _, ok := env["TANGLED_REF"]; ok {
258
+
t.Error("Should not have TANGLED_REF when push data is nil")
259
+
}
260
+
}
+51
spindle/models/secret_mask.go
+51
spindle/models/secret_mask.go
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"strings"
6
+
)
7
+
8
+
// SecretMask replaces secret values in strings with "***".
9
+
type SecretMask struct {
10
+
replacer *strings.Replacer
11
+
}
12
+
13
+
// NewSecretMask creates a mask for the given secret values.
14
+
// Also registers base64-encoded variants of each secret.
15
+
func NewSecretMask(values []string) *SecretMask {
16
+
var pairs []string
17
+
18
+
for _, value := range values {
19
+
if value == "" {
20
+
continue
21
+
}
22
+
23
+
pairs = append(pairs, value, "***")
24
+
25
+
b64 := base64.StdEncoding.EncodeToString([]byte(value))
26
+
if b64 != value {
27
+
pairs = append(pairs, b64, "***")
28
+
}
29
+
30
+
b64NoPad := strings.TrimRight(b64, "=")
31
+
if b64NoPad != b64 && b64NoPad != value {
32
+
pairs = append(pairs, b64NoPad, "***")
33
+
}
34
+
}
35
+
36
+
if len(pairs) == 0 {
37
+
return nil
38
+
}
39
+
40
+
return &SecretMask{
41
+
replacer: strings.NewReplacer(pairs...),
42
+
}
43
+
}
44
+
45
+
// Mask replaces all registered secret values with "***".
46
+
func (m *SecretMask) Mask(input string) string {
47
+
if m == nil || m.replacer == nil {
48
+
return input
49
+
}
50
+
return m.replacer.Replace(input)
51
+
}
+135
spindle/models/secret_mask_test.go
+135
spindle/models/secret_mask_test.go
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"testing"
6
+
)
7
+
8
+
func TestSecretMask_BasicMasking(t *testing.T) {
9
+
mask := NewSecretMask([]string{"mysecret123"})
10
+
11
+
input := "The password is mysecret123 in this log"
12
+
expected := "The password is *** in this log"
13
+
14
+
result := mask.Mask(input)
15
+
if result != expected {
16
+
t.Errorf("expected %q, got %q", expected, result)
17
+
}
18
+
}
19
+
20
+
func TestSecretMask_Base64Encoded(t *testing.T) {
21
+
secret := "mysecret123"
22
+
mask := NewSecretMask([]string{secret})
23
+
24
+
b64 := base64.StdEncoding.EncodeToString([]byte(secret))
25
+
input := "Encoded: " + b64
26
+
expected := "Encoded: ***"
27
+
28
+
result := mask.Mask(input)
29
+
if result != expected {
30
+
t.Errorf("expected %q, got %q", expected, result)
31
+
}
32
+
}
33
+
34
+
func TestSecretMask_Base64NoPadding(t *testing.T) {
35
+
// "test" encodes to "dGVzdA==" with padding
36
+
secret := "test"
37
+
mask := NewSecretMask([]string{secret})
38
+
39
+
b64NoPad := "dGVzdA" // base64 without padding
40
+
input := "Token: " + b64NoPad
41
+
expected := "Token: ***"
42
+
43
+
result := mask.Mask(input)
44
+
if result != expected {
45
+
t.Errorf("expected %q, got %q", expected, result)
46
+
}
47
+
}
48
+
49
+
func TestSecretMask_MultipleSecrets(t *testing.T) {
50
+
mask := NewSecretMask([]string{"password1", "apikey123"})
51
+
52
+
input := "Using password1 and apikey123 for auth"
53
+
expected := "Using *** and *** for auth"
54
+
55
+
result := mask.Mask(input)
56
+
if result != expected {
57
+
t.Errorf("expected %q, got %q", expected, result)
58
+
}
59
+
}
60
+
61
+
func TestSecretMask_MultipleOccurrences(t *testing.T) {
62
+
mask := NewSecretMask([]string{"secret"})
63
+
64
+
input := "secret appears twice: secret"
65
+
expected := "*** appears twice: ***"
66
+
67
+
result := mask.Mask(input)
68
+
if result != expected {
69
+
t.Errorf("expected %q, got %q", expected, result)
70
+
}
71
+
}
72
+
73
+
func TestSecretMask_ShortValues(t *testing.T) {
74
+
mask := NewSecretMask([]string{"abc", "xy", ""})
75
+
76
+
if mask == nil {
77
+
t.Fatal("expected non-nil mask")
78
+
}
79
+
80
+
input := "abc xy test"
81
+
expected := "*** *** test"
82
+
result := mask.Mask(input)
83
+
if result != expected {
84
+
t.Errorf("expected %q, got %q", expected, result)
85
+
}
86
+
}
87
+
88
+
func TestSecretMask_NilMask(t *testing.T) {
89
+
var mask *SecretMask
90
+
91
+
input := "some input text"
92
+
result := mask.Mask(input)
93
+
if result != input {
94
+
t.Errorf("expected %q, got %q", input, result)
95
+
}
96
+
}
97
+
98
+
func TestSecretMask_EmptyInput(t *testing.T) {
99
+
mask := NewSecretMask([]string{"secret"})
100
+
101
+
result := mask.Mask("")
102
+
if result != "" {
103
+
t.Errorf("expected empty string, got %q", result)
104
+
}
105
+
}
106
+
107
+
func TestSecretMask_NoMatch(t *testing.T) {
108
+
mask := NewSecretMask([]string{"secretvalue"})
109
+
110
+
input := "nothing to mask here"
111
+
result := mask.Mask(input)
112
+
if result != input {
113
+
t.Errorf("expected %q, got %q", input, result)
114
+
}
115
+
}
116
+
117
+
func TestSecretMask_EmptySecretsList(t *testing.T) {
118
+
mask := NewSecretMask([]string{})
119
+
120
+
if mask != nil {
121
+
t.Error("expected nil mask for empty secrets list")
122
+
}
123
+
}
124
+
125
+
func TestSecretMask_EmptySecretsFiltered(t *testing.T) {
126
+
mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"})
127
+
128
+
input := "Using validpassword here"
129
+
expected := "Using *** here"
130
+
131
+
result := mask.Mask(input)
132
+
if result != expected {
133
+
t.Errorf("expected %q, got %q", expected, result)
134
+
}
135
+
}
+1
-1
spindle/motd
+1
-1
spindle/motd
+15
-7
spindle/secrets/openbao.go
+15
-7
spindle/secrets/openbao.go
···
13
13
)
14
14
15
15
type OpenBaoManager struct {
16
-
client *vault.Client
17
-
mountPath string
18
-
logger *slog.Logger
16
+
client *vault.Client
17
+
mountPath string
18
+
logger *slog.Logger
19
+
connectionTimeout time.Duration
19
20
}
20
21
21
22
type OpenBaoManagerOpt func(*OpenBaoManager)
···
26
27
}
27
28
}
28
29
30
+
func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
31
+
return func(v *OpenBaoManager) {
32
+
v.connectionTimeout = timeout
33
+
}
34
+
}
35
+
29
36
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
37
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
38
// The proxy handles all authentication automatically via Auto-Auth
···
43
50
}
44
51
45
52
manager := &OpenBaoManager{
46
-
client: client,
47
-
mountPath: "spindle", // default KV v2 mount path
48
-
logger: logger,
53
+
client: client,
54
+
mountPath: "spindle", // default KV v2 mount path
55
+
logger: logger,
56
+
connectionTimeout: 10 * time.Second, // default connection timeout
49
57
}
50
58
51
59
for _, opt := range opts {
···
62
70
63
71
// testConnection verifies that we can connect to the proxy
64
72
func (v *OpenBaoManager) testConnection() error {
65
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
73
+
ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
66
74
defer cancel()
67
75
68
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
152
for _, tt := range tests {
153
153
t.Run(tt.name, func(t *testing.T) {
154
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
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...)
156
158
157
159
if tt.expectError {
158
160
assert.Error(t, err)
···
596
598
597
599
// All these will fail because no real proxy is running
598
600
// but we can test that the configuration is properly accepted
599
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
601
+
// Use shorter timeout for tests to avoid long waits
602
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second))
600
603
assert.Error(t, err) // Expected because no real proxy
601
604
assert.Nil(t, manager)
602
605
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+128
-54
spindle/server.go
+128
-54
spindle/server.go
···
6
6
"encoding/json"
7
7
"fmt"
8
8
"log/slog"
9
+
"maps"
9
10
"net/http"
11
+
"sync"
10
12
11
13
"github.com/go-chi/chi/v5"
12
14
"tangled.org/core/api/tangled"
···
29
31
)
30
32
31
33
//go:embed motd
32
-
var motd []byte
34
+
var defaultMotd []byte
33
35
34
36
const (
35
37
rbacDomain = "thisserver"
36
38
)
37
39
38
40
type Spindle struct {
39
-
jc *jetstream.JetstreamClient
40
-
db *db.DB
41
-
e *rbac.Enforcer
42
-
l *slog.Logger
43
-
n *notifier.Notifier
44
-
engs map[string]models.Engine
45
-
jq *queue.Queue
46
-
cfg *config.Config
47
-
ks *eventconsumer.Consumer
48
-
res *idresolver.Resolver
49
-
vault secrets.Manager
41
+
jc *jetstream.JetstreamClient
42
+
db *db.DB
43
+
e *rbac.Enforcer
44
+
l *slog.Logger
45
+
n *notifier.Notifier
46
+
engs map[string]models.Engine
47
+
jq *queue.Queue
48
+
cfg *config.Config
49
+
ks *eventconsumer.Consumer
50
+
res *idresolver.Resolver
51
+
vault secrets.Manager
52
+
motd []byte
53
+
motdMu sync.RWMutex
50
54
}
51
55
52
-
func Run(ctx context.Context) error {
56
+
// New creates a new Spindle server with the provided configuration and engines.
57
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
53
58
logger := log.FromContext(ctx)
54
59
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
-
60
60
d, err := db.Make(cfg.Server.DBPath)
61
61
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
62
+
return nil, fmt.Errorf("failed to setup db: %w", err)
63
63
}
64
64
65
65
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
66
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
67
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
68
}
69
69
e.E.EnableAutoSave(true)
70
70
···
74
74
switch cfg.Server.Secrets.Provider {
75
75
case "openbao":
76
76
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
77
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
78
}
79
79
vault, err = secrets.NewOpenBaoManager(
80
80
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
82
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
83
)
84
84
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
85
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
86
}
87
87
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
88
case "sqlite", "":
89
89
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
90
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
91
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
92
}
93
93
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
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
95
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
101
96
}
102
97
103
98
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
110
105
}
111
106
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
107
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
108
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
114
109
}
115
110
jc.AddDid(cfg.Server.Owner)
116
111
117
112
// Check if the spindle knows about any Dids;
118
113
dids, err := d.GetAllDids()
119
114
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
115
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
121
116
}
122
117
for _, d := range dids {
123
118
jc.AddDid(d)
124
119
}
125
120
126
-
resolver := idresolver.DefaultResolver()
121
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
127
122
128
-
spindle := Spindle{
123
+
spindle := &Spindle{
129
124
jc: jc,
130
125
e: e,
131
126
db: d,
132
127
l: logger,
133
128
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
129
+
engs: engines,
135
130
jq: jq,
136
131
cfg: cfg,
137
132
res: resolver,
138
133
vault: vault,
134
+
motd: defaultMotd,
139
135
}
140
136
141
137
err = e.AddSpindle(rbacDomain)
142
138
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
139
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
144
140
}
145
141
err = spindle.configureOwner()
146
142
if err != nil {
147
-
return err
143
+
return nil, err
148
144
}
149
145
logger.Info("owner set", "did", cfg.Server.Owner)
150
146
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
147
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
148
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
149
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
150
}
164
151
165
152
err = jc.StartJetstream(ctx, spindle.ingest())
166
153
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
154
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
168
155
}
169
156
170
157
// for each incoming sh.tangled.pipeline, we execute
···
177
164
ccfg.CursorStore = cursorStore
178
165
knownKnots, err := d.Knots()
179
166
if err != nil {
180
-
return err
167
+
return nil, err
181
168
}
182
169
for _, knot := range knownKnots {
183
170
logger.Info("adding source start", "knot", knot)
···
185
172
}
186
173
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
174
175
+
return spindle, nil
176
+
}
177
+
178
+
// DB returns the database instance.
179
+
func (s *Spindle) DB() *db.DB {
180
+
return s.db
181
+
}
182
+
183
+
// Queue returns the job queue instance.
184
+
func (s *Spindle) Queue() *queue.Queue {
185
+
return s.jq
186
+
}
187
+
188
+
// Engines returns the map of available engines.
189
+
func (s *Spindle) Engines() map[string]models.Engine {
190
+
return s.engs
191
+
}
192
+
193
+
// Vault returns the secrets manager instance.
194
+
func (s *Spindle) Vault() secrets.Manager {
195
+
return s.vault
196
+
}
197
+
198
+
// Notifier returns the notifier instance.
199
+
func (s *Spindle) Notifier() *notifier.Notifier {
200
+
return s.n
201
+
}
202
+
203
+
// Enforcer returns the RBAC enforcer instance.
204
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
205
+
return s.e
206
+
}
207
+
208
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
+
func (s *Spindle) SetMotdContent(content []byte) {
210
+
s.motdMu.Lock()
211
+
defer s.motdMu.Unlock()
212
+
s.motd = content
213
+
}
214
+
215
+
// GetMotdContent returns the current MOTD content.
216
+
func (s *Spindle) GetMotdContent() []byte {
217
+
s.motdMu.RLock()
218
+
defer s.motdMu.RUnlock()
219
+
return s.motd
220
+
}
221
+
222
+
// Start starts the Spindle server (blocking).
223
+
func (s *Spindle) Start(ctx context.Context) error {
224
+
// starts a job queue runner in the background
225
+
s.jq.Start()
226
+
defer s.jq.Stop()
227
+
228
+
// Stop vault token renewal if it implements Stopper
229
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
230
+
defer stopper.Stop()
231
+
}
232
+
188
233
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
234
+
s.l.Info("starting knot event consumer")
235
+
s.ks.Start(ctx)
191
236
}()
192
237
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
238
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
239
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
240
+
}
241
+
242
+
func Run(ctx context.Context) error {
243
+
cfg, err := config.Load(ctx)
244
+
if err != nil {
245
+
return fmt.Errorf("failed to load config: %w", err)
246
+
}
247
+
248
+
nixeryEng, err := nixery.New(ctx, cfg)
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
s, err := New(ctx, cfg, map[string]models.Engine{
254
+
"nixery": nixeryEng,
255
+
})
256
+
if err != nil {
257
+
return err
258
+
}
195
259
196
-
return nil
260
+
return s.Start(ctx)
197
261
}
198
262
199
263
func (s *Spindle) Router() http.Handler {
200
264
mux := chi.NewRouter()
201
265
202
266
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
203
-
w.Write(motd)
267
+
w.Write(s.GetMotdContent())
204
268
})
205
269
mux.HandleFunc("/events", s.Events)
206
270
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
266
330
267
331
workflows := make(map[models.Engine][]models.Workflow)
268
332
333
+
// Build pipeline environment variables once for all workflows
334
+
pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev)
335
+
269
336
for _, w := range tpl.Workflows {
270
337
if w != nil {
271
338
if _, ok := s.engs[w.Engine]; !ok {
···
290
357
if err != nil {
291
358
return err
292
359
}
360
+
361
+
// inject TANGLED_* env vars after InitWorkflow
362
+
// This prevents user-defined env vars from overriding them
363
+
if ewf.Environment == nil {
364
+
ewf.Environment = make(map[string]string)
365
+
}
366
+
maps.Copy(ewf.Environment, pipelineEnv)
293
367
294
368
workflows[eng] = append(workflows[eng], *ewf)
295
369
+5
spindle/stream.go
+5
spindle/stream.go
···
213
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
214
214
return fmt.Errorf("failed to write to websocket: %w", err)
215
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
+
}
216
221
}
217
222
}
218
223
}
+1
-1
tailwind.config.js
+1
-1
tailwind.config.js
···
2
2
const colors = require("tailwindcss/colors");
3
3
4
4
module.exports = {
5
-
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"],
5
+
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"],
6
6
darkMode: "media",
7
7
theme: {
8
8
container: {
+199
types/commit.go
+199
types/commit.go
···
1
+
package types
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/json"
6
+
"fmt"
7
+
"maps"
8
+
"regexp"
9
+
"strings"
10
+
11
+
"github.com/go-git/go-git/v5/plumbing"
12
+
"github.com/go-git/go-git/v5/plumbing/object"
13
+
)
14
+
15
+
type Commit struct {
16
+
// hash of the commit object.
17
+
Hash plumbing.Hash `json:"hash,omitempty"`
18
+
19
+
// author is the original author of the commit.
20
+
Author object.Signature `json:"author"`
21
+
22
+
// committer is the one performing the commit, might be different from author.
23
+
Committer object.Signature `json:"committer"`
24
+
25
+
// message is the commit message, contains arbitrary text.
26
+
Message string `json:"message"`
27
+
28
+
// treehash is the hash of the root tree of the commit.
29
+
Tree string `json:"tree"`
30
+
31
+
// parents are the hashes of the parent commits of the commit.
32
+
ParentHashes []plumbing.Hash `json:"parent_hashes,omitempty"`
33
+
34
+
// pgpsignature is the pgp signature of the commit.
35
+
PGPSignature string `json:"pgp_signature,omitempty"`
36
+
37
+
// mergetag is the embedded tag object when a merge commit is created by
38
+
// merging a signed tag.
39
+
MergeTag string `json:"merge_tag,omitempty"`
40
+
41
+
// changeid is a unique identifier for the change (e.g., gerrit change-id).
42
+
ChangeId string `json:"change_id,omitempty"`
43
+
44
+
// extraheaders contains additional headers not captured by other fields.
45
+
ExtraHeaders map[string][]byte `json:"extra_headers,omitempty"`
46
+
47
+
// deprecated: kept for backwards compatibility with old json format.
48
+
This string `json:"this,omitempty"`
49
+
50
+
// deprecated: kept for backwards compatibility with old json format.
51
+
Parent string `json:"parent,omitempty"`
52
+
}
53
+
54
+
// types.Commit is an unify two commit structs:
55
+
// - git.object.Commit from
56
+
// - types.NiceDiff.commit
57
+
//
58
+
// to do this in backwards compatible fashion, we define the base struct
59
+
// to use the same fields as NiceDiff.Commit, and then we also unmarshal
60
+
// the struct fields from go-git structs, this custom unmarshal makes sense
61
+
// of both representations and unifies them to have maximal data in either
62
+
// form.
63
+
func (c *Commit) UnmarshalJSON(data []byte) error {
64
+
type Alias Commit
65
+
66
+
aux := &struct {
67
+
*object.Commit
68
+
*Alias
69
+
}{
70
+
Alias: (*Alias)(c),
71
+
}
72
+
73
+
if err := json.Unmarshal(data, aux); err != nil {
74
+
return err
75
+
}
76
+
77
+
c.FromGoGitCommit(aux.Commit)
78
+
79
+
return nil
80
+
}
81
+
82
+
// fill in as much of Commit as possible from the given go-git commit
83
+
func (c *Commit) FromGoGitCommit(gc *object.Commit) {
84
+
if gc == nil {
85
+
return
86
+
}
87
+
88
+
if c.Hash.IsZero() {
89
+
c.Hash = gc.Hash
90
+
}
91
+
if c.This == "" {
92
+
c.This = gc.Hash.String()
93
+
}
94
+
if isEmptySignature(c.Author) {
95
+
c.Author = gc.Author
96
+
}
97
+
if isEmptySignature(c.Committer) {
98
+
c.Committer = gc.Committer
99
+
}
100
+
if c.Message == "" {
101
+
c.Message = gc.Message
102
+
}
103
+
if c.Tree == "" {
104
+
c.Tree = gc.TreeHash.String()
105
+
}
106
+
if c.PGPSignature == "" {
107
+
c.PGPSignature = gc.PGPSignature
108
+
}
109
+
if c.MergeTag == "" {
110
+
c.MergeTag = gc.MergeTag
111
+
}
112
+
113
+
if len(c.ParentHashes) == 0 {
114
+
c.ParentHashes = gc.ParentHashes
115
+
}
116
+
if c.Parent == "" && len(gc.ParentHashes) > 0 {
117
+
c.Parent = gc.ParentHashes[0].String()
118
+
}
119
+
120
+
if len(c.ExtraHeaders) == 0 {
121
+
c.ExtraHeaders = make(map[string][]byte)
122
+
maps.Copy(c.ExtraHeaders, gc.ExtraHeaders)
123
+
}
124
+
125
+
if c.ChangeId == "" {
126
+
if v, ok := gc.ExtraHeaders["change-id"]; ok {
127
+
c.ChangeId = string(v)
128
+
}
129
+
}
130
+
}
131
+
132
+
func isEmptySignature(s object.Signature) bool {
133
+
return s.Email == "" && s.Name == "" && s.When.IsZero()
134
+
}
135
+
136
+
// produce a verifiable payload from this commit's metadata
137
+
func (c *Commit) Payload() string {
138
+
author := bytes.NewBuffer([]byte{})
139
+
c.Author.Encode(author)
140
+
141
+
committer := bytes.NewBuffer([]byte{})
142
+
c.Committer.Encode(committer)
143
+
144
+
payload := strings.Builder{}
145
+
146
+
fmt.Fprintf(&payload, "tree %s\n", c.Tree)
147
+
148
+
if len(c.ParentHashes) > 0 {
149
+
for _, p := range c.ParentHashes {
150
+
fmt.Fprintf(&payload, "parent %s\n", p.String())
151
+
}
152
+
} else {
153
+
// present for backwards compatibility
154
+
fmt.Fprintf(&payload, "parent %s\n", c.Parent)
155
+
}
156
+
157
+
fmt.Fprintf(&payload, "author %s\n", author.String())
158
+
fmt.Fprintf(&payload, "committer %s\n", committer.String())
159
+
160
+
if c.ChangeId != "" {
161
+
fmt.Fprintf(&payload, "change-id %s\n", c.ChangeId)
162
+
} else if v, ok := c.ExtraHeaders["change-id"]; ok {
163
+
fmt.Fprintf(&payload, "change-id %s\n", string(v))
164
+
}
165
+
166
+
fmt.Fprintf(&payload, "\n%s", c.Message)
167
+
168
+
return payload.String()
169
+
}
170
+
171
+
var (
172
+
coAuthorRegex = regexp.MustCompile(`(?im)^Co-authored-by:\s*(.+?)\s*<([^>]+)>`)
173
+
)
174
+
175
+
func (commit Commit) CoAuthors() []object.Signature {
176
+
var coAuthors []object.Signature
177
+
seen := make(map[string]bool)
178
+
matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1)
179
+
180
+
for _, match := range matches {
181
+
if len(match) >= 3 {
182
+
name := strings.TrimSpace(match[1])
183
+
email := strings.TrimSpace(match[2])
184
+
185
+
if seen[email] {
186
+
continue
187
+
}
188
+
seen[email] = true
189
+
190
+
coAuthors = append(coAuthors, object.Signature{
191
+
Name: name,
192
+
Email: email,
193
+
When: commit.Committer.When,
194
+
})
195
+
}
196
+
}
197
+
198
+
return coAuthors
199
+
}
+5
-12
types/diff.go
+5
-12
types/diff.go
···
2
2
3
3
import (
4
4
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
-
"github.com/go-git/go-git/v5/plumbing/object"
6
5
)
7
6
8
7
type DiffOpts struct {
···
43
42
44
43
// A nicer git diff representation.
45
44
type NiceDiff struct {
46
-
Commit struct {
47
-
Message string `json:"message"`
48
-
Author object.Signature `json:"author"`
49
-
This string `json:"this"`
50
-
Parent string `json:"parent"`
51
-
PGPSignature string `json:"pgp_signature"`
52
-
Committer object.Signature `json:"committer"`
53
-
Tree string `json:"tree"`
54
-
ChangedId string `json:"change_id"`
55
-
} `json:"commit"`
56
-
Stat struct {
45
+
Commit Commit `json:"commit"`
46
+
Stat struct {
57
47
FilesChanged int `json:"files_changed"`
58
48
Insertions int `json:"insertions"`
59
49
Deletions int `json:"deletions"`
···
84
74
85
75
// used by html elements as a unique ID for hrefs
86
76
func (d *Diff) Id() string {
77
+
if d.IsDelete {
78
+
return d.Name.Old
79
+
}
87
80
return d.Name.New
88
81
}
89
82
+112
types/diff_test.go
+112
types/diff_test.go
···
1
+
package types
2
+
3
+
import "testing"
4
+
5
+
func TestDiffId(t *testing.T) {
6
+
tests := []struct {
7
+
name string
8
+
diff Diff
9
+
expected string
10
+
}{
11
+
{
12
+
name: "regular file uses new name",
13
+
diff: Diff{
14
+
Name: struct {
15
+
Old string `json:"old"`
16
+
New string `json:"new"`
17
+
}{Old: "", New: "src/main.go"},
18
+
},
19
+
expected: "src/main.go",
20
+
},
21
+
{
22
+
name: "new file uses new name",
23
+
diff: Diff{
24
+
Name: struct {
25
+
Old string `json:"old"`
26
+
New string `json:"new"`
27
+
}{Old: "", New: "src/new.go"},
28
+
IsNew: true,
29
+
},
30
+
expected: "src/new.go",
31
+
},
32
+
{
33
+
name: "deleted file uses old name",
34
+
diff: Diff{
35
+
Name: struct {
36
+
Old string `json:"old"`
37
+
New string `json:"new"`
38
+
}{Old: "src/deleted.go", New: ""},
39
+
IsDelete: true,
40
+
},
41
+
expected: "src/deleted.go",
42
+
},
43
+
{
44
+
name: "renamed file uses new name",
45
+
diff: Diff{
46
+
Name: struct {
47
+
Old string `json:"old"`
48
+
New string `json:"new"`
49
+
}{Old: "src/old.go", New: "src/renamed.go"},
50
+
IsRename: true,
51
+
},
52
+
expected: "src/renamed.go",
53
+
},
54
+
}
55
+
56
+
for _, tt := range tests {
57
+
t.Run(tt.name, func(t *testing.T) {
58
+
if got := tt.diff.Id(); got != tt.expected {
59
+
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
+
}
61
+
})
62
+
}
63
+
}
64
+
65
+
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
+
// ChangedFiles() must return values matching each Diff's Id()
67
+
// so that sidebar links point to the correct anchors.
68
+
// Tests existing, deleted, new, and renamed files.
69
+
nd := NiceDiff{
70
+
Diff: []Diff{
71
+
{
72
+
Name: struct {
73
+
Old string `json:"old"`
74
+
New string `json:"new"`
75
+
}{Old: "", New: "src/modified.go"},
76
+
},
77
+
{
78
+
Name: struct {
79
+
Old string `json:"old"`
80
+
New string `json:"new"`
81
+
}{Old: "src/deleted.go", New: ""},
82
+
IsDelete: true,
83
+
},
84
+
{
85
+
Name: struct {
86
+
Old string `json:"old"`
87
+
New string `json:"new"`
88
+
}{Old: "", New: "src/new.go"},
89
+
IsNew: true,
90
+
},
91
+
{
92
+
Name: struct {
93
+
Old string `json:"old"`
94
+
New string `json:"new"`
95
+
}{Old: "src/old.go", New: "src/renamed.go"},
96
+
IsRename: true,
97
+
},
98
+
},
99
+
}
100
+
101
+
changedFiles := nd.ChangedFiles()
102
+
103
+
if len(changedFiles) != len(nd.Diff) {
104
+
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
+
}
106
+
107
+
for i, diff := range nd.Diff {
108
+
if changedFiles[i] != diff.Id() {
109
+
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
+
}
111
+
}
112
+
}
+39
-18
types/repo.go
+39
-18
types/repo.go
···
1
1
package types
2
2
3
3
import (
4
+
"encoding/json"
5
+
4
6
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
7
"github.com/go-git/go-git/v5/plumbing/object"
6
8
)
7
9
8
10
type RepoIndexResponse struct {
9
-
IsEmpty bool `json:"is_empty"`
10
-
Ref string `json:"ref,omitempty"`
11
-
Readme string `json:"readme,omitempty"`
12
-
ReadmeFileName string `json:"readme_file_name,omitempty"`
13
-
Commits []*object.Commit `json:"commits,omitempty"`
14
-
Description string `json:"description,omitempty"`
15
-
Files []NiceTree `json:"files,omitempty"`
16
-
Branches []Branch `json:"branches,omitempty"`
17
-
Tags []*TagReference `json:"tags,omitempty"`
18
-
TotalCommits int `json:"total_commits,omitempty"`
11
+
IsEmpty bool `json:"is_empty"`
12
+
Ref string `json:"ref,omitempty"`
13
+
Readme string `json:"readme,omitempty"`
14
+
ReadmeFileName string `json:"readme_file_name,omitempty"`
15
+
Commits []Commit `json:"commits,omitempty"`
16
+
Description string `json:"description,omitempty"`
17
+
Files []NiceTree `json:"files,omitempty"`
18
+
Branches []Branch `json:"branches,omitempty"`
19
+
Tags []*TagReference `json:"tags,omitempty"`
20
+
TotalCommits int `json:"total_commits,omitempty"`
19
21
}
20
22
21
23
type RepoLogResponse struct {
22
-
Commits []*object.Commit `json:"commits,omitempty"`
23
-
Ref string `json:"ref,omitempty"`
24
-
Description string `json:"description,omitempty"`
25
-
Log bool `json:"log,omitempty"`
26
-
Total int `json:"total,omitempty"`
27
-
Page int `json:"page,omitempty"`
28
-
PerPage int `json:"per_page,omitempty"`
24
+
Commits []Commit `json:"commits,omitempty"`
25
+
Ref string `json:"ref,omitempty"`
26
+
Description string `json:"description,omitempty"`
27
+
Log bool `json:"log,omitempty"`
28
+
Total int `json:"total,omitempty"`
29
+
Page int `json:"page,omitempty"`
30
+
PerPage int `json:"per_page,omitempty"`
29
31
}
30
32
31
33
type RepoCommitResponse struct {
···
66
68
type Branch struct {
67
69
Reference `json:"reference"`
68
70
Commit *object.Commit `json:"commit,omitempty"`
69
-
IsDefault bool `json:"is_deafult,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
70
91
}
71
92
72
93
type RepoTagsResponse struct {
+88
-5
types/tree.go
+88
-5
types/tree.go
···
1
1
package types
2
2
3
3
import (
4
+
"fmt"
5
+
"os"
4
6
"time"
5
7
6
8
"github.com/go-git/go-git/v5/plumbing"
9
+
"github.com/go-git/go-git/v5/plumbing/filemode"
7
10
)
8
11
9
12
// A nicer git tree representation.
10
13
type NiceTree struct {
11
14
// 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"`
15
+
Name string `json:"name"`
16
+
Mode string `json:"mode"`
17
+
Size int64 `json:"size"`
17
18
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
19
102
}
20
103
21
104
type LastCommitInfo struct {
+9
-1
workflow/compile.go
+9
-1
workflow/compile.go
···
113
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
114
cw := &tangled.Pipeline_Workflow{}
115
115
116
-
if !w.Match(compiler.Trigger) {
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 {
117
125
compiler.Diagnostics.AddWarning(
118
126
w.Name,
119
127
WorkflowSkipped,
+125
workflow/compile_test.go
+125
workflow/compile_test.go
···
95
95
assert.Len(t, c.Diagnostics.Errors, 1)
96
96
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
97
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
8
9
9
"tangled.org/core/api/tangled"
10
10
11
+
"github.com/bmatcuk/doublestar/v4"
11
12
"github.com/go-git/go-git/v5/plumbing"
12
13
"gopkg.in/yaml.v3"
13
14
)
···
33
34
34
35
Constraint struct {
35
36
Event StringList `yaml:"event"`
36
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
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
37
39
}
38
40
39
41
CloneOpts struct {
···
59
61
return strings.ReplaceAll(string(t), "_", " ")
60
62
}
61
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
+
62
81
func FromFile(name string, contents []byte) (Workflow, error) {
63
82
var wf Workflow
64
83
···
74
93
}
75
94
76
95
// if any of the constraints on a workflow is true, return true
77
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
96
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
78
97
// manual triggers always run the workflow
79
98
if trigger.Manual != nil {
80
-
return true
99
+
return true, nil
81
100
}
82
101
83
102
// if not manual, run through the constraint list and see if any one matches
84
103
for _, c := range w.When {
85
-
if c.Match(trigger) {
86
-
return true
104
+
matched, err := c.Match(trigger)
105
+
if err != nil {
106
+
return false, err
107
+
}
108
+
if matched {
109
+
return true, nil
87
110
}
88
111
}
89
112
90
113
// no constraints, always run this workflow
91
114
if len(w.When) == 0 {
92
-
return true
115
+
return true, nil
93
116
}
94
117
95
-
return false
118
+
return false, nil
96
119
}
97
120
98
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
121
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
99
122
match := true
100
123
101
124
// manual triggers always pass this constraint
102
125
if trigger.Manual != nil {
103
-
return true
126
+
return true, nil
104
127
}
105
128
106
129
// apply event constraints
···
108
131
109
132
// apply branch constraints for PRs
110
133
if trigger.PullRequest != nil {
111
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
134
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
+
if err != nil {
136
+
return false, err
137
+
}
138
+
match = match && matched
112
139
}
113
140
114
141
// apply ref constraints for pushes
115
142
if trigger.Push != nil {
116
-
match = match && c.MatchRef(trigger.Push.Ref)
143
+
matched, err := c.MatchRef(trigger.Push.Ref)
144
+
if err != nil {
145
+
return false, err
146
+
}
147
+
match = match && matched
117
148
}
118
149
119
-
return match
120
-
}
121
-
122
-
func (c *Constraint) MatchBranch(branch string) bool {
123
-
return slices.Contains(c.Branch, branch)
150
+
return match, nil
124
151
}
125
152
126
-
func (c *Constraint) MatchRef(ref string) bool {
153
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
127
154
refName := plumbing.ReferenceName(ref)
155
+
shortName := refName.Short()
156
+
128
157
if refName.IsBranch() {
129
-
return slices.Contains(c.Branch, refName.Short())
158
+
return c.MatchBranch(shortName)
130
159
}
131
-
return false
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)
132
174
}
133
175
134
176
func (c *Constraint) MatchEvent(event string) bool {
+284
-1
workflow/def_test.go
+284
-1
workflow/def_test.go
···
6
6
"github.com/stretchr/testify/assert"
7
7
)
8
8
9
-
func TestUnmarshalWorkflow(t *testing.T) {
9
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
10
10
yamlData := `
11
11
when:
12
12
- event: ["push", "pull_request"]
···
38
38
39
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
40
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
+
}