+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
-1
api/tangled/actorprofile.go
+3
-1
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"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
33
}
+845
-10
api/tangled/cbor_gen.go
+845
-10
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
···
6981
7295
}
6982
7296
6983
7297
t.CreatedAt = string(sval)
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
+
}
6984
7338
}
6985
7339
6986
7340
default:
···
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--
···
7104
7466
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
7105
7467
return err
7106
7468
}
7469
+
}
7470
+
}
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
+
7107
7505
}
7108
7506
}
7109
7507
···
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
···
7238
7712
7239
7713
t.CreatedAt = string(sval)
7240
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
+
}
7754
+
}
7241
7755
7242
7756
default:
7243
7757
// Field doesn't exist on this type, so ignore it
···
7420
7934
}
7421
7935
7422
7936
cw := cbg.NewCborWriter(w)
7423
-
fieldCount := 7
7937
+
fieldCount := 9
7424
7938
7425
7939
if t.Body == nil {
7426
7940
fieldCount--
7427
7941
}
7428
7942
7943
+
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.References == nil {
7948
+
fieldCount--
7949
+
}
7950
+
7429
7951
if t.Source == nil {
7430
7952
fieldCount--
7431
7953
}
···
7566
8088
return err
7567
8089
}
7568
8090
8091
+
// t.Mentions ([]string) (slice)
8092
+
if t.Mentions != nil {
8093
+
8094
+
if len("mentions") > 1000000 {
8095
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
8096
+
}
8097
+
8098
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
8099
+
return err
8100
+
}
8101
+
if _, err := cw.WriteString(string("mentions")); err != nil {
8102
+
return err
8103
+
}
8104
+
8105
+
if len(t.Mentions) > 8192 {
8106
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
8107
+
}
8108
+
8109
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
8110
+
return err
8111
+
}
8112
+
for _, v := range t.Mentions {
8113
+
if len(v) > 1000000 {
8114
+
return xerrors.Errorf("Value in field v was too long")
8115
+
}
8116
+
8117
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8118
+
return err
8119
+
}
8120
+
if _, err := cw.WriteString(string(v)); err != nil {
8121
+
return err
8122
+
}
8123
+
8124
+
}
8125
+
}
8126
+
7569
8127
// t.CreatedAt (string) (string)
7570
8128
if len("createdAt") > 1000000 {
7571
8129
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7588
8146
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7589
8147
return err
7590
8148
}
8149
+
8150
+
// t.References ([]string) (slice)
8151
+
if t.References != nil {
8152
+
8153
+
if len("references") > 1000000 {
8154
+
return xerrors.Errorf("Value in field \"references\" was too long")
8155
+
}
8156
+
8157
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
8158
+
return err
8159
+
}
8160
+
if _, err := cw.WriteString(string("references")); err != nil {
8161
+
return err
8162
+
}
8163
+
8164
+
if len(t.References) > 8192 {
8165
+
return xerrors.Errorf("Slice value in field t.References was too long")
8166
+
}
8167
+
8168
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
8169
+
return err
8170
+
}
8171
+
for _, v := range t.References {
8172
+
if len(v) > 1000000 {
8173
+
return xerrors.Errorf("Value in field v was too long")
8174
+
}
8175
+
8176
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8177
+
return err
8178
+
}
8179
+
if _, err := cw.WriteString(string(v)); err != nil {
8180
+
return err
8181
+
}
8182
+
8183
+
}
8184
+
}
7591
8185
return nil
7592
8186
}
7593
8187
···
7616
8210
7617
8211
n := extra
7618
8212
7619
-
nameBuf := make([]byte, 9)
8213
+
nameBuf := make([]byte, 10)
7620
8214
for i := uint64(0); i < n; i++ {
7621
8215
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7622
8216
if err != nil {
···
7726
8320
}
7727
8321
7728
8322
}
8323
+
// t.Mentions ([]string) (slice)
8324
+
case "mentions":
8325
+
8326
+
maj, extra, err = cr.ReadHeader()
8327
+
if err != nil {
8328
+
return err
8329
+
}
8330
+
8331
+
if extra > 8192 {
8332
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
8333
+
}
8334
+
8335
+
if maj != cbg.MajArray {
8336
+
return fmt.Errorf("expected cbor array")
8337
+
}
8338
+
8339
+
if extra > 0 {
8340
+
t.Mentions = make([]string, extra)
8341
+
}
8342
+
8343
+
for i := 0; i < int(extra); i++ {
8344
+
{
8345
+
var maj byte
8346
+
var extra uint64
8347
+
var err error
8348
+
_ = maj
8349
+
_ = extra
8350
+
_ = err
8351
+
8352
+
{
8353
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8354
+
if err != nil {
8355
+
return err
8356
+
}
8357
+
8358
+
t.Mentions[i] = string(sval)
8359
+
}
8360
+
8361
+
}
8362
+
}
7729
8363
// t.CreatedAt (string) (string)
7730
8364
case "createdAt":
7731
8365
···
7737
8371
7738
8372
t.CreatedAt = string(sval)
7739
8373
}
8374
+
// t.References ([]string) (slice)
8375
+
case "references":
8376
+
8377
+
maj, extra, err = cr.ReadHeader()
8378
+
if err != nil {
8379
+
return err
8380
+
}
8381
+
8382
+
if extra > 8192 {
8383
+
return fmt.Errorf("t.References: array too large (%d)", extra)
8384
+
}
8385
+
8386
+
if maj != cbg.MajArray {
8387
+
return fmt.Errorf("expected cbor array")
8388
+
}
8389
+
8390
+
if extra > 0 {
8391
+
t.References = make([]string, extra)
8392
+
}
8393
+
8394
+
for i := 0; i < int(extra); i++ {
8395
+
{
8396
+
var maj byte
8397
+
var extra uint64
8398
+
var err error
8399
+
_ = maj
8400
+
_ = extra
8401
+
_ = err
8402
+
8403
+
{
8404
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8405
+
if err != nil {
8406
+
return err
8407
+
}
8408
+
8409
+
t.References[i] = string(sval)
8410
+
}
8411
+
8412
+
}
8413
+
}
7740
8414
7741
8415
default:
7742
8416
// Field doesn't exist on this type, so ignore it
···
7755
8429
}
7756
8430
7757
8431
cw := cbg.NewCborWriter(w)
8432
+
fieldCount := 6
7758
8433
7759
-
if _, err := cw.Write([]byte{164}); err != nil {
8434
+
if t.Mentions == nil {
8435
+
fieldCount--
8436
+
}
8437
+
8438
+
if t.References == nil {
8439
+
fieldCount--
8440
+
}
8441
+
8442
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
7760
8443
return err
7761
8444
}
7762
8445
···
7825
8508
return err
7826
8509
}
7827
8510
8511
+
// t.Mentions ([]string) (slice)
8512
+
if t.Mentions != nil {
8513
+
8514
+
if len("mentions") > 1000000 {
8515
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
8516
+
}
8517
+
8518
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
8519
+
return err
8520
+
}
8521
+
if _, err := cw.WriteString(string("mentions")); err != nil {
8522
+
return err
8523
+
}
8524
+
8525
+
if len(t.Mentions) > 8192 {
8526
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
8527
+
}
8528
+
8529
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
8530
+
return err
8531
+
}
8532
+
for _, v := range t.Mentions {
8533
+
if len(v) > 1000000 {
8534
+
return xerrors.Errorf("Value in field v was too long")
8535
+
}
8536
+
8537
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8538
+
return err
8539
+
}
8540
+
if _, err := cw.WriteString(string(v)); err != nil {
8541
+
return err
8542
+
}
8543
+
8544
+
}
8545
+
}
8546
+
7828
8547
// t.CreatedAt (string) (string)
7829
8548
if len("createdAt") > 1000000 {
7830
8549
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7847
8566
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7848
8567
return err
7849
8568
}
8569
+
8570
+
// t.References ([]string) (slice)
8571
+
if t.References != nil {
8572
+
8573
+
if len("references") > 1000000 {
8574
+
return xerrors.Errorf("Value in field \"references\" was too long")
8575
+
}
8576
+
8577
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
8578
+
return err
8579
+
}
8580
+
if _, err := cw.WriteString(string("references")); err != nil {
8581
+
return err
8582
+
}
8583
+
8584
+
if len(t.References) > 8192 {
8585
+
return xerrors.Errorf("Slice value in field t.References was too long")
8586
+
}
8587
+
8588
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
8589
+
return err
8590
+
}
8591
+
for _, v := range t.References {
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
+
}
7850
8605
return nil
7851
8606
}
7852
8607
···
7875
8630
7876
8631
n := extra
7877
8632
7878
-
nameBuf := make([]byte, 9)
8633
+
nameBuf := make([]byte, 10)
7879
8634
for i := uint64(0); i < n; i++ {
7880
8635
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7881
8636
if err != nil {
···
7924
8679
7925
8680
t.LexiconTypeID = string(sval)
7926
8681
}
8682
+
// t.Mentions ([]string) (slice)
8683
+
case "mentions":
8684
+
8685
+
maj, extra, err = cr.ReadHeader()
8686
+
if err != nil {
8687
+
return err
8688
+
}
8689
+
8690
+
if extra > 8192 {
8691
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
8692
+
}
8693
+
8694
+
if maj != cbg.MajArray {
8695
+
return fmt.Errorf("expected cbor array")
8696
+
}
8697
+
8698
+
if extra > 0 {
8699
+
t.Mentions = make([]string, extra)
8700
+
}
8701
+
8702
+
for i := 0; i < int(extra); i++ {
8703
+
{
8704
+
var maj byte
8705
+
var extra uint64
8706
+
var err error
8707
+
_ = maj
8708
+
_ = extra
8709
+
_ = err
8710
+
8711
+
{
8712
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8713
+
if err != nil {
8714
+
return err
8715
+
}
8716
+
8717
+
t.Mentions[i] = string(sval)
8718
+
}
8719
+
8720
+
}
8721
+
}
7927
8722
// t.CreatedAt (string) (string)
7928
8723
case "createdAt":
7929
8724
···
7934
8729
}
7935
8730
7936
8731
t.CreatedAt = string(sval)
8732
+
}
8733
+
// t.References ([]string) (slice)
8734
+
case "references":
8735
+
8736
+
maj, extra, err = cr.ReadHeader()
8737
+
if err != nil {
8738
+
return err
8739
+
}
8740
+
8741
+
if extra > 8192 {
8742
+
return fmt.Errorf("t.References: array too large (%d)", extra)
8743
+
}
8744
+
8745
+
if maj != cbg.MajArray {
8746
+
return fmt.Errorf("expected cbor array")
8747
+
}
8748
+
8749
+
if extra > 0 {
8750
+
t.References = make([]string, extra)
8751
+
}
8752
+
8753
+
for i := 0; i < int(extra); i++ {
8754
+
{
8755
+
var maj byte
8756
+
var extra uint64
8757
+
var err error
8758
+
_ = maj
8759
+
_ = extra
8760
+
_ = err
8761
+
8762
+
{
8763
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8764
+
if err != nil {
8765
+
return err
8766
+
}
8767
+
8768
+
t.References[i] = string(sval)
8769
+
}
8770
+
8771
+
}
7937
8772
}
7938
8773
7939
8774
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
}
+2
api/tangled/repopull.go
+2
api/tangled/repopull.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
23
24
Patch string `json:"patch" cborgen:"patch"`
25
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
24
26
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
25
27
Target *RepoPull_Target `json:"target" cborgen:"target"`
26
28
Title string `json:"title" cborgen:"title"`
-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
+85
-130
appview/db/db.go
+85
-130
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
-
return &DB{
1110
-
db,
1111
-
logger,
1112
-
}, nil
1113
-
}
1115
+
orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1116
+
_, err := tx.Exec(`
1117
+
alter table profile add column pronouns text;
1118
+
`)
1119
+
return err
1120
+
})
1114
1121
1115
-
type migrationFn = func(*sql.Tx) error
1116
-
1117
-
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1118
-
logger = logger.With("migration", name)
1119
-
1120
-
tx, err := c.BeginTx(context.Background(), nil)
1121
-
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
+
`)
1122
1127
return err
1123
-
}
1124
-
defer tx.Rollback()
1128
+
})
1125
1129
1126
-
var exists bool
1127
-
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
1128
-
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
+
`)
1129
1134
return err
1130
-
}
1135
+
})
1136
+
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,
1144
+
1145
+
subject_at text not null,
1131
1146
1132
-
if !exists {
1133
-
// run migration
1134
-
err = migrationFn(tx)
1135
-
if err != nil {
1136
-
logger.Error("failed to run migration", "err", err)
1137
-
return err
1138
-
}
1147
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1148
+
unique(did, rkey),
1149
+
unique(did, subject_at)
1150
+
);
1139
1151
1140
-
// mark migration as complete
1141
-
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1142
-
if err != nil {
1143
-
logger.Error("failed to mark migration as complete", "err", err)
1144
-
return err
1145
-
}
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;
1146
1166
1147
-
// commit the transaction
1148
-
if err := tx.Commit(); err != nil {
1149
-
return err
1150
-
}
1167
+
drop table stars;
1168
+
alter table stars_new rename to stars;
1151
1169
1152
-
logger.Info("migration applied successfully")
1153
-
} else {
1154
-
logger.Warn("skipped migration, already applied")
1155
-
}
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
+
})
1156
1175
1157
-
return nil
1176
+
return &DB{
1177
+
db,
1178
+
logger,
1179
+
}, nil
1158
1180
}
1159
1181
1160
1182
func (d *DB) Close() error {
1161
1183
return d.DB.Close()
1162
1184
}
1163
-
1164
-
type filter struct {
1165
-
key string
1166
-
arg any
1167
-
cmp string
1168
-
}
1169
-
1170
-
func newFilter(key, cmp string, arg any) filter {
1171
-
return filter{
1172
-
key: key,
1173
-
arg: arg,
1174
-
cmp: cmp,
1175
-
}
1176
-
}
1177
-
1178
-
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
1179
-
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
1180
-
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
1181
-
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
1182
-
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
1183
-
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
1184
-
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1185
-
func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) }
1186
-
func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) }
1187
-
func FilterContains(key string, arg any) filter {
1188
-
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
1189
-
}
1190
-
1191
-
func (f filter) Condition() string {
1192
-
rv := reflect.ValueOf(f.arg)
1193
-
kind := rv.Kind()
1194
-
1195
-
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
1196
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1197
-
if rv.Len() == 0 {
1198
-
// always false
1199
-
return "1 = 0"
1200
-
}
1201
-
1202
-
placeholders := make([]string, rv.Len())
1203
-
for i := range placeholders {
1204
-
placeholders[i] = "?"
1205
-
}
1206
-
1207
-
return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
1208
-
}
1209
-
1210
-
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
1211
-
}
1212
-
1213
-
func (f filter) Arg() []any {
1214
-
rv := reflect.ValueOf(f.arg)
1215
-
kind := rv.Kind()
1216
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1217
-
if rv.Len() == 0 {
1218
-
return nil
1219
-
}
1220
-
1221
-
out := make([]any, rv.Len())
1222
-
for i := range rv.Len() {
1223
-
out[i] = rv.Index(i).Interface()
1224
-
}
1225
-
return out
1226
-
}
1227
-
1228
-
return []any{f.arg}
1229
-
}
+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)
+37
-11
appview/db/profile.go
+37
-11
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
···
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)
···
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
}
···
129
130
did,
130
131
description,
131
132
include_bluesky,
132
-
location
133
+
location,
134
+
pronouns
133
135
)
134
-
values (?, ?, ?, ?)`,
136
+
values (?, ?, ?, ?, ?)`,
135
137
profile.Did,
136
138
profile.Description,
137
139
includeBskyValue,
138
140
profile.Location,
141
+
profile.Pronouns,
139
142
)
140
143
141
144
if err != nil {
···
197
200
return tx.Commit()
198
201
}
199
202
200
-
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
203
+
func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
201
204
var conditions []string
202
205
var args []any
203
206
for _, filter := range filters {
···
216
219
did,
217
220
description,
218
221
include_bluesky,
219
-
location
222
+
location,
223
+
pronouns
220
224
from
221
225
profile
222
226
%s`,
···
226
230
if err != nil {
227
231
return nil, err
228
232
}
233
+
defer rows.Close()
229
234
230
235
profileMap := make(map[string]*models.Profile)
231
236
for rows.Next() {
232
237
var profile models.Profile
233
238
var includeBluesky int
239
+
var pronouns sql.Null[string]
234
240
235
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
241
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
236
242
if err != nil {
237
243
return nil, err
238
244
}
···
241
247
profile.IncludeBluesky = true
242
248
}
243
249
250
+
if pronouns.Valid {
251
+
profile.Pronouns = pronouns.V
252
+
}
253
+
244
254
profileMap[profile.Did] = &profile
245
255
}
246
256
if err = rows.Err(); err != nil {
···
261
271
if err != nil {
262
272
return nil, err
263
273
}
274
+
defer rows.Close()
275
+
264
276
idxs := make(map[string]int)
265
277
for did := range profileMap {
266
278
idxs[did] = 0
···
281
293
if err != nil {
282
294
return nil, err
283
295
}
296
+
defer rows.Close()
297
+
284
298
idxs = make(map[string]int)
285
299
for did := range profileMap {
286
300
idxs[did] = 0
···
302
316
303
317
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
318
var profile models.Profile
319
+
var pronouns sql.Null[string]
320
+
305
321
profile.Did = did
306
322
307
323
includeBluesky := 0
324
+
308
325
err := e.QueryRow(
309
-
`select description, include_bluesky, location from profile where did = ?`,
326
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
310
327
did,
311
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
328
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
312
329
if err == sql.ErrNoRows {
313
330
profile := models.Profile{}
314
331
profile.Did = did
···
321
338
322
339
if includeBluesky != 0 {
323
340
profile.IncludeBluesky = true
341
+
}
342
+
343
+
if pronouns.Valid {
344
+
profile.Pronouns = pronouns.V
324
345
}
325
346
326
347
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
414
435
return fmt.Errorf("Entered location is too long.")
415
436
}
416
437
438
+
// ensure pronouns are not too long
439
+
if len(profile.Pronouns) > 40 {
440
+
return fmt.Errorf("Entered pronouns are too long.")
441
+
}
442
+
417
443
// ensure links are in order
418
444
err := validateLinks(profile)
419
445
if err != nil {
···
421
447
}
422
448
423
449
// ensure all pinned repos are either own repos or collaborating repos
424
-
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
450
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did))
425
451
if err != nil {
426
452
log.Printf("getting repos for %s: %s", profile.Did, err)
427
453
}
+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
+2
-1
appview/db/punchcard.go
+2
-1
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)
+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 {
+81
-49
appview/db/repos.go
+81
-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 {
···
182
167
if err != nil {
183
168
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
184
169
}
170
+
defer rows.Close()
171
+
185
172
for rows.Next() {
186
173
var repoat, lang string
187
174
if err := rows.Scan(&repoat, &lang); err != nil {
···
198
185
199
186
starCountQuery := fmt.Sprintf(
200
187
`select
201
-
repo_at, count(1)
188
+
subject_at, count(1)
202
189
from stars
203
-
where repo_at in (%s)
204
-
group by repo_at`,
190
+
where subject_at in (%s)
191
+
group by subject_at`,
205
192
inClause,
206
193
)
207
194
rows, err = e.Query(starCountQuery, args...)
208
195
if err != nil {
209
196
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
210
197
}
198
+
defer rows.Close()
199
+
211
200
for rows.Next() {
212
201
var repoat string
213
202
var count int
···
237
226
if err != nil {
238
227
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
239
228
}
229
+
defer rows.Close()
230
+
240
231
for rows.Next() {
241
232
var repoat string
242
233
var open, closed int
···
278
269
if err != nil {
279
270
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
280
271
}
272
+
defer rows.Close()
273
+
281
274
for rows.Next() {
282
275
var repoat string
283
276
var open, merged, closed, deleted int
···
312
305
}
313
306
314
307
// helper to get exactly one repo
315
-
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
308
+
func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) {
316
309
repos, err := GetRepos(e, 0, filters...)
317
310
if err != nil {
318
311
return nil, err
···
329
322
return &repos[0], nil
330
323
}
331
324
332
-
func CountRepos(e Execer, filters ...filter) (int64, error) {
325
+
func CountRepos(e Execer, filters ...orm.Filter) (int64, error) {
333
326
var conditions []string
334
327
var args []any
335
328
for _, filter := range filters {
···
356
349
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
350
var repo models.Repo
358
351
var nullableDescription sql.NullString
352
+
var nullableWebsite sql.NullString
353
+
var nullableTopicStr sql.NullString
359
354
360
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
355
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
361
356
362
357
var createdAt string
363
-
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
358
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
364
359
return nil, err
365
360
}
366
361
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
368
363
369
364
if nullableDescription.Valid {
370
365
repo.Description = nullableDescription.String
371
-
} else {
372
-
repo.Description = ""
366
+
}
367
+
if nullableWebsite.Valid {
368
+
repo.Website = nullableWebsite.String
369
+
}
370
+
if nullableTopicStr.Valid {
371
+
repo.Topics = strings.Fields(nullableTopicStr.String)
373
372
}
374
373
375
374
return &repo, nil
376
375
}
377
376
377
+
func PutRepo(tx *sql.Tx, repo models.Repo) error {
378
+
_, err := tx.Exec(
379
+
`update repos
380
+
set knot = ?, description = ?, website = ?, topics = ?
381
+
where did = ? and rkey = ?
382
+
`,
383
+
repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey,
384
+
)
385
+
return err
386
+
}
387
+
378
388
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
389
_, err := tx.Exec(
380
390
`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,
391
+
(did, name, knot, rkey, at_uri, description, website, topics, source)
392
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
393
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source,
384
394
)
385
395
if err != nil {
386
396
return fmt.Errorf("failed to insert repo: %w", err)
···
412
422
return nullableSource.String, nil
413
423
}
414
424
425
+
func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) {
426
+
source, err := GetRepoSource(e, repoAt)
427
+
if source == "" || errors.Is(err, sql.ErrNoRows) {
428
+
return nil, nil
429
+
}
430
+
if err != nil {
431
+
return nil, err
432
+
}
433
+
return GetRepoByAtUri(e, source)
434
+
}
435
+
415
436
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
416
437
var repos []models.Repo
417
438
418
439
rows, err := e.Query(
419
-
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
440
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
420
441
from repos r
421
442
left join collaborators c on r.at_uri = c.repo_at
422
443
where (r.did = ? or c.subject_did = ?)
···
434
455
var repo models.Repo
435
456
var createdAt string
436
457
var nullableDescription sql.NullString
458
+
var nullableWebsite sql.NullString
437
459
var nullableSource sql.NullString
438
460
439
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
461
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
440
462
if err != nil {
441
463
return nil, err
442
464
}
···
470
492
var repo models.Repo
471
493
var createdAt string
472
494
var nullableDescription sql.NullString
495
+
var nullableWebsite sql.NullString
496
+
var nullableTopicStr sql.NullString
473
497
var nullableSource sql.NullString
474
498
475
499
row := e.QueryRow(
476
-
`select id, did, name, knot, rkey, description, created, source
500
+
`select id, did, name, knot, rkey, description, website, topics, created, source
477
501
from repos
478
502
where did = ? and name = ? and source is not null and source != ''`,
479
503
did, name,
480
504
)
481
505
482
-
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
506
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
483
507
if err != nil {
484
508
return nil, err
485
509
}
···
488
512
repo.Description = nullableDescription.String
489
513
}
490
514
515
+
if nullableWebsite.Valid {
516
+
repo.Website = nullableWebsite.String
517
+
}
518
+
519
+
if nullableTopicStr.Valid {
520
+
repo.Topics = strings.Fields(nullableTopicStr.String)
521
+
}
522
+
491
523
if nullableSource.Valid {
492
524
repo.Source = nullableSource.String
493
525
}
···
521
553
return err
522
554
}
523
555
524
-
func UnsubscribeLabel(e Execer, filters ...filter) error {
556
+
func UnsubscribeLabel(e Execer, filters ...orm.Filter) error {
525
557
var conditions []string
526
558
var args []any
527
559
for _, filter := range filters {
···
539
571
return err
540
572
}
541
573
542
-
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
574
+
func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) {
543
575
var conditions []string
544
576
var args []any
545
577
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) {
+56
-32
appview/ingester.go
+56
-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 {
···
291
292
292
293
includeBluesky := record.Bluesky
293
294
295
+
pronouns := ""
296
+
if record.Pronouns != nil {
297
+
pronouns = *record.Pronouns
298
+
}
299
+
294
300
location := ""
295
301
if record.Location != nil {
296
302
location = *record.Location
···
325
331
Links: links,
326
332
Stats: stats,
327
333
PinnedRepos: pinned,
334
+
Pronouns: pronouns,
328
335
}
329
336
330
337
ddb, ok := i.Db.Execer.(*db.DB)
···
344
351
345
352
err = db.UpsertProfile(tx, &profile)
346
353
case jmodels.CommitOperationDelete:
347
-
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))
348
355
}
349
356
350
357
if err != nil {
···
418
425
// get record from db first
419
426
members, err := db.GetSpindleMembers(
420
427
ddb,
421
-
db.FilterEq("did", did),
422
-
db.FilterEq("rkey", rkey),
428
+
orm.FilterEq("did", did),
429
+
orm.FilterEq("rkey", rkey),
423
430
)
424
431
if err != nil || len(members) != 1 {
425
432
return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members))
···
434
441
// remove record by rkey && update enforcer
435
442
if err = db.RemoveSpindleMember(
436
443
tx,
437
-
db.FilterEq("did", did),
438
-
db.FilterEq("rkey", rkey),
444
+
orm.FilterEq("did", did),
445
+
orm.FilterEq("rkey", rkey),
439
446
); err != nil {
440
447
return fmt.Errorf("failed to remove from db: %w", err)
441
448
}
···
517
524
// get record from db first
518
525
spindles, err := db.GetSpindles(
519
526
ddb,
520
-
db.FilterEq("owner", did),
521
-
db.FilterEq("instance", instance),
527
+
orm.FilterEq("owner", did),
528
+
orm.FilterEq("instance", instance),
522
529
)
523
530
if err != nil || len(spindles) != 1 {
524
531
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
···
537
544
// remove spindle members first
538
545
err = db.RemoveSpindleMember(
539
546
tx,
540
-
db.FilterEq("owner", did),
541
-
db.FilterEq("instance", instance),
547
+
orm.FilterEq("owner", did),
548
+
orm.FilterEq("instance", instance),
542
549
)
543
550
if err != nil {
544
551
return err
···
546
553
547
554
err = db.DeleteSpindle(
548
555
tx,
549
-
db.FilterEq("owner", did),
550
-
db.FilterEq("instance", instance),
556
+
orm.FilterEq("owner", did),
557
+
orm.FilterEq("instance", instance),
551
558
)
552
559
if err != nil {
553
560
return err
···
615
622
case jmodels.CommitOperationDelete:
616
623
if err := db.DeleteString(
617
624
ddb,
618
-
db.FilterEq("did", did),
619
-
db.FilterEq("rkey", rkey),
625
+
orm.FilterEq("did", did),
626
+
orm.FilterEq("rkey", rkey),
620
627
); err != nil {
621
628
l.Error("failed to delete", "err", err)
622
629
return fmt.Errorf("failed to delete string record: %w", err)
···
734
741
// get record from db first
735
742
registrations, err := db.GetRegistrations(
736
743
ddb,
737
-
db.FilterEq("domain", domain),
738
-
db.FilterEq("did", did),
744
+
orm.FilterEq("domain", domain),
745
+
orm.FilterEq("did", did),
739
746
)
740
747
if err != nil {
741
748
return fmt.Errorf("failed to get registration: %w", err)
···
756
763
757
764
err = db.DeleteKnot(
758
765
tx,
759
-
db.FilterEq("did", did),
760
-
db.FilterEq("domain", domain),
766
+
orm.FilterEq("did", did),
767
+
orm.FilterEq("domain", domain),
761
768
)
762
769
if err != nil {
763
770
return err
···
835
842
return nil
836
843
837
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
+
838
852
if err := db.DeleteIssues(
839
-
ddb,
840
-
db.FilterEq("did", did),
841
-
db.FilterEq("rkey", rkey),
853
+
tx,
854
+
did,
855
+
rkey,
842
856
); err != nil {
843
857
l.Error("failed to delete", "err", err)
844
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
845
863
}
846
864
847
865
return nil
···
882
900
return fmt.Errorf("failed to validate comment: %w", err)
883
901
}
884
902
885
-
_, 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)
886
910
if err != nil {
887
911
return fmt.Errorf("failed to create issue comment: %w", err)
888
912
}
889
913
890
-
return nil
914
+
return tx.Commit()
891
915
892
916
case jmodels.CommitOperationDelete:
893
917
if err := db.DeleteIssueComments(
894
918
ddb,
895
-
db.FilterEq("did", did),
896
-
db.FilterEq("rkey", rkey),
919
+
orm.FilterEq("did", did),
920
+
orm.FilterEq("rkey", rkey),
897
921
); err != nil {
898
922
return fmt.Errorf("failed to delete issue comment record: %w", err)
899
923
}
···
946
970
case jmodels.CommitOperationDelete:
947
971
if err := db.DeleteLabelDefinition(
948
972
ddb,
949
-
db.FilterEq("did", did),
950
-
db.FilterEq("rkey", rkey),
973
+
orm.FilterEq("did", did),
974
+
orm.FilterEq("rkey", rkey),
951
975
); err != nil {
952
976
return fmt.Errorf("failed to delete labeldef record: %w", err)
953
977
}
···
987
1011
var repo *models.Repo
988
1012
switch collection {
989
1013
case tangled.RepoIssueNSID:
990
-
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
1014
+
i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject))
991
1015
if err != nil || len(i) != 1 {
992
1016
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993
1017
}
···
996
1020
return fmt.Errorf("unsupport label subject: %s", collection)
997
1021
}
998
1022
999
-
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1023
+
actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels))
1000
1024
if err != nil {
1001
1025
return fmt.Errorf("failed to build label application ctx: %w", err)
1002
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
}
+8
-8
appview/issues/opengraph.go
+8
-8
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.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
197
if err != nil {
198
198
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
199
}
···
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
-19
appview/knots/knots.go
+46
-19
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.")
+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
+
}
+9
-6
appview/middleware/middleware.go
+9
-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)
···
325
328
return
326
329
}
327
330
328
-
fullName := f.OwnerHandle() + "/" + f.Name
331
+
fullName := reporesolver.GetBaseRepoPath(r, f)
329
332
330
333
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
331
334
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,
+4
-1
appview/models/profile.go
+4
-1
appview/models/profile.go
···
19
19
Links [5]string
20
20
Stats [2]VanityStat
21
21
PinnedRepos [6]syntax.ATURI
22
+
Pronouns string
22
23
}
23
24
24
25
func (p Profile) IsLinksEmpty() bool {
···
110
111
}
111
112
112
113
type ByMonth struct {
114
+
Commits int
113
115
RepoEvents []RepoEvent
114
116
IssueEvents IssueEvents
115
117
PullEvents PullEvents
···
118
120
func (b ByMonth) IsEmpty() bool {
119
121
return len(b.RepoEvents) == 0 &&
120
122
len(b.IssueEvents.Items) == 0 &&
121
-
len(b.PullEvents.Items) == 0
123
+
len(b.PullEvents.Items) == 0 &&
124
+
b.Commits == 0
122
125
}
123
126
124
127
type IssueEvents struct {
+42
-4
appview/models/pull.go
+42
-4
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
···
92
94
source.Repo = &s
93
95
}
94
96
}
97
+
mentions := make([]string, len(p.Mentions))
98
+
for i, did := range p.Mentions {
99
+
mentions[i] = string(did)
100
+
}
101
+
references := make([]string, len(p.References))
102
+
for i, uri := range p.References {
103
+
references[i] = string(uri)
104
+
}
95
105
96
106
record := tangled.RepoPull{
97
-
Title: p.Title,
98
-
Body: &p.Body,
99
-
CreatedAt: p.Created.Format(time.RFC3339),
107
+
Title: p.Title,
108
+
Body: &p.Body,
109
+
Mentions: mentions,
110
+
References: references,
111
+
CreatedAt: p.Created.Format(time.RFC3339),
100
112
Target: &tangled.RepoPull_Target{
101
113
Repo: p.RepoAt.String(),
102
114
Branch: p.TargetBranch,
···
148
160
Body string
149
161
150
162
// meta
163
+
Mentions []syntax.DID
164
+
References []syntax.ATURI
165
+
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
}
+59
-10
appview/ogcard/card.go
+59
-10
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)
308
303
}
309
304
305
+
return icon, nil
306
+
}
307
+
308
+
func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) {
309
+
svgData, err := pages.Files.ReadFile(svgPath)
310
+
if err != nil {
311
+
return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
312
+
}
313
+
314
+
icon, err := BuildSVGIconFromData(svgData, iconColor)
315
+
if err != nil {
316
+
return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err)
317
+
}
318
+
319
+
return icon, nil
320
+
}
321
+
322
+
func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) {
323
+
return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
324
+
}
325
+
326
+
func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error {
327
+
icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
328
+
if err != nil {
329
+
return err
330
+
}
331
+
332
+
c.DrawSVGIcon(icon, x, y, size)
333
+
334
+
return nil
335
+
}
336
+
337
+
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
+
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
+
if err != nil {
341
+
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
+
}
343
+
344
+
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
+
}
348
+
349
+
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
350
+
if err != nil {
351
+
return err
352
+
}
353
+
354
+
c.DrawSVGIcon(icon, x, y, size)
355
+
356
+
return nil
357
+
}
358
+
359
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
360
+
func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) {
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
+99
-11
appview/pages/funcmap.go
+99
-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"
22
28
"tangled.org/core/appview/filetree"
29
+
"tangled.org/core/appview/models"
23
30
"tangled.org/core/appview/pages/markup"
24
31
"tangled.org/core/crypto"
25
32
)
···
38
45
"contains": func(s string, target string) bool {
39
46
return strings.Contains(s, target)
40
47
},
48
+
"stripPort": func(hostname string) string {
49
+
if strings.Contains(hostname, ":") {
50
+
return strings.Split(hostname, ":")[0]
51
+
}
52
+
return hostname
53
+
},
41
54
"mapContains": func(m any, key any) bool {
42
55
mapValue := reflect.ValueOf(m)
43
56
if mapValue.Kind() != reflect.Map {
···
57
70
return "handle.invalid"
58
71
}
59
72
60
-
return "@" + identity.Handle.String()
73
+
return identity.Handle.String()
74
+
},
75
+
"ownerSlashRepo": func(repo *models.Repo) string {
76
+
ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did)
77
+
if err != nil {
78
+
return repo.DidSlashRepo()
79
+
}
80
+
handle := ownerId.Handle
81
+
if handle != "" && !handle.IsInvalidHandle() {
82
+
return string(handle) + "/" + repo.Name
83
+
}
84
+
return repo.DidSlashRepo()
61
85
},
62
86
"truncateAt30": func(s string) string {
63
87
if len(s) <= 30 {
···
68
92
"splitOn": func(s, sep string) []string {
69
93
return strings.Split(s, sep)
70
94
},
95
+
"string": func(v any) string {
96
+
return fmt.Sprint(v)
97
+
},
71
98
"int64": func(a int) int64 {
72
99
return int64(a)
73
100
},
···
83
110
},
84
111
"sub": func(a, b int) int {
85
112
return a - b
113
+
},
114
+
"mul": func(a, b int) int {
115
+
return a * b
116
+
},
117
+
"div": func(a, b int) int {
118
+
return a / b
119
+
},
120
+
"mod": func(a, b int) int {
121
+
return a % b
86
122
},
87
123
"f64": func(a int) float64 {
88
124
return float64(a)
···
116
152
117
153
return b
118
154
},
119
-
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
122
-
} else {
123
-
return did
124
-
}
125
-
},
126
155
"assoc": func(values ...string) ([][]string, error) {
127
156
if len(values)%2 != 0 {
128
157
return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
···
133
162
}
134
163
return pairs, nil
135
164
},
136
-
"append": func(s []string, values ...string) []string {
165
+
"append": func(s []any, values ...any) []any {
137
166
s = append(s, values...)
138
167
return s
139
168
},
···
232
261
},
233
262
"description": func(text string) template.HTML {
234
263
p.rctx.RendererType = markup.RendererTypeDefault
235
-
htmlString := p.rctx.RenderMarkdown(text)
264
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
236
265
sanitized := p.rctx.SanitizeDescription(htmlString)
237
266
return template.HTML(sanitized)
238
267
},
268
+
"readme": func(text string) template.HTML {
269
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
270
+
htmlString := p.rctx.RenderMarkdown(text)
271
+
sanitized := p.rctx.SanitizeDefault(htmlString)
272
+
return template.HTML(sanitized)
273
+
},
274
+
"code": func(content, path string) string {
275
+
var style *chroma.Style = styles.Get("catpuccin-latte")
276
+
formatter := chromahtml.New(
277
+
chromahtml.InlineCode(false),
278
+
chromahtml.WithLineNumbers(true),
279
+
chromahtml.WithLinkableLineNumbers(true, "L"),
280
+
chromahtml.Standalone(false),
281
+
chromahtml.WithClasses(true),
282
+
)
283
+
284
+
lexer := lexers.Get(filepath.Base(path))
285
+
if lexer == nil {
286
+
lexer = lexers.Fallback
287
+
}
288
+
289
+
iterator, err := lexer.Tokenise(nil, content)
290
+
if err != nil {
291
+
p.logger.Error("chroma tokenize", "err", "err")
292
+
return ""
293
+
}
294
+
295
+
var code bytes.Buffer
296
+
err = formatter.Format(&code, style, iterator)
297
+
if err != nil {
298
+
p.logger.Error("chroma format", "err", "err")
299
+
return ""
300
+
}
301
+
302
+
return code.String()
303
+
},
304
+
"trimUriScheme": func(text string) string {
305
+
text = strings.TrimPrefix(text, "https://")
306
+
text = strings.TrimPrefix(text, "http://")
307
+
return text
308
+
},
239
309
"isNil": func(t any) bool {
240
310
// returns false for other "zero" values
241
311
return t == nil
···
281
351
u, _ := url.PathUnescape(s)
282
352
return u
283
353
},
284
-
354
+
"safeUrl": func(s string) template.URL {
355
+
return template.URL(s)
356
+
},
285
357
"tinyAvatar": func(handle string) string {
286
358
return p.AvatarUrl(handle, "tiny")
287
359
},
···
311
383
}
312
384
}
313
385
386
+
func (p *Pages) resolveDid(did string) string {
387
+
identity, err := p.resolver.ResolveIdent(context.Background(), did)
388
+
389
+
if err != nil {
390
+
return did
391
+
}
392
+
393
+
if identity.Handle.IsInvalidHandle() {
394
+
return "handle.invalid"
395
+
}
396
+
397
+
return identity.Handle.String()
398
+
}
399
+
314
400
func (p *Pages) AvatarUrl(handle, size string) string {
315
401
handle = strings.TrimPrefix(handle, "@")
402
+
403
+
handle = p.resolveDid(handle)
316
404
317
405
secret := p.avatar.SharedSecret
318
406
h := hmac.New(sha256.New, []byte(secret))
+125
appview/pages/markup/extension/atlink.go
+125
appview/pages/markup/extension/atlink.go
···
1
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
2
+
3
+
package extension
4
+
5
+
import (
6
+
"regexp"
7
+
8
+
"github.com/yuin/goldmark"
9
+
"github.com/yuin/goldmark/ast"
10
+
"github.com/yuin/goldmark/parser"
11
+
"github.com/yuin/goldmark/renderer"
12
+
"github.com/yuin/goldmark/renderer/html"
13
+
"github.com/yuin/goldmark/text"
14
+
"github.com/yuin/goldmark/util"
15
+
)
16
+
17
+
// An AtNode struct represents an AtNode
18
+
type AtNode struct {
19
+
Handle string
20
+
ast.BaseInline
21
+
}
22
+
23
+
var _ ast.Node = &AtNode{}
24
+
25
+
// Dump implements Node.Dump.
26
+
func (n *AtNode) Dump(source []byte, level int) {
27
+
ast.DumpHelper(n, source, level, nil, nil)
28
+
}
29
+
30
+
// KindAt is a NodeKind of the At node.
31
+
var KindAt = ast.NewNodeKind("At")
32
+
33
+
// Kind implements Node.Kind.
34
+
func (n *AtNode) Kind() ast.NodeKind {
35
+
return KindAt
36
+
}
37
+
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
+
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
+
if !util.IsSpaceRune(block.PrecendingCharacter()) {
61
+
return nil
62
+
}
63
+
64
+
// Check for all links in the markdown to see if the handle found is inside one
65
+
linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1)
66
+
for _, linkMatch := range linksIndexes {
67
+
if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] {
68
+
return nil
69
+
}
70
+
}
71
+
72
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
73
+
block.Advance(m[1])
74
+
node := &AtNode{}
75
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
76
+
node.Handle = string(atSegment.Value(block.Source())[1:])
77
+
return node
78
+
}
79
+
80
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
81
+
// renders At nodes.
82
+
type atHtmlRenderer struct {
83
+
html.Config
84
+
}
85
+
86
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
87
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
88
+
r := &atHtmlRenderer{
89
+
Config: html.NewConfig(),
90
+
}
91
+
for _, opt := range opts {
92
+
opt.SetHTMLOption(&r.Config)
93
+
}
94
+
return r
95
+
}
96
+
97
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
98
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
99
+
reg.Register(KindAt, r.renderAt)
100
+
}
101
+
102
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
103
+
if entering {
104
+
w.WriteString(`<a href="/@`)
105
+
w.WriteString(n.(*AtNode).Handle)
106
+
w.WriteString(`" class="mention font-bold">`)
107
+
} else {
108
+
w.WriteString("</a>")
109
+
}
110
+
return ast.WalkContinue, nil
111
+
}
112
+
113
+
type atExt struct{}
114
+
115
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
116
+
var AtExt = &atExt{}
117
+
118
+
func (e *atExt) Extend(m goldmark.Markdown) {
119
+
m.Parser().AddOptions(parser.WithInlineParsers(
120
+
util.Prioritized(NewAtParser(), 500),
121
+
))
122
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
123
+
util.Prioritized(NewAtHTMLRenderer(), 500),
124
+
))
125
+
}
+11
-4
appview/pages/markup/markdown.go
+11
-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"
17
16
highlighting "github.com/yuin/goldmark-highlighting/v2"
18
17
"github.com/yuin/goldmark/ast"
···
25
24
htmlparse "golang.org/x/net/html"
26
25
27
26
"tangled.org/core/api/tangled"
27
+
textension "tangled.org/core/appview/pages/markup/extension"
28
28
"tangled.org/core/appview/pages/repoinfo"
29
29
)
30
30
···
50
50
Files fs.FS
51
51
}
52
52
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
53
+
func NewMarkdown() goldmark.Markdown {
54
54
md := goldmark.New(
55
55
goldmark.WithExtensions(
56
56
extension.GFM,
···
64
64
extension.NewFootnote(
65
65
extension.WithFootnoteIDPrefix([]byte("footnote")),
66
66
),
67
-
treeblood.MathML(),
68
67
callout.CalloutExtention,
68
+
textension.AtExt,
69
69
),
70
70
goldmark.WithParserOptions(
71
71
parser.WithAutoHeadingID(),
72
72
),
73
73
goldmark.WithRendererOptions(html.WithUnsafe()),
74
74
)
75
+
return md
76
+
}
75
77
78
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
79
+
return rctx.RenderMarkdownWith(source, NewMarkdown())
80
+
}
81
+
82
+
func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
76
83
if rctx != nil {
77
84
var transformers []util.PrioritizedValue
78
85
···
240
247
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
241
248
242
249
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
243
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
250
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
244
251
245
252
parsedURL := &url.URL{
246
253
Scheme: scheme,
+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
+51
-128
appview/pages/pages.go
+51
-128
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
···
411
406
type KnotsParams struct {
412
407
LoggedInUser *oauth.User
413
408
Registrations []models.Registration
409
+
Tabs []map[string]any
410
+
Tab string
414
411
}
415
412
416
413
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
423
420
Members []string
424
421
Repos map[string][]models.Repo
425
422
IsOwner bool
423
+
Tabs []map[string]any
424
+
Tab string
426
425
}
427
426
428
427
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
···
440
439
type SpindlesParams struct {
441
440
LoggedInUser *oauth.User
442
441
Spindles []models.Spindle
442
+
Tabs []map[string]any
443
+
Tab string
443
444
}
444
445
445
446
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
448
449
449
450
type SpindleListingParams struct {
450
451
models.Spindle
452
+
Tabs []map[string]any
453
+
Tab string
451
454
}
452
455
453
456
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
459
462
Spindle models.Spindle
460
463
Members []string
461
464
Repos map[string][]models.Repo
465
+
Tabs []map[string]any
466
+
Tab string
462
467
}
463
468
464
469
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
486
491
487
492
type ProfileCard struct {
488
493
UserDid string
489
-
UserHandle string
490
494
FollowStatus models.FollowStatus
491
495
Punchcard *models.Punchcard
492
496
Profile *models.Profile
···
629
633
return p.executePlain("user/fragments/editPins", w, params)
630
634
}
631
635
632
-
type RepoStarFragmentParams struct {
636
+
type StarBtnFragmentParams struct {
633
637
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)
638
+
SubjectAt syntax.ATURI
639
+
StarCount int
640
640
}
641
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)
648
-
}
649
-
650
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
651
-
return p.executePlain("repo/fragments/repoDescription", w, params)
642
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
643
+
return p.executePlain("fragments/starBtn-oob", w, params)
652
644
}
653
645
654
646
type RepoIndexParams struct {
···
656
648
RepoInfo repoinfo.RepoInfo
657
649
Active string
658
650
TagMap map[string][]string
659
-
CommitsTrunc []*object.Commit
651
+
CommitsTrunc []types.Commit
660
652
TagsTrunc []*types.TagReference
661
653
BranchesTrunc []types.Branch
662
654
// ForkInfo *types.ForkInfo
···
755
747
func (r RepoTreeParams) TreeStats() RepoTreeStats {
756
748
numFolders, numFiles := 0, 0
757
749
for _, f := range r.Files {
758
-
if !f.IsFile {
750
+
if !f.IsFile() {
759
751
numFolders += 1
760
-
} else if f.IsFile {
752
+
} else if f.IsFile() {
761
753
numFiles += 1
762
754
}
763
755
}
···
828
820
}
829
821
830
822
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
823
+
LoggedInUser *oauth.User
824
+
RepoInfo repoinfo.RepoInfo
825
+
Active string
826
+
BreadCrumbs [][]string
827
+
BlobView models.BlobView
842
828
*tangled.RepoBlob_Output
843
-
// Computed fields for template compatibility
844
-
Contents string
845
-
Lines int
846
-
SizeHint uint64
847
-
IsBinary bool
848
829
}
849
830
850
831
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
-
}
832
+
switch params.BlobView.ContentType {
833
+
case models.BlobContentTypeMarkup:
834
+
p.rctx.RepoInfo = params.RepoInfo
862
835
}
863
836
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)
887
-
}
888
-
889
-
params.Contents = code.String()
890
837
params.Active = "overview"
891
838
return p.executeRepo("repo/blob", w, params)
892
839
}
893
840
894
841
type Collaborator struct {
895
-
Did string
896
-
Handle string
897
-
Role string
842
+
Did string
843
+
Role string
898
844
}
899
845
900
846
type RepoSettingsParams struct {
···
969
915
RepoInfo repoinfo.RepoInfo
970
916
Active string
971
917
Issues []models.Issue
918
+
IssueCount int
972
919
LabelDefs map[string]*models.LabelDefinition
973
920
Page pagination.Page
974
921
FilteringByOpen bool
···
986
933
Active string
987
934
Issue *models.Issue
988
935
CommentList []models.CommentListItem
936
+
Backlinks []models.RichReferenceLink
989
937
LabelDefs map[string]*models.LabelDefinition
990
938
991
939
OrderedReactionKinds []models.ReactionKind
···
1139
1087
Pull *models.Pull
1140
1088
Stack models.Stack
1141
1089
AbandonedPulls []*models.Pull
1090
+
Backlinks []models.RichReferenceLink
1142
1091
BranchDeleteStatus *models.BranchDeleteStatus
1143
1092
MergeCheck types.MergeCheckResponse
1144
1093
ResubmitCheck ResubmitResult
···
1310
1259
return p.executePlain("repo/fragments/compareAllowPull", w, params)
1311
1260
}
1312
1261
1313
-
type RepoCompareDiffParams struct {
1314
-
LoggedInUser *oauth.User
1315
-
RepoInfo repoinfo.RepoInfo
1316
-
Diff types.NiceDiff
1262
+
type RepoCompareDiffFragmentParams struct {
1263
+
Diff types.NiceDiff
1264
+
DiffOpts types.DiffOpts
1317
1265
}
1318
1266
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})
1267
+
func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1268
+
return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1321
1269
}
1322
1270
1323
1271
type LabelPanelParams struct {
···
1361
1309
Name string
1362
1310
Command string
1363
1311
Collapsed bool
1312
+
StartTime time.Time
1364
1313
}
1365
1314
1366
1315
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1367
1316
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1368
1317
}
1369
1318
1319
+
type LogBlockEndParams struct {
1320
+
Id int
1321
+
StartTime time.Time
1322
+
EndTime time.Time
1323
+
}
1324
+
1325
+
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1326
+
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1327
+
}
1328
+
1370
1329
type LogLineParams struct {
1371
1330
Id int
1372
1331
Content string
···
1426
1385
ShowRendered bool
1427
1386
RenderToggle bool
1428
1387
RenderedContents template.HTML
1429
-
String models.String
1388
+
String *models.String
1430
1389
Stats models.StringStats
1390
+
IsStarred bool
1391
+
StarCount int
1431
1392
Owner identity.Identity
1432
1393
}
1433
1394
1434
1395
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
1396
return p.execute("strings/string", w, params)
1474
1397
}
1475
1398
+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:
+82
-54
appview/pages/templates/fragments/dolly/logo.html
+82
-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="{{ . }}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
10
+
inkscape:export-filename="tangled_logotype_black_on_trans.svg"
11
+
inkscape:export-xdpi="96"
12
+
inkscape:export-ydpi="96"
13
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
14
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
15
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
16
+
xmlns="http://www.w3.org/2000/svg"
17
+
xmlns:svg="http://www.w3.org/2000/svg"
18
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
+
xmlns:cc="http://creativecommons.org/ns#">
20
+
<sodipodi:namedview
21
+
id="namedview1"
22
+
pagecolor="#ffffff"
23
+
bordercolor="#000000"
24
+
borderopacity="0.25"
25
+
inkscape:showpageshadow="2"
26
+
inkscape:pageopacity="0.0"
27
+
inkscape:pagecheckerboard="true"
28
+
inkscape:deskcolor="#d5d5d5"
29
+
inkscape:zoom="45.254834"
30
+
inkscape:cx="3.1377863"
31
+
inkscape:cy="8.9382717"
32
+
inkscape:window-width="3840"
33
+
inkscape:window-height="2160"
34
+
inkscape:window-x="0"
35
+
inkscape:window-y="0"
36
+
inkscape:window-maximized="0"
37
+
inkscape:current-layer="g1"
38
+
borderlayer="true">
39
+
<inkscape:page
40
+
x="0"
41
+
y="0"
42
+
width="25"
43
+
height="25"
44
+
id="page2"
45
+
margin="0"
46
+
bleed="0" />
47
+
</sodipodi:namedview>
48
+
<g
49
+
inkscape:groupmode="layer"
50
+
inkscape:label="Image"
51
+
id="g1"
52
+
transform="translate(-0.42924038,-0.87777209)">
53
+
<path
54
+
fill="currentColor"
55
+
style="stroke-width:0.111183;"
56
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
+
id="path4"
58
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
59
+
</g>
60
+
<metadata
61
+
id="metadata1">
62
+
<rdf:RDF>
63
+
<cc:Work
64
+
rdf:about="">
65
+
<cc:license
66
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
67
+
</cc:Work>
68
+
<cc:License
69
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
70
+
<cc:permits
71
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
72
+
<cc:permits
73
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
74
+
<cc:requires
75
+
rdf:resource="http://creativecommons.org/ns#Notice" />
76
+
<cc:requires
77
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
78
+
<cc:permits
79
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
80
+
</cc:License>
81
+
</rdf:RDF>
82
+
</metadata>
83
+
</svg>
56
84
{{ end }}
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
···
2
2
<svg
3
3
version="1.1"
4
4
id="svg1"
5
-
width="32"
6
-
height="32"
5
+
width="25"
6
+
height="25"
7
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
+
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
+
inkscape:export-xdpi="96"
11
+
inkscape:export-ydpi="96"
12
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
9
13
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
14
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
15
xmlns="http://www.w3.org/2000/svg"
12
-
xmlns:svg="http://www.w3.org/2000/svg">
13
-
<style>
14
-
.dolly {
15
-
color: #000000;
16
-
}
16
+
xmlns:svg="http://www.w3.org/2000/svg"
17
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
+
xmlns:cc="http://creativecommons.org/ns#">
19
+
<style>
20
+
.dolly {
21
+
color: #000000;
22
+
}
17
23
18
-
@media (prefers-color-scheme: dark) {
19
-
.dolly {
20
-
color: #ffffff;
21
-
}
22
-
}
23
-
</style>
24
-
<title>Dolly</title>
25
-
<defs
26
-
id="defs1" />
24
+
@media (prefers-color-scheme: dark) {
25
+
.dolly {
26
+
color: #ffffff;
27
+
}
28
+
}
29
+
</style>
27
30
<sodipodi:namedview
28
31
id="namedview1"
29
32
pagecolor="#ffffff"
···
32
35
inkscape:showpageshadow="2"
33
36
inkscape:pageopacity="0.0"
34
37
inkscape:pagecheckerboard="true"
35
-
inkscape:deskcolor="#d1d1d1">
38
+
inkscape:deskcolor="#d5d5d5"
39
+
inkscape:zoom="64"
40
+
inkscape:cx="4.96875"
41
+
inkscape:cy="13.429688"
42
+
inkscape:window-width="3840"
43
+
inkscape:window-height="2160"
44
+
inkscape:window-x="0"
45
+
inkscape:window-y="0"
46
+
inkscape:window-maximized="0"
47
+
inkscape:current-layer="g1"
48
+
borderlayer="true">
36
49
<inkscape:page
37
50
x="0"
38
51
y="0"
···
45
58
<g
46
59
inkscape:groupmode="layer"
47
60
inkscape:label="Image"
48
-
id="g1">
61
+
id="g1"
62
+
transform="translate(-0.42924038,-0.87777209)">
49
63
<path
50
64
class="dolly"
51
65
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" />
66
+
style="stroke-width:0.111183"
67
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
+
id="path7"
69
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
55
70
</g>
71
+
<metadata
72
+
id="metadata1">
73
+
<rdf:RDF>
74
+
<cc:Work
75
+
rdf:about="">
76
+
<cc:license
77
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
+
</cc:Work>
79
+
<cc:License
80
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
+
<cc:permits
82
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
+
<cc:permits
84
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
+
<cc:requires
86
+
rdf:resource="http://creativecommons.org/ns#Notice" />
87
+
<cc:requires
88
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
+
<cc:permits
90
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
+
</cc:License>
92
+
</rdf:RDF>
93
+
</metadata>
56
94
</svg>
57
95
{{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
-44
appview/pages/templates/fragments/dolly/silhouette.svg
···
1
-
<svg
2
-
version="1.1"
3
-
id="svg1"
4
-
width="32"
5
-
height="32"
6
-
viewBox="0 0 25 25"
7
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
9
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
-
xmlns="http://www.w3.org/2000/svg"
11
-
xmlns:svg="http://www.w3.org/2000/svg">
12
-
<title>Dolly</title>
13
-
<defs
14
-
id="defs1" />
15
-
<sodipodi:namedview
16
-
id="namedview1"
17
-
pagecolor="#ffffff"
18
-
bordercolor="#000000"
19
-
borderopacity="0.25"
20
-
inkscape:showpageshadow="2"
21
-
inkscape:pageopacity="0.0"
22
-
inkscape:pagecheckerboard="true"
23
-
inkscape:deskcolor="#d1d1d1">
24
-
<inkscape:page
25
-
x="0"
26
-
y="0"
27
-
width="25"
28
-
height="25"
29
-
id="page2"
30
-
margin="0"
31
-
bleed="0" />
32
-
</sodipodi:namedview>
33
-
<g
34
-
inkscape:groupmode="layer"
35
-
inkscape:label="Image"
36
-
id="g1">
37
-
<path
38
-
class="dolly"
39
-
fill="currentColor"
40
-
style="stroke-width:1.12248"
41
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
42
-
id="path1" />
43
-
</g>
44
-
</svg>
+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://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
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 }}
+1
appview/pages/templates/layouts/base.html
+1
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>
12
13
13
14
<!-- preconnect to image cdn -->
14
15
<link rel="preconnect" href="https://avatar.tangled.sh" />
-2
appview/pages/templates/layouts/fragments/topbar.html
-2
appview/pages/templates/layouts/fragments/topbar.html
···
61
61
<a href="/{{ $user }}">profile</a>
62
62
<a href="/{{ $user }}?tab=repos">repositories</a>
63
63
<a href="/{{ $user }}?tab=strings">strings</a>
64
-
<a href="/knots">knots</a>
65
-
<a href="/spindles">spindles</a>
66
64
<a href="/settings">settings</a>
67
65
<a href="#"
68
66
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" }}
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
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"
+2
-3
appview/pages/templates/repo/fragments/diff.html
+2
-3
appview/pages/templates/repo/fragments/diff.html
+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 }}
+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://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
27
+
</p>
28
+
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
+
</div>
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" }}
+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://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
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 }}
+17
appview/pages/templates/user/fragments/editBio.html
+17
appview/pages/templates/user/fragments/editBio.html
···
20
20
</div>
21
21
22
22
<div class="flex flex-col gap-1">
23
+
<label class="m-0 p-0" for="pronouns">pronouns</label>
24
+
<div class="flex items-center gap-2 w-full">
25
+
{{ $pronouns := "" }}
26
+
{{ if and .Profile .Profile.Pronouns }}
27
+
{{ $pronouns = .Profile.Pronouns }}
28
+
{{ end }}
29
+
<input
30
+
type="text"
31
+
class="py-1 px-1 w-full"
32
+
name="pronouns"
33
+
placeholder="they/them"
34
+
value="{{ $pronouns }}"
35
+
>
36
+
</div>
37
+
</div>
38
+
39
+
<div class="flex flex-col gap-1">
23
40
<label class="m-0 p-0" for="location">location</label>
24
41
<div class="flex items-center gap-2 w-full">
25
42
{{ $location := "" }}
+20
-7
appview/pages/templates/user/fragments/profileCard.html
+20
-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">
···
12
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
13
{{ $userIdent }}
14
14
</p>
15
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
15
+
{{ with .Profile }}
16
+
{{ if .Pronouns }}
17
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
+
{{ end }}
19
+
{{ end }}
16
20
</div>
17
21
18
22
<div class="md:hidden">
···
67
71
{{ end }}
68
72
</div>
69
73
{{ end }}
70
-
{{ if ne .FollowStatus.String "IsSelf" }}
71
-
{{ template "user/fragments/follow" . }}
72
-
{{ 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 }}
73
79
<button id="editBtn"
74
-
class="btn mt-2 w-full flex items-center gap-2 group"
80
+
class="btn w-full flex items-center gap-2 group"
75
81
hx-target="#profile-bio"
76
82
hx-get="/profile/edit-bio"
77
83
hx-swap="innerHTML">
···
79
85
edit
80
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
87
</button>
82
-
{{ 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
+
83
96
</div>
84
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
85
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.DrawDollySilhouette(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)
+159
-141
appview/pulls/pulls.go
+159
-141
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,
···
469
477
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
470
478
user := s.oauth.GetUser(r)
471
479
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
-
478
480
var diffOpts types.DiffOpts
479
481
if d := r.URL.Query().Get("diff"); d == "split" {
480
482
diffOpts.Split = true
···
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
})
···
727
729
s.pages.Notice(w, "pull", "Comment body is required")
728
730
return
729
731
}
732
+
733
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
730
734
731
735
// Start a transaction
732
736
tx, err := s.db.BeginTx(r.Context(), 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,
···
1198
1206
}
1199
1207
}
1200
1208
1209
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
1210
+
1201
1211
rkey := tid.TID()
1202
1212
initialSubmission := models.PullSubmission{
1203
1213
Patch: patch,
···
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.")
···
1237
1249
Val: &tangled.RepoPull{
1238
1250
Title: title,
1239
1251
Target: &tangled.RepoPull_Target{
1240
-
Repo: string(f.RepoAt()),
1252
+
Repo: string(repo.RepoAt()),
1241
1253
Branch: targetBranch,
1242
1254
},
1243
1255
Patch: patch,
···
1260
1272
1261
1273
s.notifier.NewPull(r.Context(), pull)
1262
1274
1263
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1275
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1276
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
1264
1277
}
1265
1278
1266
1279
func (s *Pulls) createStackedPullRequest(
1267
1280
w http.ResponseWriter,
1268
1281
r *http.Request,
1269
-
f *reporesolver.ResolvedRepo,
1282
+
repo *models.Repo,
1270
1283
user *oauth.User,
1271
1284
targetBranch string,
1272
1285
patch string,
···
1298
1311
1299
1312
// build a stack out of this patch
1300
1313
stackId := uuid.New()
1301
-
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1314
+
stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String())
1302
1315
if err != nil {
1303
1316
log.Println("failed to create stack", err)
1304
1317
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
···
1353
1366
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1354
1367
return
1355
1368
}
1369
+
1356
1370
}
1357
1371
1358
1372
if err = tx.Commit(); err != nil {
···
1361
1375
return
1362
1376
}
1363
1377
1364
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1378
+
// notify about each pull
1379
+
//
1380
+
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1381
+
for _, p := range stack {
1382
+
s.notifier.NewPull(r.Context(), p)
1383
+
}
1384
+
1385
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1386
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1365
1387
}
1366
1388
1367
1389
func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
···
1392
1414
1393
1415
func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1394
1416
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
1417
1401
1418
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1402
-
RepoInfo: f.RepoInfo(user),
1419
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1403
1420
})
1404
1421
}
1405
1422
···
1420
1437
Host: host,
1421
1438
}
1422
1439
1423
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1440
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1424
1441
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1425
1442
if err != nil {
1426
1443
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1453
1470
}
1454
1471
1455
1472
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1456
-
RepoInfo: f.RepoInfo(user),
1473
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1457
1474
Branches: withoutDefault,
1458
1475
})
1459
1476
}
1460
1477
1461
1478
func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1462
1479
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
1480
1469
1481
forks, err := db.GetForksByDid(s.db, user.Did)
1470
1482
if err != nil {
···
1473
1485
}
1474
1486
1475
1487
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1476
-
RepoInfo: f.RepoInfo(user),
1488
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1477
1489
Forks: forks,
1478
1490
Selected: r.URL.Query().Get("fork"),
1479
1491
})
···
1495
1507
// fork repo
1496
1508
repo, err := db.GetRepo(
1497
1509
s.db,
1498
-
db.FilterEq("did", forkOwnerDid),
1499
-
db.FilterEq("name", forkName),
1510
+
orm.FilterEq("did", forkOwnerDid),
1511
+
orm.FilterEq("name", forkName),
1500
1512
)
1501
1513
if err != nil {
1502
1514
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
···
1541
1553
Host: targetHost,
1542
1554
}
1543
1555
1544
-
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1556
+
targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1545
1557
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1546
1558
if err != nil {
1547
1559
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1566
1578
})
1567
1579
1568
1580
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1569
-
RepoInfo: f.RepoInfo(user),
1581
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1570
1582
SourceBranches: sourceBranches.Branches,
1571
1583
TargetBranches: targetBranches.Branches,
1572
1584
})
···
1574
1586
1575
1587
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1576
1588
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
1589
1583
1590
pull, ok := r.Context().Value("pull").(*models.Pull)
1584
1591
if !ok {
···
1590
1597
switch r.Method {
1591
1598
case http.MethodGet:
1592
1599
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1593
-
RepoInfo: f.RepoInfo(user),
1600
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1594
1601
Pull: pull,
1595
1602
})
1596
1603
return
···
1657
1664
return
1658
1665
}
1659
1666
1660
-
if !f.RepoInfo(user).Roles.IsPushAllowed() {
1667
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
1668
+
if !roles.IsPushAllowed() {
1661
1669
log.Println("unauthorized user")
1662
1670
w.WriteHeader(http.StatusUnauthorized)
1663
1671
return
···
1672
1680
Host: host,
1673
1681
}
1674
1682
1675
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1683
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1676
1684
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1677
1685
if err != nil {
1678
1686
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1799
1807
func (s *Pulls) resubmitPullHelper(
1800
1808
w http.ResponseWriter,
1801
1809
r *http.Request,
1802
-
f *reporesolver.ResolvedRepo,
1810
+
repo *models.Repo,
1803
1811
user *oauth.User,
1804
1812
pull *models.Pull,
1805
1813
patch string,
···
1808
1816
) {
1809
1817
if pull.IsStacked() {
1810
1818
log.Println("resubmitting stacked PR")
1811
-
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1819
+
s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId)
1812
1820
return
1813
1821
}
1814
1822
···
1838
1846
}
1839
1847
defer tx.Rollback()
1840
1848
1841
-
pullAt := pull.PullAt()
1849
+
pullAt := pull.AtUri()
1842
1850
newRoundNumber := len(pull.Submissions)
1843
1851
newPatch := patch
1844
1852
newSourceRev := sourceRev
···
1888
1896
Val: &tangled.RepoPull{
1889
1897
Title: pull.Title,
1890
1898
Target: &tangled.RepoPull_Target{
1891
-
Repo: string(f.RepoAt()),
1899
+
Repo: string(repo.RepoAt()),
1892
1900
Branch: pull.TargetBranch,
1893
1901
},
1894
1902
Patch: patch, // new patch
···
1909
1917
return
1910
1918
}
1911
1919
1912
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1920
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1921
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1913
1922
}
1914
1923
1915
1924
func (s *Pulls) resubmitStackedPullHelper(
1916
1925
w http.ResponseWriter,
1917
1926
r *http.Request,
1918
-
f *reporesolver.ResolvedRepo,
1927
+
repo *models.Repo,
1919
1928
user *oauth.User,
1920
1929
pull *models.Pull,
1921
1930
patch string,
···
1924
1933
targetBranch := pull.TargetBranch
1925
1934
1926
1935
origStack, _ := r.Context().Value("stack").(models.Stack)
1927
-
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1936
+
newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId)
1928
1937
if err != nil {
1929
1938
log.Println("failed to create resubmitted stack", err)
1930
1939
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
2035
2044
}
2036
2045
2037
2046
// resubmit the new pull
2038
-
pullAt := op.PullAt()
2047
+
pullAt := op.AtUri()
2039
2048
newRoundNumber := len(op.Submissions)
2040
2049
newPatch := np.LatestPatch()
2041
2050
combinedPatch := np.LatestSubmission().Combined
···
2066
2075
tx,
2067
2076
p.ParentChangeId,
2068
2077
// 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),
2078
+
orm.FilterEq("repo_at", p.RepoAt.String()),
2079
+
orm.FilterEq("owner_did", p.OwnerDid),
2080
+
orm.FilterEq("change_id", p.ChangeId),
2072
2081
)
2073
2082
2074
2083
if err != nil {
···
2102
2111
return
2103
2112
}
2104
2113
2105
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2114
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2115
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2106
2116
}
2107
2117
2108
2118
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2119
+
user := s.oauth.GetUser(r)
2109
2120
f, err := s.repoResolver.Resolve(r)
2110
2121
if err != nil {
2111
2122
log.Println("failed to resolve repo:", err)
···
2154
2165
2155
2166
authorName := ident.Handle.String()
2156
2167
mergeInput := &tangled.RepoMerge_Input{
2157
-
Did: f.OwnerDid(),
2168
+
Did: f.Did,
2158
2169
Name: f.Name,
2159
2170
Branch: pull.TargetBranch,
2160
2171
Patch: patch,
···
2216
2227
2217
2228
// notify about the pull merge
2218
2229
for _, p := range pullsToMerge {
2219
-
s.notifier.NewPullState(r.Context(), p)
2230
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2220
2231
}
2221
2232
2222
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2233
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2234
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2223
2235
}
2224
2236
2225
2237
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2239
2251
}
2240
2252
2241
2253
// auth filter: only owner or collaborators can close
2242
-
roles := f.RolesInRepo(user)
2254
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2243
2255
isOwner := roles.IsOwner()
2244
2256
isCollaborator := roles.IsCollaborator()
2245
2257
isPullAuthor := user.Did == pull.OwnerDid
···
2288
2300
}
2289
2301
2290
2302
for _, p := range pullsToClose {
2291
-
s.notifier.NewPullState(r.Context(), p)
2303
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2292
2304
}
2293
2305
2294
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2306
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2307
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2295
2308
}
2296
2309
2297
2310
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2312
2325
}
2313
2326
2314
2327
// auth filter: only owner or collaborators can close
2315
-
roles := f.RolesInRepo(user)
2328
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2316
2329
isOwner := roles.IsOwner()
2317
2330
isCollaborator := roles.IsCollaborator()
2318
2331
isPullAuthor := user.Did == pull.OwnerDid
···
2361
2374
}
2362
2375
2363
2376
for _, p := range pullsToReopen {
2364
-
s.notifier.NewPullState(r.Context(), p)
2377
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2365
2378
}
2366
2379
2367
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2380
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2381
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2368
2382
}
2369
2383
2370
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2384
+
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
2385
formatPatches, err := patchutil.ExtractPatches(patch)
2372
2386
if err != nil {
2373
2387
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2392
2406
body := fp.Body
2393
2407
rkey := tid.TID()
2394
2408
2409
+
mentions, references := s.mentionsResolver.Resolve(ctx, body)
2410
+
2395
2411
initialSubmission := models.PullSubmission{
2396
2412
Patch: fp.Raw,
2397
2413
SourceRev: fp.SHA,
···
2402
2418
Body: body,
2403
2419
TargetBranch: targetBranch,
2404
2420
OwnerDid: user.Did,
2405
-
RepoAt: f.RepoAt(),
2421
+
RepoAt: repo.RepoAt(),
2406
2422
Rkey: rkey,
2423
+
Mentions: mentions,
2424
+
References: references,
2407
2425
Submissions: []*models.PullSubmission{
2408
2426
&initialSubmission,
2409
2427
},
+49
appview/repo/archive.go
+49
appview/repo/archive.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
9
+
"tangled.org/core/api/tangled"
10
+
xrpcclient "tangled.org/core/appview/xrpcclient"
11
+
12
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/go-chi/chi/v5"
14
+
"github.com/go-git/go-git/v5/plumbing"
15
+
)
16
+
17
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "DownloadArchive")
19
+
ref := chi.URLParam(r, "ref")
20
+
ref, _ = url.PathUnescape(ref)
21
+
f, err := rp.repoResolver.Resolve(r)
22
+
if err != nil {
23
+
l.Error("failed to get repo and knot", "err", err)
24
+
return
25
+
}
26
+
scheme := "http"
27
+
if !rp.config.Core.Dev {
28
+
scheme = "https"
29
+
}
30
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
31
+
xrpcc := &indigoxrpc.Client{
32
+
Host: host,
33
+
}
34
+
didSlashRepo := f.DidSlashRepo()
35
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo)
36
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
+
rp.pages.Error503(w)
39
+
return
40
+
}
41
+
// Set headers for file download, just pass along whatever the knot specifies
42
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
43
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
44
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
45
+
w.Header().Set("Content-Type", "application/gzip")
46
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
47
+
// Write the archive data directly
48
+
w.Write(archiveBytes)
49
+
}
+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.DrawDollySilhouette(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
+
}
+76
-162
appview/reporesolver/resolver.go
+76
-162
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 := path.Dir(extractPathAfterRef(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)
132
90
}
133
-
}
134
-
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)
91
+
stats = &models.RepoStats{
92
+
StarCount: starCount,
93
+
IssueCount: issueCount,
94
+
PullCount: pullCount,
95
+
}
164
96
}
165
97
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
-
}
211
-
212
-
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
213
-
if u != nil {
214
-
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
215
-
return repoinfo.RolesInRepo{Roles: r}
216
-
} else {
217
-
return repoinfo.RolesInRepo{}
218
-
}
219
133
}
220
134
221
135
// extractPathAfterRef gets the actual repository path
+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
-26
appview/spindles/spindles.go
+53
-26
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.")
···
646
673
// get the record from the DB first:
647
674
members, err := db.GetSpindleMembers(
648
675
s.Db,
649
-
db.FilterEq("did", user.Did),
650
-
db.FilterEq("instance", instance),
651
-
db.FilterEq("subject", memberId.DID),
676
+
orm.FilterEq("did", user.Did),
677
+
orm.FilterEq("instance", instance),
678
+
orm.FilterEq("subject", memberId.DID),
652
679
)
653
680
if err != nil || len(members) != 1 {
654
681
l.Error("failed to get member", "err", err)
···
659
686
// remove from db
660
687
if err = db.RemoveSpindleMember(
661
688
tx,
662
-
db.FilterEq("did", user.Did),
663
-
db.FilterEq("instance", instance),
664
-
db.FilterEq("subject", memberId.DID),
689
+
orm.FilterEq("did", user.Did),
690
+
orm.FilterEq("instance", instance),
691
+
orm.FilterEq("subject", memberId.DID),
665
692
); err != nil {
666
693
l.Error("failed to remove spindle member", "err", err)
667
694
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
+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
+
currentMonth := time.Now().Month()
167
+
for _, p := range profile.Punchcard.Punches {
168
+
idx := currentMonth - p.Date.Month()
169
+
if int(idx) < len(timeline.ByMonth) {
170
+
timeline.ByMonth[idx].Commits += p.Count
171
+
}
172
+
}
173
+
165
174
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
166
175
LoggedInUser: s.oauth.GetUser(r),
167
176
Card: profile,
···
180
189
s.pages.Error500(w)
181
190
return
182
191
}
183
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
192
+
l = l.With("profileDid", profile.UserDid)
184
193
185
194
repos, err := db.GetRepos(
186
195
s.db,
187
196
0,
188
-
db.FilterEq("did", profile.UserDid),
197
+
orm.FilterEq("did", profile.UserDid),
189
198
)
190
199
if err != nil {
191
200
l.Error("failed to get repos", "err", err)
···
209
218
s.pages.Error500(w)
210
219
return
211
220
}
212
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
221
+
l = l.With("profileDid", profile.UserDid)
213
222
214
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
223
+
stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid))
215
224
if err != nil {
216
225
l.Error("failed to get stars", "err", err)
217
226
s.pages.Error500(w)
···
219
228
}
220
229
var repos []models.Repo
221
230
for _, s := range stars {
222
-
if s.Repo != nil {
223
-
repos = append(repos, *s.Repo)
224
-
}
231
+
repos = append(repos, *s.Repo)
225
232
}
226
233
227
234
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
240
247
s.pages.Error500(w)
241
248
return
242
249
}
243
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
250
+
l = l.With("profileDid", profile.UserDid)
244
251
245
-
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
252
+
strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid))
246
253
if err != nil {
247
254
l.Error("failed to get strings", "err", err)
248
255
s.pages.Error500(w)
···
272
279
if err != nil {
273
280
return nil, err
274
281
}
275
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
282
+
l = l.With("profileDid", profile.UserDid)
276
283
277
284
loggedInUser := s.oauth.GetUser(r)
278
285
params := FollowsPageParams{
···
294
301
followDids = append(followDids, extractDid(follow))
295
302
}
296
303
297
-
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
304
+
profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids))
298
305
if err != nil {
299
306
l.Error("failed to get profiles", "followDids", followDids, "err", err)
300
307
return ¶ms, err
···
538
545
profile.Description = r.FormValue("description")
539
546
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
540
547
profile.Location = r.FormValue("location")
548
+
profile.Pronouns = r.FormValue("pronouns")
541
549
542
550
var links [5]string
543
551
for i := range 5 {
···
652
660
Location: &profile.Location,
653
661
PinnedRepositories: pinnedRepoStrings,
654
662
Stats: vanityStats[:],
663
+
Pronouns: &profile.Pronouns,
655
664
}},
656
665
SwapRecord: cid,
657
666
})
···
695
704
log.Printf("getting profile data for %s: %s", user.Did, err)
696
705
}
697
706
698
-
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
707
+
repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did))
699
708
if err != nil {
700
709
log.Printf("getting repos for %s: %s", user.Did, err)
701
710
}
+44
-33
appview/state/router.go
+44
-33
appview/state/router.go
···
42
42
43
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
44
pat := chi.URLParam(r, "*")
45
-
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
46
-
userRouter.ServeHTTP(w, r)
47
-
} else {
48
-
// Check if the first path element is a valid handle without '@' or a flattened DID
49
-
pathParts := strings.SplitN(pat, "/", 2)
50
-
if len(pathParts) > 0 {
51
-
if userutil.IsHandleNoAt(pathParts[0]) {
52
-
// Redirect to the same path but with '@' prefixed to the handle
53
-
redirectPath := "@" + pat
54
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
55
-
return
56
-
} else if userutil.IsFlattenedDid(pathParts[0]) {
57
-
// Redirect to the unflattened DID version
58
-
unflattenedDid := userutil.UnflattenDid(pathParts[0])
59
-
var redirectPath string
60
-
if len(pathParts) > 1 {
61
-
redirectPath = unflattenedDid + "/" + pathParts[1]
62
-
} else {
63
-
redirectPath = unflattenedDid
64
-
}
65
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
66
-
return
67
-
}
45
+
pathParts := strings.SplitN(pat, "/", 2)
46
+
47
+
if len(pathParts) > 0 {
48
+
firstPart := pathParts[0]
49
+
50
+
// if using a DID or handle, just continue as per usual
51
+
if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
52
+
userRouter.ServeHTTP(w, r)
53
+
return
54
+
}
55
+
56
+
// if using a flattened DID (like you would in go modules), unflatten
57
+
if userutil.IsFlattenedDid(firstPart) {
58
+
unflattenedDid := userutil.UnflattenDid(firstPart)
59
+
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
60
+
61
+
redirectURL := *r.URL
62
+
redirectURL.Path = "/" + redirectPath
63
+
64
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
65
+
return
66
+
}
67
+
68
+
// if using a handle with @, rewrite to work without @
69
+
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
70
+
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
71
+
72
+
redirectURL := *r.URL
73
+
redirectURL.Path = "/" + redirectPath
74
+
75
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
76
+
return
68
77
}
69
-
standardRouter.ServeHTTP(w, r)
78
+
70
79
}
80
+
81
+
standardRouter.ServeHTTP(w, r)
71
82
})
72
83
73
84
return router
···
80
91
r.Get("/", s.Profile)
81
92
r.Get("/feed.atom", s.AtomFeedPage)
82
93
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
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
90
95
r.Use(mw.GoImport())
91
96
r.Mount("/", s.RepoRouter(mw))
···
96
101
97
102
// These routes get proxied to the knot
98
103
r.Get("/info/refs", s.InfoRefs)
104
+
r.Post("/git-upload-archive", s.UploadArchive)
99
105
r.Post("/git-upload-pack", s.UploadPack)
100
106
r.Post("/git-receive-pack", s.ReceivePack)
101
107
···
134
140
// r.Post("/import", s.ImportRepo)
135
141
})
136
142
137
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
143
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
138
144
139
145
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
140
146
r.Post("/", s.Follow)
···
161
167
162
168
r.Mount("/settings", s.SettingsRouter())
163
169
r.Mount("/strings", s.StringsRouter(mw))
164
-
r.Mount("/knots", s.KnotsRouter())
165
-
r.Mount("/spindles", s.SpindlesRouter())
170
+
171
+
r.Mount("/settings/knots", s.KnotsRouter())
172
+
r.Mount("/settings/spindles", s.SpindlesRouter())
173
+
166
174
r.Mount("/notifications", s.NotificationsRouter(mw))
167
175
168
176
r.Mount("/signup", s.SignupRouter())
···
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
-34
appview/state/state.go
+40
-34
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,
···
294
300
return
295
301
}
296
302
297
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
303
+
gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
304
if err != nil {
299
305
// non-fatal
300
306
}
···
318
324
319
325
regs, err := db.GetRegistrations(
320
326
s.db,
321
-
db.FilterEq("did", user.Did),
322
-
db.FilterEq("needs_upgrade", 1),
327
+
orm.FilterEq("did", user.Did),
328
+
orm.FilterEq("needs_upgrade", 1),
323
329
)
324
330
if err != nil {
325
331
l.Error("non-fatal: failed to get registrations", "err", err)
···
327
333
328
334
spindles, err := db.GetSpindles(
329
335
s.db,
330
-
db.FilterEq("owner", user.Did),
331
-
db.FilterEq("needs_upgrade", 1),
336
+
orm.FilterEq("owner", user.Did),
337
+
orm.FilterEq("needs_upgrade", 1),
332
338
)
333
339
if err != nil {
334
340
l.Error("non-fatal: failed to get spindles", "err", err)
···
386
392
387
393
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
388
394
if err != nil {
389
-
w.WriteHeader(http.StatusNotFound)
395
+
s.logger.Error("failed to get public keys", "err", err)
396
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
390
397
return
391
398
}
392
399
393
400
if len(pubKeys) == 0 {
394
-
w.WriteHeader(http.StatusNotFound)
401
+
w.WriteHeader(http.StatusNoContent)
395
402
return
396
403
}
397
404
···
498
505
// Check for existing repos
499
506
existingRepo, err := db.GetRepo(
500
507
s.db,
501
-
db.FilterEq("did", user.Did),
502
-
db.FilterEq("name", repoName),
508
+
orm.FilterEq("did", user.Did),
509
+
orm.FilterEq("name", repoName),
503
510
)
504
511
if err == nil && existingRepo != nil {
505
512
l.Info("repo exists")
···
516
523
Rkey: rkey,
517
524
Description: description,
518
525
Created: time.Now(),
519
-
Labels: models.DefaultLabelDefs(),
526
+
Labels: s.config.Label.DefaultLabelDefs,
520
527
}
521
528
record := repo.AsRecord()
522
529
···
632
639
aturi = ""
633
640
634
641
s.notifier.NewRepo(r.Context(), repo)
635
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
642
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
636
643
}
637
644
}
638
645
···
658
665
return err
659
666
}
660
667
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))
668
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
669
+
defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults))
664
670
if err != nil {
665
671
return err
666
672
}
···
669
675
return nil
670
676
}
671
677
672
-
labelDefs, err := models.FetchDefaultDefs(r)
678
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
673
679
if err != nil {
674
680
return err
675
681
}
+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
+
}
+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.
+6
-6
docs/hacking.md
+6
-6
docs/hacking.md
···
52
52
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
53
54
54
# the secret key from above
55
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
55
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
56
56
57
57
# run redis in at a new shell to store oauth sessions
58
58
redis-server
···
117
117
# type `poweroff` at the shell to exit the VM
118
118
```
119
119
120
-
This starts a knot on port 6000, a spindle on port 6555
120
+
This starts a knot on port 6444, a spindle on port 6555
121
121
with `ssh` exposed on port 2222.
122
122
123
123
Once the services are running, head to
124
-
http://localhost:3000/knots and hit verify. It should
124
+
http://localhost:3000/settings/knots and hit verify. It should
125
125
verify the ownership of the services instantly if everything
126
126
went smoothly.
127
127
···
146
146
### running a spindle
147
147
148
148
The above VM should already be running a spindle on
149
-
`localhost:6555`. Head to http://localhost:3000/spindles and
149
+
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
150
150
hit verify. You can then configure each repository to use
151
151
this spindle and run CI jobs.
152
152
···
168
168
169
169
If for any reason you wish to disable either one of the
170
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`.
171
+
`services.tangled.spindle.enable` (or
172
+
`services.tangled.knot.enable`) to `false`.
+1
-1
docs/knot-hosting.md
+1
-1
docs/knot-hosting.md
···
131
131
132
132
You should now have a running knot server! You can finalize
133
133
your registration by hitting the `verify` button on the
134
-
[/knots](https://tangled.org/knots) page. This simply creates
134
+
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
135
135
a record on your PDS to announce the existence of the knot.
136
136
137
137
### custom paths
+4
-4
docs/migrations.md
+4
-4
docs/migrations.md
···
14
14
For knots:
15
15
16
16
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.org/knots) and
17
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
18
18
hit the "retry" button to verify your knot
19
19
20
20
For spindles:
21
21
22
22
- Upgrade to latest tag (v1.9.0 or above)
23
23
- Head to the [spindle
24
-
dashboard](https://tangled.org/spindles) and hit the
24
+
dashboard](https://tangled.org/settings/spindles) and hit the
25
25
"retry" button to verify your spindle
26
26
27
27
## Upgrading from v1.7.x
···
41
41
[settings](https://tangled.org/settings) page.
42
42
- Restart your knot once you have replaced the environment
43
43
variable
44
-
- Head to the [knot dashboard](https://tangled.org/knots) and
44
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
45
45
hit the "retry" button to verify your knot. This simply
46
46
writes a `sh.tangled.knot` record to your PDS.
47
47
···
49
49
latest revision, and change your config block like so:
50
50
51
51
```diff
52
-
services.tangled-knot = {
52
+
services.tangled.knot = {
53
53
enable = true;
54
54
server = {
55
55
- secretFile = /path/to/secret;
+19
-1
docs/spindle/pipeline.md
+19
-1
docs/spindle/pipeline.md
···
19
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
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.
22
+
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
+
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
24
24
25
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
···
29
30
branch: ["main", "develop"]
30
31
- event: ["pull_request"]
31
32
branch: ["main"]
33
+
```
34
+
35
+
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
+
37
+
```yaml
38
+
when:
39
+
- event: ["push"]
40
+
tag: ["v*"]
41
+
```
42
+
43
+
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
+
45
+
```yaml
46
+
when:
47
+
- event: ["push"]
48
+
branch: ["main", "release-*"]
49
+
tag: ["v*", "stable"]
32
50
```
33
51
34
52
## Engine
+20
-3
flake.lock
+20
-3
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": {
···
134
150
},
135
151
"nixpkgs": {
136
152
"locked": {
137
-
"lastModified": 1751984180,
138
-
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
153
+
"lastModified": 1765186076,
154
+
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
139
155
"owner": "nixos",
140
156
"repo": "nixpkgs",
141
-
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
157
+
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
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",
+12
-12
flake.nix
+12
-12
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"];
···
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 {};
87
91
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
151
155
nativeBuildInputs = [
152
156
pkgs.go
153
157
pkgs.air
154
-
pkgs.tilt
155
158
pkgs.gopls
156
159
pkgs.httpie
157
160
pkgs.litecli
···
179
182
air-watcher = name: arg:
180
183
pkgs.writeShellScriptBin "run"
181
184
''
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"
185
+
export PATH=${pkgs.go}/bin:$PATH
186
+
${pkgs.air}/bin/air -c ./.air/${name}.toml \
187
+
-build.args_bin "${arg}"
188
188
'';
189
189
tailwind-watcher =
190
190
pkgs.writeShellScriptBin "run"
···
283
283
}: {
284
284
imports = [./nix/modules/appview.nix];
285
285
286
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
286
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview;
287
287
};
288
288
nixosModules.knot = {
289
289
lib,
···
292
292
}: {
293
293
imports = [./nix/modules/knot.nix];
294
294
295
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
295
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot;
296
296
};
297
297
nixosModules.spindle = {
298
298
lib,
···
301
301
}: {
302
302
imports = [./nix/modules/spindle.nix];
303
303
304
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
304
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
305
305
};
306
306
};
307
307
}
+5
-15
go.mod
+5
-15
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
47
48
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
49
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
48
50
golang.org/x/crypto v0.40.0
49
51
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
52
golang.org/x/image v0.31.0
···
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
+2
-21
go.sum
+2
-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=
+36
-61
guard/guard.go
+36
-61
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"
19
17
"tangled.org/core/log"
20
18
)
21
19
···
93
91
"command", sshCommand,
94
92
"client", clientIP)
95
93
94
+
// TODO: greet user with their resolved handle instead of did
96
95
if sshCommand == "" {
97
96
l.Info("access denied: no interactive shells", "user", incomingUser)
98
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
107
106
}
108
107
109
108
gitCommand := cmdParts[0]
110
-
111
-
// did:foo/repo-name or
112
-
// handle/repo-name or
113
-
// any of the above with a leading slash (/)
114
-
115
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
116
-
l.Info("command components", "components", components)
117
-
118
-
if len(components) != 2 {
119
-
l.Error("invalid repo format", "components", components)
120
-
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
121
-
os.Exit(-1)
122
-
}
123
-
124
-
didOrHandle := components[0]
125
-
identity := resolveIdentity(ctx, l, didOrHandle)
126
-
did := identity.DID.String()
127
-
repoName := components[1]
128
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
109
+
repoPath := cmdParts[1]
129
110
130
111
validCommands := map[string]bool{
131
112
"git-receive-pack": true,
···
138
119
return fmt.Errorf("access denied: invalid git command")
139
120
}
140
121
141
-
if gitCommand != "git-upload-pack" {
142
-
if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
143
-
l.Error("access denied: user not allowed",
144
-
"did", incomingUser,
145
-
"reponame", qualifiedRepoName)
146
-
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
147
-
os.Exit(-1)
148
-
}
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)
149
128
}
150
129
151
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
152
131
153
132
l.Info("processing command",
154
133
"user", incomingUser,
155
134
"command", gitCommand,
156
-
"repo", repoName,
135
+
"repo", repoPath,
157
136
"fullPath", fullPath,
158
137
"client", clientIP)
159
138
···
177
156
gitCmd.Stdin = os.Stdin
178
157
gitCmd.Env = append(os.Environ(),
179
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
181
159
)
182
160
183
161
if err := gitCmd.Run(); err != nil {
···
189
167
l.Info("command completed",
190
168
"user", incomingUser,
191
169
"command", gitCommand,
192
-
"repo", repoName,
170
+
"repo", repoPath,
193
171
"success", true)
194
172
195
173
return nil
196
174
}
197
175
198
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
-
resolver := idresolver.DefaultResolver()
200
-
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())
201
186
if err != nil {
202
-
l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
203
-
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
204
-
os.Exit(1)
205
-
}
206
-
if ident.Handle.IsInvalidHandle() {
207
-
l.Error("Error resolving handle", "invalid handle", didOrHandle)
208
-
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
209
-
os.Exit(1)
187
+
return "", err
210
188
}
211
-
return ident
212
-
}
189
+
defer resp.Body.Close()
213
190
214
-
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
215
-
u, _ := url.Parse(endpoint + "/push-allowed")
216
-
q := u.Query()
217
-
q.Add("user", user)
218
-
q.Add("repo", qualifiedRepoName)
219
-
u.RawQuery = q.Encode()
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
220
192
221
-
req, err := http.Get(u.String())
193
+
body, err := io.ReadAll(resp.Body)
222
194
if err != nil {
223
-
l.Error("Error verifying permissions", "error", err)
224
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
225
-
os.Exit(1)
195
+
return "", err
226
196
}
227
-
228
-
l.Info("Checking push permission",
229
-
"url", u.String(),
230
-
"status", req.Status)
197
+
text := string(body)
231
198
232
-
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
+
}
233
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)
+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
}
+38
input.css
+38
input.css
···
161
161
@apply no-underline;
162
162
}
163
163
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
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 {
···
924
929
text-decoration: underline;
925
930
}
926
931
}
932
+
933
+
actor-typeahead {
934
+
--color-background: #ffffff;
935
+
--color-border: #d1d5db;
936
+
--color-shadow: #000000;
937
+
--color-hover: #f9fafb;
938
+
--color-avatar-fallback: #e5e7eb;
939
+
--radius: 0.0;
940
+
--padding-menu: 0.0rem;
941
+
z-index: 1000;
942
+
}
943
+
944
+
actor-typeahead::part(handle) {
945
+
color: #111827;
946
+
}
947
+
948
+
actor-typeahead::part(menu) {
949
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
950
+
}
951
+
952
+
@media (prefers-color-scheme: dark) {
953
+
actor-typeahead {
954
+
--color-background: #1f2937;
955
+
--color-border: #4b5563;
956
+
--color-shadow: #000000;
957
+
--color-hover: #374151;
958
+
--color-avatar-fallback: #4b5563;
959
+
}
960
+
961
+
actor-typeahead::part(handle) {
962
+
color: #f9fafb;
963
+
}
964
+
}
+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 {
+5
lexicons/actor/profile.json
+5
lexicons/actor/profile.json
+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
}
+14
lexicons/pulls/pull.json
+14
lexicons/pulls/pull.json
···
36
36
"createdAt": {
37
37
"type": "string",
38
38
"format": "datetime"
39
+
},
40
+
"mentions": {
41
+
"type": "array",
42
+
"items": {
43
+
"type": "string",
44
+
"format": "did"
45
+
}
46
+
},
47
+
"references": {
48
+
"type": "array",
49
+
"items": {
50
+
"type": "string",
51
+
"format": "at-uri"
52
+
}
39
53
}
40
54
}
41
55
}
+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",
+2
-32
nix/gomod2nix.toml
+2
-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="
+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}"
+2
nix/pkgs/appview-static-files.nix
+2
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,
10
11
src,
···
24
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
27
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
30
# for whatever reason (produces broken css), so we are doing this instead
29
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+1
-1
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+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
+24
-11
nix/vm.nix
+24
-11
nix/vm.nix
···
10
10
if var == ""
11
11
then throw "\$${name} must be defined, see docs/hacking.md 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
}
+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
+17
-20
spindle/engine/engine.go
+17
-20
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
···
99
102
if errors.Is(err, ErrTimedOut) {
100
103
dbErr := db.StatusTimeout(wid, n)
101
104
if dbErr != nil {
102
-
return dbErr
105
+
l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
103
106
}
104
107
} else {
105
108
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
106
109
if dbErr != nil {
107
-
return dbErr
110
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
108
111
}
109
112
}
110
-
111
-
return fmt.Errorf("starting steps image: %w", err)
113
+
return
112
114
}
113
115
}
114
116
115
117
err = db.StatusSuccess(wid, n)
116
118
if err != nil {
117
-
return err
119
+
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
118
120
}
119
-
120
-
return nil
121
-
})
121
+
}()
122
122
}
123
123
}
124
124
125
-
if err := eg.Wait(); err != nil {
126
-
l.Error("failed to run one or more workflows", "err", err)
127
-
} else {
128
-
l.Info("successfully ran full pipeline")
129
-
}
125
+
wg.Wait()
126
+
l.Info("all workflows completed")
130
127
}
+10
-9
spindle/engines/nixery/engine.go
+10
-9
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
-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
+
}
+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
+
}
+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")
+97
-41
spindle/server.go
+97
-41
spindle/server.go
···
6
6
"encoding/json"
7
7
"fmt"
8
8
"log/slog"
9
+
"maps"
9
10
"net/http"
10
11
11
12
"github.com/go-chi/chi/v5"
···
49
50
vault secrets.Manager
50
51
}
51
52
52
-
func Run(ctx context.Context) error {
53
+
// New creates a new Spindle server with the provided configuration and engines.
54
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
53
55
logger := log.FromContext(ctx)
54
56
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
-
60
57
d, err := db.Make(cfg.Server.DBPath)
61
58
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
59
+
return nil, fmt.Errorf("failed to setup db: %w", err)
63
60
}
64
61
65
62
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
63
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
64
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
65
}
69
66
e.E.EnableAutoSave(true)
70
67
···
74
71
switch cfg.Server.Secrets.Provider {
75
72
case "openbao":
76
73
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
74
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
75
}
79
76
vault, err = secrets.NewOpenBaoManager(
80
77
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
79
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
80
)
84
81
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
82
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
83
}
87
84
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
85
case "sqlite", "":
89
86
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
87
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
88
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
89
}
93
90
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
91
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
92
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
101
93
}
102
94
103
95
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
110
102
}
111
103
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
104
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
105
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
114
106
}
115
107
jc.AddDid(cfg.Server.Owner)
116
108
117
109
// Check if the spindle knows about any Dids;
118
110
dids, err := d.GetAllDids()
119
111
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
112
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
121
113
}
122
114
for _, d := range dids {
123
115
jc.AddDid(d)
124
116
}
125
117
126
-
resolver := idresolver.DefaultResolver()
118
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
127
119
128
-
spindle := Spindle{
120
+
spindle := &Spindle{
129
121
jc: jc,
130
122
e: e,
131
123
db: d,
132
124
l: logger,
133
125
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
126
+
engs: engines,
135
127
jq: jq,
136
128
cfg: cfg,
137
129
res: resolver,
···
140
132
141
133
err = e.AddSpindle(rbacDomain)
142
134
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
135
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
144
136
}
145
137
err = spindle.configureOwner()
146
138
if err != nil {
147
-
return err
139
+
return nil, err
148
140
}
149
141
logger.Info("owner set", "did", cfg.Server.Owner)
150
142
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
143
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
144
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
145
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
146
}
164
147
165
148
err = jc.StartJetstream(ctx, spindle.ingest())
166
149
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
150
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
168
151
}
169
152
170
153
// for each incoming sh.tangled.pipeline, we execute
···
177
160
ccfg.CursorStore = cursorStore
178
161
knownKnots, err := d.Knots()
179
162
if err != nil {
180
-
return err
163
+
return nil, err
181
164
}
182
165
for _, knot := range knownKnots {
183
166
logger.Info("adding source start", "knot", knot)
···
185
168
}
186
169
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
170
171
+
return spindle, nil
172
+
}
173
+
174
+
// DB returns the database instance.
175
+
func (s *Spindle) DB() *db.DB {
176
+
return s.db
177
+
}
178
+
179
+
// Queue returns the job queue instance.
180
+
func (s *Spindle) Queue() *queue.Queue {
181
+
return s.jq
182
+
}
183
+
184
+
// Engines returns the map of available engines.
185
+
func (s *Spindle) Engines() map[string]models.Engine {
186
+
return s.engs
187
+
}
188
+
189
+
// Vault returns the secrets manager instance.
190
+
func (s *Spindle) Vault() secrets.Manager {
191
+
return s.vault
192
+
}
193
+
194
+
// Notifier returns the notifier instance.
195
+
func (s *Spindle) Notifier() *notifier.Notifier {
196
+
return s.n
197
+
}
198
+
199
+
// Enforcer returns the RBAC enforcer instance.
200
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
201
+
return s.e
202
+
}
203
+
204
+
// Start starts the Spindle server (blocking).
205
+
func (s *Spindle) Start(ctx context.Context) error {
206
+
// starts a job queue runner in the background
207
+
s.jq.Start()
208
+
defer s.jq.Stop()
209
+
210
+
// Stop vault token renewal if it implements Stopper
211
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
212
+
defer stopper.Stop()
213
+
}
214
+
188
215
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
216
+
s.l.Info("starting knot event consumer")
217
+
s.ks.Start(ctx)
191
218
}()
192
219
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
220
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
221
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
222
+
}
195
223
196
-
return nil
224
+
func Run(ctx context.Context) error {
225
+
cfg, err := config.Load(ctx)
226
+
if err != nil {
227
+
return fmt.Errorf("failed to load config: %w", err)
228
+
}
229
+
230
+
nixeryEng, err := nixery.New(ctx, cfg)
231
+
if err != nil {
232
+
return err
233
+
}
234
+
235
+
s, err := New(ctx, cfg, map[string]models.Engine{
236
+
"nixery": nixeryEng,
237
+
})
238
+
if err != nil {
239
+
return err
240
+
}
241
+
242
+
return s.Start(ctx)
197
243
}
198
244
199
245
func (s *Spindle) Router() http.Handler {
···
266
312
267
313
workflows := make(map[models.Engine][]models.Workflow)
268
314
315
+
// Build pipeline environment variables once for all workflows
316
+
pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev)
317
+
269
318
for _, w := range tpl.Workflows {
270
319
if w != nil {
271
320
if _, ok := s.engs[w.Engine]; !ok {
···
290
339
if err != nil {
291
340
return err
292
341
}
342
+
343
+
// inject TANGLED_* env vars after InitWorkflow
344
+
// This prevents user-defined env vars from overriding them
345
+
if ewf.Environment == nil {
346
+
ewf.Environment = make(map[string]string)
347
+
}
348
+
maps.Copy(ewf.Environment, pipelineEnv)
293
349
294
350
workflows[eng] = append(workflows[eng], *ewf)
295
351
+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
}
+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
+
}
+2
-12
types/diff.go
+2
-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"`
+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
+
}