+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
+649
-8
api/tangled/cbor_gen.go
+649
-8
api/tangled/cbor_gen.go
···
6938
6938
}
6939
6939
6940
6940
cw := cbg.NewCborWriter(w)
6941
-
fieldCount := 5
6941
+
fieldCount := 7
6942
6942
6943
6943
if t.Body == nil {
6944
+
fieldCount--
6945
+
}
6946
+
6947
+
if t.Mentions == nil {
6948
+
fieldCount--
6949
+
}
6950
+
6951
+
if t.References == nil {
6944
6952
fieldCount--
6945
6953
}
6946
6954
···
7045
7053
return err
7046
7054
}
7047
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
+
7048
7092
// t.CreatedAt (string) (string)
7049
7093
if len("createdAt") > 1000000 {
7050
7094
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7067
7111
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7068
7112
return err
7069
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
+
}
7070
7150
return nil
7071
7151
}
7072
7152
···
7095
7175
7096
7176
n := extra
7097
7177
7098
-
nameBuf := make([]byte, 9)
7178
+
nameBuf := make([]byte, 10)
7099
7179
for i := uint64(0); i < n; i++ {
7100
7180
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7101
7181
if err != nil {
···
7164
7244
}
7165
7245
7166
7246
t.Title = string(sval)
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
+
}
7167
7287
}
7168
7288
// t.CreatedAt (string) (string)
7169
7289
case "createdAt":
···
7176
7296
7177
7297
t.CreatedAt = string(sval)
7178
7298
}
7299
+
// t.References ([]string) (slice)
7300
+
case "references":
7301
+
7302
+
maj, extra, err = cr.ReadHeader()
7303
+
if err != nil {
7304
+
return err
7305
+
}
7306
+
7307
+
if extra > 8192 {
7308
+
return fmt.Errorf("t.References: array too large (%d)", extra)
7309
+
}
7310
+
7311
+
if maj != cbg.MajArray {
7312
+
return fmt.Errorf("expected cbor array")
7313
+
}
7314
+
7315
+
if extra > 0 {
7316
+
t.References = make([]string, extra)
7317
+
}
7318
+
7319
+
for i := 0; i < int(extra); i++ {
7320
+
{
7321
+
var maj byte
7322
+
var extra uint64
7323
+
var err error
7324
+
_ = maj
7325
+
_ = extra
7326
+
_ = err
7327
+
7328
+
{
7329
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7330
+
if err != nil {
7331
+
return err
7332
+
}
7333
+
7334
+
t.References[i] = string(sval)
7335
+
}
7336
+
7337
+
}
7338
+
}
7179
7339
7180
7340
default:
7181
7341
// Field doesn't exist on this type, so ignore it
···
7194
7354
}
7195
7355
7196
7356
cw := cbg.NewCborWriter(w)
7197
-
fieldCount := 5
7357
+
fieldCount := 7
7358
+
7359
+
if t.Mentions == nil {
7360
+
fieldCount--
7361
+
}
7362
+
7363
+
if t.References == nil {
7364
+
fieldCount--
7365
+
}
7198
7366
7199
7367
if t.ReplyTo == nil {
7200
7368
fieldCount--
···
7301
7469
}
7302
7470
}
7303
7471
7472
+
// t.Mentions ([]string) (slice)
7473
+
if t.Mentions != nil {
7474
+
7475
+
if len("mentions") > 1000000 {
7476
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
7477
+
}
7478
+
7479
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
7480
+
return err
7481
+
}
7482
+
if _, err := cw.WriteString(string("mentions")); err != nil {
7483
+
return err
7484
+
}
7485
+
7486
+
if len(t.Mentions) > 8192 {
7487
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
7488
+
}
7489
+
7490
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
7491
+
return err
7492
+
}
7493
+
for _, v := range t.Mentions {
7494
+
if len(v) > 1000000 {
7495
+
return xerrors.Errorf("Value in field v was too long")
7496
+
}
7497
+
7498
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
7499
+
return err
7500
+
}
7501
+
if _, err := cw.WriteString(string(v)); err != nil {
7502
+
return err
7503
+
}
7504
+
7505
+
}
7506
+
}
7507
+
7304
7508
// t.CreatedAt (string) (string)
7305
7509
if len("createdAt") > 1000000 {
7306
7510
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7323
7527
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7324
7528
return err
7325
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
+
}
7326
7566
return nil
7327
7567
}
7328
7568
···
7351
7591
7352
7592
n := extra
7353
7593
7354
-
nameBuf := make([]byte, 9)
7594
+
nameBuf := make([]byte, 10)
7355
7595
for i := uint64(0); i < n; i++ {
7356
7596
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7357
7597
if err != nil {
···
7419
7659
}
7420
7660
7421
7661
t.ReplyTo = (*string)(&sval)
7662
+
}
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
+
7422
7702
}
7423
7703
}
7424
7704
// t.CreatedAt (string) (string)
···
7431
7711
}
7432
7712
7433
7713
t.CreatedAt = string(sval)
7714
+
}
7715
+
// t.References ([]string) (slice)
7716
+
case "references":
7717
+
7718
+
maj, extra, err = cr.ReadHeader()
7719
+
if err != nil {
7720
+
return err
7721
+
}
7722
+
7723
+
if extra > 8192 {
7724
+
return fmt.Errorf("t.References: array too large (%d)", extra)
7725
+
}
7726
+
7727
+
if maj != cbg.MajArray {
7728
+
return fmt.Errorf("expected cbor array")
7729
+
}
7730
+
7731
+
if extra > 0 {
7732
+
t.References = make([]string, extra)
7733
+
}
7734
+
7735
+
for i := 0; i < int(extra); i++ {
7736
+
{
7737
+
var maj byte
7738
+
var extra uint64
7739
+
var err error
7740
+
_ = maj
7741
+
_ = extra
7742
+
_ = err
7743
+
7744
+
{
7745
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7746
+
if err != nil {
7747
+
return err
7748
+
}
7749
+
7750
+
t.References[i] = string(sval)
7751
+
}
7752
+
7753
+
}
7434
7754
}
7435
7755
7436
7756
default:
···
7614
7934
}
7615
7935
7616
7936
cw := cbg.NewCborWriter(w)
7617
-
fieldCount := 7
7937
+
fieldCount := 9
7618
7938
7619
7939
if t.Body == nil {
7940
+
fieldCount--
7941
+
}
7942
+
7943
+
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.References == nil {
7620
7948
fieldCount--
7621
7949
}
7622
7950
···
7760
8088
return err
7761
8089
}
7762
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
+
7763
8127
// t.CreatedAt (string) (string)
7764
8128
if len("createdAt") > 1000000 {
7765
8129
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7782
8146
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7783
8147
return err
7784
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
+
}
7785
8185
return nil
7786
8186
}
7787
8187
···
7810
8210
7811
8211
n := extra
7812
8212
7813
-
nameBuf := make([]byte, 9)
8213
+
nameBuf := make([]byte, 10)
7814
8214
for i := uint64(0); i < n; i++ {
7815
8215
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7816
8216
if err != nil {
···
7919
8319
}
7920
8320
}
7921
8321
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
+
}
7922
8362
}
7923
8363
// t.CreatedAt (string) (string)
7924
8364
case "createdAt":
···
7931
8371
7932
8372
t.CreatedAt = string(sval)
7933
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
+
}
7934
8414
7935
8415
default:
7936
8416
// Field doesn't exist on this type, so ignore it
···
7949
8429
}
7950
8430
7951
8431
cw := cbg.NewCborWriter(w)
8432
+
fieldCount := 6
7952
8433
7953
-
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 {
7954
8443
return err
7955
8444
}
7956
8445
···
8019
8508
return err
8020
8509
}
8021
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
+
8022
8547
// t.CreatedAt (string) (string)
8023
8548
if len("createdAt") > 1000000 {
8024
8549
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
8040
8565
}
8041
8566
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8042
8567
return err
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
+
}
8043
8604
}
8044
8605
return nil
8045
8606
}
···
8069
8630
8070
8631
n := extra
8071
8632
8072
-
nameBuf := make([]byte, 9)
8633
+
nameBuf := make([]byte, 10)
8073
8634
for i := uint64(0); i < n; i++ {
8074
8635
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8075
8636
if err != nil {
···
8118
8679
8119
8680
t.LexiconTypeID = string(sval)
8120
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
+
}
8121
8722
// t.CreatedAt (string) (string)
8122
8723
case "createdAt":
8123
8724
···
8128
8729
}
8129
8730
8130
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
+
}
8131
8772
}
8132
8773
8133
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"`
+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) {
+55
-2
appview/db/db.go
+55
-2
appview/db/db.go
···
561
561
email_notifications integer not null default 0
562
562
);
563
563
564
+
create table if not exists reference_links (
565
+
id integer primary key autoincrement,
566
+
from_at text not null,
567
+
to_at text not null,
568
+
unique (from_at, to_at)
569
+
);
570
+
564
571
create table if not exists migrations (
565
572
id integer primary key autoincrement,
566
573
name text unique
···
569
576
-- indexes for better performance
570
577
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
571
578
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);
579
+
create index if not exists idx_references_from_at on reference_links(from_at);
580
+
create index if not exists idx_references_to_at on reference_links(to_at);
574
581
`)
575
582
if err != nil {
576
583
return nil, err
···
1117
1124
_, err := tx.Exec(`
1118
1125
alter table repos add column website text;
1119
1126
alter table repos add column topics text;
1127
+
`)
1128
+
return err
1129
+
})
1130
+
1131
+
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1132
+
_, err := tx.Exec(`
1133
+
alter table notification_preferences add column user_mentioned integer not null default 1;
1134
+
`)
1135
+
return err
1136
+
})
1137
+
1138
+
// remove the foreign key constraints from stars.
1139
+
runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1140
+
_, err := tx.Exec(`
1141
+
create table stars_new (
1142
+
id integer primary key autoincrement,
1143
+
did text not null,
1144
+
rkey text not null,
1145
+
1146
+
subject_at text not null,
1147
+
1148
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1149
+
unique(did, rkey),
1150
+
unique(did, subject_at)
1151
+
);
1152
+
1153
+
insert into stars_new (
1154
+
id,
1155
+
did,
1156
+
rkey,
1157
+
subject_at,
1158
+
created
1159
+
)
1160
+
select
1161
+
id,
1162
+
starred_by_did,
1163
+
rkey,
1164
+
repo_at,
1165
+
created
1166
+
from stars;
1167
+
1168
+
drop table stars;
1169
+
alter table stars_new rename to stars;
1170
+
1171
+
create index if not exists idx_stars_created on stars(created);
1172
+
create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
1120
1173
`)
1121
1174
return err
1122
1175
})
+73
-18
appview/db/issues.go
+73
-18
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"
15
16
)
···
69
70
returning rowid, issue_id
70
71
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
71
72
72
-
return row.Scan(&issue.Id, &issue.IssueId)
73
+
err = row.Scan(&issue.Id, &issue.IssueId)
74
+
if err != nil {
75
+
return fmt.Errorf("scan row: %w", err)
76
+
}
77
+
78
+
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
79
+
return fmt.Errorf("put reference_links: %w", err)
80
+
}
81
+
return nil
73
82
}
74
83
75
84
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
···
79
88
set title = ?, body = ?, edited = ?
80
89
where did = ? and rkey = ?
81
90
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
82
-
return err
91
+
if err != nil {
92
+
return err
93
+
}
94
+
95
+
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
96
+
return fmt.Errorf("put reference_links: %w", err)
97
+
}
98
+
return nil
83
99
}
84
100
85
101
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
···
234
250
}
235
251
}
236
252
253
+
// collect references for each issue
254
+
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", issueAts))
255
+
if err != nil {
256
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
257
+
}
258
+
for issueAt, references := range allReferencs {
259
+
if issue, ok := issueMap[issueAt.String()]; ok {
260
+
issue.References = references
261
+
}
262
+
}
263
+
237
264
var issues []models.Issue
238
265
for _, i := range issueMap {
239
266
issues = append(issues, *i)
···
323
350
return ids, nil
324
351
}
325
352
326
-
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
327
-
result, err := e.Exec(
353
+
func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
354
+
result, err := tx.Exec(
328
355
`insert into issue_comments (
329
356
did,
330
357
rkey,
···
363
390
return 0, err
364
391
}
365
392
393
+
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
394
+
return 0, fmt.Errorf("put reference_links: %w", err)
395
+
}
396
+
366
397
return id, nil
367
398
}
368
399
···
386
417
}
387
418
388
419
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
389
-
var comments []models.IssueComment
420
+
commentMap := make(map[string]*models.IssueComment)
390
421
391
422
var conditions []string
392
423
var args []any
···
465
496
comment.ReplyTo = &replyTo.V
466
497
}
467
498
468
-
comments = append(comments, comment)
499
+
atUri := comment.AtUri().String()
500
+
commentMap[atUri] = &comment
469
501
}
470
502
471
503
if err = rows.Err(); err != nil {
472
504
return nil, err
473
505
}
474
506
507
+
// collect references for each comments
508
+
commentAts := slices.Collect(maps.Keys(commentMap))
509
+
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts))
510
+
if err != nil {
511
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
512
+
}
513
+
for commentAt, references := range allReferencs {
514
+
if comment, ok := commentMap[commentAt.String()]; ok {
515
+
comment.References = references
516
+
}
517
+
}
518
+
519
+
var comments []models.IssueComment
520
+
for _, c := range commentMap {
521
+
comments = append(comments, *c)
522
+
}
523
+
524
+
sort.Slice(comments, func(i, j int) bool {
525
+
return comments[i].Created.After(comments[j].Created)
526
+
})
527
+
475
528
return comments, nil
476
529
}
477
530
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()...)
531
+
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
532
+
_, err := tx.Exec(
533
+
`delete from issues
534
+
where did = ? and rkey = ?`,
535
+
did,
536
+
rkey,
537
+
)
538
+
if err != nil {
539
+
return fmt.Errorf("delete issue: %w", err)
484
540
}
485
541
486
-
whereClause := ""
487
-
if conditions != nil {
488
-
whereClause = " where " + strings.Join(conditions, " and ")
542
+
uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey))
543
+
err = deleteReferences(tx, uri)
544
+
if err != nil {
545
+
return fmt.Errorf("delete reference_links: %w", err)
489
546
}
490
547
491
-
query := fmt.Sprintf(`delete from issues %s`, whereClause)
492
-
_, err := e.Exec(query, args...)
493
-
return err
548
+
return nil
494
549
}
495
550
496
551
func CloseIssues(e Execer, filters ...filter) error {
+6
-2
appview/db/notifications.go
+6
-2
appview/db/notifications.go
···
400
400
pull_created,
401
401
pull_commented,
402
402
followed,
403
+
user_mentioned,
403
404
pull_merged,
404
405
issue_closed,
405
406
email_notifications
···
425
426
&prefs.PullCreated,
426
427
&prefs.PullCommented,
427
428
&prefs.Followed,
429
+
&prefs.UserMentioned,
428
430
&prefs.PullMerged,
429
431
&prefs.IssueClosed,
430
432
&prefs.EmailNotifications,
···
446
448
query := `
447
449
INSERT OR REPLACE INTO notification_preferences
448
450
(user_did, repo_starred, issue_created, issue_commented, pull_created,
449
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
450
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
+
email_notifications)
453
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
454
`
452
455
453
456
result, err := d.DB.ExecContext(ctx, query,
···
458
461
prefs.PullCreated,
459
462
prefs.PullCommented,
460
463
prefs.Followed,
464
+
prefs.UserMentioned,
461
465
prefs.PullMerged,
462
466
prefs.IssueClosed,
463
467
prefs.EmailNotifications,
+4
-2
appview/db/pipeline.go
+4
-2
appview/db/pipeline.go
···
168
168
169
169
// this is a mega query, but the most useful one:
170
170
// get N pipelines, for each one get the latest status of its N workflows
171
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
171
+
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
172
172
var conditions []string
173
173
var args []any
174
174
for _, filter := range filters {
···
205
205
join
206
206
triggers t ON p.trigger_id = t.id
207
207
%s
208
-
`, whereClause)
208
+
order by p.created desc
209
+
limit %d
210
+
`, whereClause, limit)
209
211
210
212
rows, err := e.Query(query, args...)
211
213
if err != nil {
+50
-6
appview/db/pulls.go
+50
-6
appview/db/pulls.go
···
93
93
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
94
values (?, ?, ?, ?, ?)
95
95
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
-
return err
96
+
if err != nil {
97
+
return err
98
+
}
99
+
100
+
if err := putReferences(tx, pull.AtUri(), pull.References); err != nil {
101
+
return fmt.Errorf("put reference_links: %w", err)
102
+
}
103
+
104
+
return nil
97
105
}
98
106
99
107
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
···
266
274
}
267
275
}
268
276
277
+
allReferences, err := GetReferencesAll(e, FilterIn("from_at", pullAts))
278
+
if err != nil {
279
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
280
+
}
281
+
for pullAt, references := range allReferences {
282
+
if pull, ok := pulls[pullAt]; ok {
283
+
pull.References = references
284
+
}
285
+
}
286
+
269
287
orderedByPullId := []*models.Pull{}
270
288
for _, p := range pulls {
271
289
orderedByPullId = append(orderedByPullId, p)
···
432
450
submissionIds := slices.Collect(maps.Keys(submissionMap))
433
451
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
434
452
if err != nil {
435
-
return nil, err
453
+
return nil, fmt.Errorf("failed to get pull comments: %w", err)
436
454
}
437
455
for _, comment := range comments {
438
456
if submission, ok := submissionMap[comment.SubmissionId]; ok {
···
492
510
}
493
511
defer rows.Close()
494
512
495
-
var comments []models.PullComment
513
+
commentMap := make(map[string]*models.PullComment)
496
514
for rows.Next() {
497
515
var comment models.PullComment
498
516
var createdAt string
···
514
532
comment.Created = t
515
533
}
516
534
517
-
comments = append(comments, comment)
535
+
atUri := comment.AtUri().String()
536
+
commentMap[atUri] = &comment
518
537
}
519
538
520
539
if err := rows.Err(); err != nil {
521
540
return nil, err
522
541
}
523
542
543
+
// collect references for each comments
544
+
commentAts := slices.Collect(maps.Keys(commentMap))
545
+
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts))
546
+
if err != nil {
547
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
548
+
}
549
+
for commentAt, references := range allReferencs {
550
+
if comment, ok := commentMap[commentAt.String()]; ok {
551
+
comment.References = references
552
+
}
553
+
}
554
+
555
+
var comments []models.PullComment
556
+
for _, c := range commentMap {
557
+
comments = append(comments, *c)
558
+
}
559
+
560
+
sort.Slice(comments, func(i, j int) bool {
561
+
return comments[i].Created.Before(comments[j].Created)
562
+
})
563
+
524
564
return comments, nil
525
565
}
526
566
···
600
640
return pulls, nil
601
641
}
602
642
603
-
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
643
+
func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) {
604
644
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
605
-
res, err := e.Exec(
645
+
res, err := tx.Exec(
606
646
query,
607
647
comment.OwnerDid,
608
648
comment.RepoAt,
···
618
658
i, err := res.LastInsertId()
619
659
if err != nil {
620
660
return 0, err
661
+
}
662
+
663
+
if err := putReferences(tx, comment.AtUri(), comment.References); err != nil {
664
+
return 0, fmt.Errorf("put reference_links: %w", err)
621
665
}
622
666
623
667
return i, nil
+462
appview/db/reference.go
+462
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
+
)
12
+
13
+
// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs.
14
+
// It will ignore missing refLinks.
15
+
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
16
+
var (
17
+
issueRefs []models.ReferenceLink
18
+
pullRefs []models.ReferenceLink
19
+
)
20
+
for _, ref := range refLinks {
21
+
switch ref.Kind {
22
+
case models.RefKindIssue:
23
+
issueRefs = append(issueRefs, ref)
24
+
case models.RefKindPull:
25
+
pullRefs = append(pullRefs, ref)
26
+
}
27
+
}
28
+
issueUris, err := findIssueReferences(e, issueRefs)
29
+
if err != nil {
30
+
return nil, fmt.Errorf("find issue references: %w", err)
31
+
}
32
+
pullUris, err := findPullReferences(e, pullRefs)
33
+
if err != nil {
34
+
return nil, fmt.Errorf("find pull references: %w", err)
35
+
}
36
+
37
+
return append(issueUris, pullUris...), nil
38
+
}
39
+
40
+
func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
41
+
if len(refLinks) == 0 {
42
+
return nil, nil
43
+
}
44
+
vals := make([]string, len(refLinks))
45
+
args := make([]any, 0, len(refLinks)*4)
46
+
for i, ref := range refLinks {
47
+
vals[i] = "(?, ?, ?, ?)"
48
+
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
49
+
}
50
+
query := fmt.Sprintf(
51
+
`with input(owner_did, name, issue_id, comment_id) as (
52
+
values %s
53
+
)
54
+
select
55
+
i.did, i.rkey,
56
+
c.did, c.rkey
57
+
from input inp
58
+
join repos r
59
+
on r.did = inp.owner_did
60
+
and r.name = inp.name
61
+
join issues i
62
+
on i.repo_at = r.at_uri
63
+
and i.issue_id = inp.issue_id
64
+
left join issue_comments c
65
+
on inp.comment_id is not null
66
+
and c.issue_at = i.at_uri
67
+
and c.id = inp.comment_id
68
+
`,
69
+
strings.Join(vals, ","),
70
+
)
71
+
rows, err := e.Query(query, args...)
72
+
if err != nil {
73
+
return nil, err
74
+
}
75
+
defer rows.Close()
76
+
77
+
var uris []syntax.ATURI
78
+
79
+
for rows.Next() {
80
+
// Scan rows
81
+
var issueOwner, issueRkey string
82
+
var commentOwner, commentRkey sql.NullString
83
+
var uri syntax.ATURI
84
+
if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil {
85
+
return nil, err
86
+
}
87
+
if commentOwner.Valid && commentRkey.Valid {
88
+
uri = syntax.ATURI(fmt.Sprintf(
89
+
"at://%s/%s/%s",
90
+
commentOwner.String,
91
+
tangled.RepoIssueCommentNSID,
92
+
commentRkey.String,
93
+
))
94
+
} else {
95
+
uri = syntax.ATURI(fmt.Sprintf(
96
+
"at://%s/%s/%s",
97
+
issueOwner,
98
+
tangled.RepoIssueNSID,
99
+
issueRkey,
100
+
))
101
+
}
102
+
uris = append(uris, uri)
103
+
}
104
+
if err := rows.Err(); err != nil {
105
+
return nil, fmt.Errorf("iterate rows: %w", err)
106
+
}
107
+
108
+
return uris, nil
109
+
}
110
+
111
+
func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
112
+
if len(refLinks) == 0 {
113
+
return nil, nil
114
+
}
115
+
vals := make([]string, len(refLinks))
116
+
args := make([]any, 0, len(refLinks)*4)
117
+
for i, ref := range refLinks {
118
+
vals[i] = "(?, ?, ?, ?)"
119
+
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
120
+
}
121
+
query := fmt.Sprintf(
122
+
`with input(owner_did, name, pull_id, comment_id) as (
123
+
values %s
124
+
)
125
+
select
126
+
p.owner_did, p.rkey,
127
+
c.comment_at
128
+
from input inp
129
+
join repos r
130
+
on r.did = inp.owner_did
131
+
and r.name = inp.name
132
+
join pulls p
133
+
on p.repo_at = r.at_uri
134
+
and p.pull_id = inp.pull_id
135
+
left join pull_comments c
136
+
on inp.comment_id is not null
137
+
and c.repo_at = r.at_uri and c.pull_id = p.pull_id
138
+
and c.id = inp.comment_id
139
+
`,
140
+
strings.Join(vals, ","),
141
+
)
142
+
rows, err := e.Query(query, args...)
143
+
if err != nil {
144
+
return nil, err
145
+
}
146
+
defer rows.Close()
147
+
148
+
var uris []syntax.ATURI
149
+
150
+
for rows.Next() {
151
+
// Scan rows
152
+
var pullOwner, pullRkey string
153
+
var commentUri sql.NullString
154
+
var uri syntax.ATURI
155
+
if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil {
156
+
return nil, err
157
+
}
158
+
if commentUri.Valid {
159
+
// no-op
160
+
uri = syntax.ATURI(commentUri.String)
161
+
} else {
162
+
uri = syntax.ATURI(fmt.Sprintf(
163
+
"at://%s/%s/%s",
164
+
pullOwner,
165
+
tangled.RepoPullNSID,
166
+
pullRkey,
167
+
))
168
+
}
169
+
uris = append(uris, uri)
170
+
}
171
+
return uris, nil
172
+
}
173
+
174
+
func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error {
175
+
err := deleteReferences(tx, fromAt)
176
+
if err != nil {
177
+
return fmt.Errorf("delete old reference_links: %w", err)
178
+
}
179
+
if len(references) == 0 {
180
+
return nil
181
+
}
182
+
183
+
values := make([]string, 0, len(references))
184
+
args := make([]any, 0, len(references)*2)
185
+
for _, ref := range references {
186
+
values = append(values, "(?, ?)")
187
+
args = append(args, fromAt, ref)
188
+
}
189
+
_, err = tx.Exec(
190
+
fmt.Sprintf(
191
+
`insert into reference_links (from_at, to_at)
192
+
values %s`,
193
+
strings.Join(values, ","),
194
+
),
195
+
args...,
196
+
)
197
+
if err != nil {
198
+
return fmt.Errorf("insert new reference_links: %w", err)
199
+
}
200
+
return nil
201
+
}
202
+
203
+
func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error {
204
+
_, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt)
205
+
return err
206
+
}
207
+
208
+
func GetReferencesAll(e Execer, filters ...filter) (map[syntax.ATURI][]syntax.ATURI, error) {
209
+
var (
210
+
conditions []string
211
+
args []any
212
+
)
213
+
for _, filter := range filters {
214
+
conditions = append(conditions, filter.Condition())
215
+
args = append(args, filter.Arg()...)
216
+
}
217
+
218
+
whereClause := ""
219
+
if conditions != nil {
220
+
whereClause = " where " + strings.Join(conditions, " and ")
221
+
}
222
+
223
+
rows, err := e.Query(
224
+
fmt.Sprintf(
225
+
`select from_at, to_at from reference_links %s`,
226
+
whereClause,
227
+
),
228
+
args...,
229
+
)
230
+
if err != nil {
231
+
return nil, fmt.Errorf("query reference_links: %w", err)
232
+
}
233
+
defer rows.Close()
234
+
235
+
result := make(map[syntax.ATURI][]syntax.ATURI)
236
+
237
+
for rows.Next() {
238
+
var from, to syntax.ATURI
239
+
if err := rows.Scan(&from, &to); err != nil {
240
+
return nil, fmt.Errorf("scan row: %w", err)
241
+
}
242
+
243
+
result[from] = append(result[from], to)
244
+
}
245
+
if err := rows.Err(); err != nil {
246
+
return nil, fmt.Errorf("iterate rows: %w", err)
247
+
}
248
+
249
+
return result, nil
250
+
}
251
+
252
+
func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) {
253
+
rows, err := e.Query(
254
+
`select from_at from reference_links
255
+
where to_at = ?`,
256
+
target,
257
+
)
258
+
if err != nil {
259
+
return nil, fmt.Errorf("query backlinks: %w", err)
260
+
}
261
+
defer rows.Close()
262
+
263
+
var (
264
+
backlinks []models.RichReferenceLink
265
+
backlinksMap = make(map[string][]syntax.ATURI)
266
+
)
267
+
for rows.Next() {
268
+
var from syntax.ATURI
269
+
if err := rows.Scan(&from); err != nil {
270
+
return nil, fmt.Errorf("scan row: %w", err)
271
+
}
272
+
nsid := from.Collection().String()
273
+
backlinksMap[nsid] = append(backlinksMap[nsid], from)
274
+
}
275
+
if err := rows.Err(); err != nil {
276
+
return nil, fmt.Errorf("iterate rows: %w", err)
277
+
}
278
+
279
+
var ls []models.RichReferenceLink
280
+
ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID])
281
+
if err != nil {
282
+
return nil, fmt.Errorf("get issue backlinks: %w", err)
283
+
}
284
+
backlinks = append(backlinks, ls...)
285
+
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID])
286
+
if err != nil {
287
+
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
288
+
}
289
+
backlinks = append(backlinks, ls...)
290
+
ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID])
291
+
if err != nil {
292
+
return nil, fmt.Errorf("get pull backlinks: %w", err)
293
+
}
294
+
backlinks = append(backlinks, ls...)
295
+
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID])
296
+
if err != nil {
297
+
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
298
+
}
299
+
backlinks = append(backlinks, ls...)
300
+
301
+
return backlinks, nil
302
+
}
303
+
304
+
func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
305
+
if len(aturis) == 0 {
306
+
return nil, nil
307
+
}
308
+
vals := make([]string, len(aturis))
309
+
args := make([]any, 0, len(aturis)*2)
310
+
for i, aturi := range aturis {
311
+
vals[i] = "(?, ?)"
312
+
did := aturi.Authority().String()
313
+
rkey := aturi.RecordKey().String()
314
+
args = append(args, did, rkey)
315
+
}
316
+
rows, err := e.Query(
317
+
fmt.Sprintf(
318
+
`select r.did, r.name, i.issue_id, i.title, i.open
319
+
from issues i
320
+
join repos r
321
+
on r.at_uri = i.repo_at
322
+
where (i.did, i.rkey) in (%s)`,
323
+
strings.Join(vals, ","),
324
+
),
325
+
args...,
326
+
)
327
+
if err != nil {
328
+
return nil, err
329
+
}
330
+
defer rows.Close()
331
+
var refLinks []models.RichReferenceLink
332
+
for rows.Next() {
333
+
var l models.RichReferenceLink
334
+
l.Kind = models.RefKindIssue
335
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
336
+
return nil, err
337
+
}
338
+
refLinks = append(refLinks, l)
339
+
}
340
+
if err := rows.Err(); err != nil {
341
+
return nil, fmt.Errorf("iterate rows: %w", err)
342
+
}
343
+
return refLinks, nil
344
+
}
345
+
346
+
func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
347
+
if len(aturis) == 0 {
348
+
return nil, nil
349
+
}
350
+
filter := FilterIn("c.at_uri", aturis)
351
+
rows, err := e.Query(
352
+
fmt.Sprintf(
353
+
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
354
+
from issue_comments c
355
+
join issues i
356
+
on i.at_uri = c.issue_at
357
+
join repos r
358
+
on r.at_uri = i.repo_at
359
+
where %s`,
360
+
filter.Condition(),
361
+
),
362
+
filter.Arg()...,
363
+
)
364
+
if err != nil {
365
+
return nil, err
366
+
}
367
+
defer rows.Close()
368
+
var refLinks []models.RichReferenceLink
369
+
for rows.Next() {
370
+
var l models.RichReferenceLink
371
+
l.Kind = models.RefKindIssue
372
+
l.CommentId = new(int)
373
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
374
+
return nil, err
375
+
}
376
+
refLinks = append(refLinks, l)
377
+
}
378
+
if err := rows.Err(); err != nil {
379
+
return nil, fmt.Errorf("iterate rows: %w", err)
380
+
}
381
+
return refLinks, nil
382
+
}
383
+
384
+
func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
385
+
if len(aturis) == 0 {
386
+
return nil, nil
387
+
}
388
+
vals := make([]string, len(aturis))
389
+
args := make([]any, 0, len(aturis)*2)
390
+
for i, aturi := range aturis {
391
+
vals[i] = "(?, ?)"
392
+
did := aturi.Authority().String()
393
+
rkey := aturi.RecordKey().String()
394
+
args = append(args, did, rkey)
395
+
}
396
+
rows, err := e.Query(
397
+
fmt.Sprintf(
398
+
`select r.did, r.name, p.pull_id, p.title, p.state
399
+
from pulls p
400
+
join repos r
401
+
on r.at_uri = p.repo_at
402
+
where (p.owner_did, p.rkey) in (%s)`,
403
+
strings.Join(vals, ","),
404
+
),
405
+
args...,
406
+
)
407
+
if err != nil {
408
+
return nil, err
409
+
}
410
+
defer rows.Close()
411
+
var refLinks []models.RichReferenceLink
412
+
for rows.Next() {
413
+
var l models.RichReferenceLink
414
+
l.Kind = models.RefKindPull
415
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
416
+
return nil, err
417
+
}
418
+
refLinks = append(refLinks, l)
419
+
}
420
+
if err := rows.Err(); err != nil {
421
+
return nil, fmt.Errorf("iterate rows: %w", err)
422
+
}
423
+
return refLinks, nil
424
+
}
425
+
426
+
func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
427
+
if len(aturis) == 0 {
428
+
return nil, nil
429
+
}
430
+
filter := FilterIn("c.comment_at", aturis)
431
+
rows, err := e.Query(
432
+
fmt.Sprintf(
433
+
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
434
+
from repos r
435
+
join pulls p
436
+
on r.at_uri = p.repo_at
437
+
join pull_comments c
438
+
on r.at_uri = c.repo_at and p.pull_id = c.pull_id
439
+
where %s`,
440
+
filter.Condition(),
441
+
),
442
+
filter.Arg()...,
443
+
)
444
+
if err != nil {
445
+
return nil, err
446
+
}
447
+
defer rows.Close()
448
+
var refLinks []models.RichReferenceLink
449
+
for rows.Next() {
450
+
var l models.RichReferenceLink
451
+
l.Kind = models.RefKindPull
452
+
l.CommentId = new(int)
453
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
454
+
return nil, err
455
+
}
456
+
refLinks = append(refLinks, l)
457
+
}
458
+
if err := rows.Err(); err != nil {
459
+
return nil, fmt.Errorf("iterate rows: %w", err)
460
+
}
461
+
return refLinks, nil
462
+
}
+14
-31
appview/db/repos.go
+14
-31
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"
16
14
)
17
-
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
15
44
16
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
17
repoMap := make(map[syntax.ATURI]*models.Repo)
···
208
180
209
181
starCountQuery := fmt.Sprintf(
210
182
`select
211
-
repo_at, count(1)
183
+
subject_at, count(1)
212
184
from stars
213
-
where repo_at in (%s)
214
-
group by repo_at`,
185
+
where subject_at in (%s)
186
+
group by subject_at`,
215
187
inClause,
216
188
)
217
189
rows, err = e.Query(starCountQuery, args...)
···
437
409
return "", err
438
410
}
439
411
return nullableSource.String, nil
412
+
}
413
+
414
+
func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) {
415
+
source, err := GetRepoSource(e, repoAt)
416
+
if source == "" || errors.Is(err, sql.ErrNoRows) {
417
+
return nil, nil
418
+
}
419
+
if err != nil {
420
+
return nil, err
421
+
}
422
+
return GetRepoByAtUri(e, source)
440
423
}
441
424
442
425
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
+39
-99
appview/db/star.go
+39
-99
appview/db/star.go
···
14
14
)
15
15
16
16
func AddStar(e Execer, star *models.Star) error {
17
-
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
17
+
query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)`
18
18
_, err := e.Exec(
19
19
query,
20
-
star.StarredByDid,
20
+
star.Did,
21
21
star.RepoAt.String(),
22
22
star.Rkey,
23
23
)
···
25
25
}
26
26
27
27
// Get a star record
28
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
28
+
func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) {
29
29
query := `
30
-
select starred_by_did, repo_at, created, rkey
30
+
select did, subject_at, created, rkey
31
31
from stars
32
-
where starred_by_did = ? and repo_at = ?`
33
-
row := e.QueryRow(query, starredByDid, repoAt)
32
+
where did = ? and subject_at = ?`
33
+
row := e.QueryRow(query, did, subjectAt)
34
34
35
35
var star models.Star
36
36
var created string
37
-
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
37
+
err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
38
38
if err != nil {
39
39
return nil, err
40
40
}
···
51
51
}
52
52
53
53
// Remove a star
54
-
func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error {
55
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt)
54
+
func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error {
55
+
_, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt)
56
56
return err
57
57
}
58
58
59
59
// Remove a star
60
-
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
61
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
60
+
func DeleteStarByRkey(e Execer, did string, rkey string) error {
61
+
_, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey)
62
62
return err
63
63
}
64
64
65
-
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
65
+
func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) {
66
66
stars := 0
67
67
err := e.QueryRow(
68
-
`select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
68
+
`select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars)
69
69
if err != nil {
70
70
return 0, err
71
71
}
···
89
89
}
90
90
91
91
query := fmt.Sprintf(`
92
-
SELECT repo_at
92
+
SELECT subject_at
93
93
FROM stars
94
-
WHERE starred_by_did = ? AND repo_at IN (%s)
94
+
WHERE did = ? AND subject_at IN (%s)
95
95
`, strings.Join(placeholders, ","))
96
96
97
97
rows, err := e.Query(query, args...)
···
118
118
return result, nil
119
119
}
120
120
121
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
122
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
121
+
func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool {
122
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt})
123
123
if err != nil {
124
124
return false
125
125
}
126
-
return statuses[repoAt.String()]
126
+
return statuses[subjectAt.String()]
127
127
}
128
128
129
129
// GetStarStatuses returns a map of repo URIs to star status for a given user
130
-
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
131
-
return getStarStatuses(e, userDid, repoAts)
130
+
func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) {
131
+
return getStarStatuses(e, userDid, subjectAts)
132
132
}
133
-
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
133
+
134
+
// GetRepoStars return a list of stars each holding target repository.
135
+
// If there isn't known repo with starred at-uri, those stars will be ignored.
136
+
func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) {
134
137
var conditions []string
135
138
var args []any
136
139
for _, filter := range filters {
···
149
152
}
150
153
151
154
repoQuery := fmt.Sprintf(
152
-
`select starred_by_did, repo_at, created, rkey
155
+
`select did, subject_at, created, rkey
153
156
from stars
154
157
%s
155
158
order by created desc
···
166
169
for rows.Next() {
167
170
var star models.Star
168
171
var created string
169
-
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
172
+
err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
170
173
if err != nil {
171
174
return nil, err
172
175
}
···
197
200
return nil, err
198
201
}
199
202
203
+
var repoStars []models.RepoStar
200
204
for _, r := range repos {
201
205
if stars, ok := starMap[string(r.RepoAt())]; ok {
202
-
for i := range stars {
203
-
stars[i].Repo = &r
206
+
for _, star := range stars {
207
+
repoStars = append(repoStars, models.RepoStar{
208
+
Star: star,
209
+
Repo: &r,
210
+
})
204
211
}
205
212
}
206
213
}
207
214
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 {
215
+
slices.SortFunc(repoStars, func(a, b models.RepoStar) int {
214
216
if a.Created.After(b.Created) {
215
217
return -1
216
218
}
···
220
222
return 0
221
223
})
222
224
223
-
return stars, nil
225
+
return repoStars, nil
224
226
}
225
227
226
228
func CountStars(e Execer, filters ...filter) (int64, error) {
···
247
249
return count, nil
248
250
}
249
251
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
252
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
313
253
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
314
254
// first, get the top repo URIs by star count from the last week
315
255
query := `
316
256
with recent_starred_repos as (
317
-
select distinct repo_at
257
+
select distinct subject_at
318
258
from stars
319
259
where created >= datetime('now', '-7 days')
320
260
),
321
261
repo_star_counts as (
322
262
select
323
-
s.repo_at,
263
+
s.subject_at,
324
264
count(*) as stars_gained_last_week
325
265
from stars s
326
-
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
266
+
join recent_starred_repos rsr on s.subject_at = rsr.subject_at
327
267
where s.created >= datetime('now', '-7 days')
328
-
group by s.repo_at
268
+
group by s.subject_at
329
269
)
330
-
select rsc.repo_at
270
+
select rsc.subject_at
331
271
from repo_star_counts rsc
332
272
order by rsc.stars_gained_last_week desc
333
273
limit 8
+3
-13
appview/db/timeline.go
+3
-13
appview/db/timeline.go
···
146
146
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
147
filters := make([]filter, 0)
148
148
if userIsFollowing != nil {
149
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
149
+
filters = append(filters, FilterIn("did", userIsFollowing))
150
150
}
151
151
152
-
stars, err := GetStars(e, limit, filters...)
152
+
stars, err := GetRepoStars(e, limit, filters...)
153
153
if err != nil {
154
154
return nil, err
155
155
}
156
156
157
-
// filter star records without a repo
158
-
n := 0
159
-
for _, s := range stars {
160
-
if s.Repo != nil {
161
-
stars[n] = s
162
-
n++
163
-
}
164
-
}
165
-
stars = stars[:n]
166
-
167
157
var repos []models.Repo
168
158
for _, s := range stars {
169
159
repos = append(repos, *s.Repo)
···
179
169
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
180
170
181
171
events = append(events, models.TimelineEvent{
182
-
Star: &s,
172
+
RepoStar: &s,
183
173
EventAt: s.Created,
184
174
IsStarred: isStarred,
185
175
StarCount: starCount,
+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) {
+1
-1
appview/indexer/notifier.go
+1
-1
appview/indexer/notifier.go
···
11
11
12
12
var _ notify.Notifier = &Indexer{}
13
13
14
-
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) {
14
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
15
15
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
16
16
l.Debug("indexing new issue")
17
17
err := ix.Issues.Index(ctx, *issue)
+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) {
+25
-8
appview/ingester.go
+25
-8
appview/ingester.go
···
121
121
return err
122
122
}
123
123
err = db.AddStar(i.Db, &models.Star{
124
-
StarredByDid: did,
125
-
RepoAt: subjectUri,
126
-
Rkey: e.Commit.RKey,
124
+
Did: did,
125
+
RepoAt: subjectUri,
126
+
Rkey: e.Commit.RKey,
127
127
})
128
128
case jmodels.CommitOperationDelete:
129
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
···
841
841
return nil
842
842
843
843
case jmodels.CommitOperationDelete:
844
+
tx, err := ddb.BeginTx(ctx, nil)
845
+
if err != nil {
846
+
l.Error("failed to begin transaction", "err", err)
847
+
return err
848
+
}
849
+
defer tx.Rollback()
850
+
844
851
if err := db.DeleteIssues(
845
-
ddb,
846
-
db.FilterEq("did", did),
847
-
db.FilterEq("rkey", rkey),
852
+
tx,
853
+
did,
854
+
rkey,
848
855
); err != nil {
849
856
l.Error("failed to delete", "err", err)
850
857
return fmt.Errorf("failed to delete issue record: %w", err)
858
+
}
859
+
if err := tx.Commit(); err != nil {
860
+
l.Error("failed to commit txn", "err", err)
861
+
return err
851
862
}
852
863
853
864
return nil
···
888
899
return fmt.Errorf("failed to validate comment: %w", err)
889
900
}
890
901
891
-
_, err = db.AddIssueComment(ddb, *comment)
902
+
tx, err := ddb.Begin()
903
+
if err != nil {
904
+
return fmt.Errorf("failed to start transaction: %w", err)
905
+
}
906
+
defer tx.Rollback()
907
+
908
+
_, err = db.AddIssueComment(tx, *comment)
892
909
if err != nil {
893
910
return fmt.Errorf("failed to create issue comment: %w", err)
894
911
}
895
912
896
-
return nil
913
+
return tx.Commit()
897
914
898
915
case jmodels.CommitOperationDelete:
899
916
if err := db.DeleteIssueComments(
+147
-101
appview/issues/issues.go
+147
-101
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"
···
24
23
"tangled.org/core/appview/notify"
25
24
"tangled.org/core/appview/oauth"
26
25
"tangled.org/core/appview/pages"
26
+
"tangled.org/core/appview/pages/repoinfo"
27
27
"tangled.org/core/appview/pagination"
28
+
"tangled.org/core/appview/refresolver"
28
29
"tangled.org/core/appview/reporesolver"
29
30
"tangled.org/core/appview/validator"
30
31
"tangled.org/core/idresolver"
32
+
"tangled.org/core/rbac"
31
33
"tangled.org/core/tid"
32
34
)
33
35
34
36
type Issues struct {
35
37
oauth *oauth.OAuth
36
38
repoResolver *reporesolver.RepoResolver
39
+
enforcer *rbac.Enforcer
37
40
pages *pages.Pages
38
41
idResolver *idresolver.Resolver
42
+
refResolver *refresolver.Resolver
39
43
db *db.DB
40
44
config *config.Config
41
45
notifier notify.Notifier
···
47
51
func New(
48
52
oauth *oauth.OAuth,
49
53
repoResolver *reporesolver.RepoResolver,
54
+
enforcer *rbac.Enforcer,
50
55
pages *pages.Pages,
51
56
idResolver *idresolver.Resolver,
57
+
refResolver *refresolver.Resolver,
52
58
db *db.DB,
53
59
config *config.Config,
54
60
notifier notify.Notifier,
···
59
65
return &Issues{
60
66
oauth: oauth,
61
67
repoResolver: repoResolver,
68
+
enforcer: enforcer,
62
69
pages: pages,
63
70
idResolver: idResolver,
71
+
refResolver: refResolver,
64
72
db: db,
65
73
config: config,
66
74
notifier: notifier,
···
96
104
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
97
105
}
98
106
107
+
backlinks, err := db.GetBacklinks(rp.db, issue.AtUri())
108
+
if err != nil {
109
+
l.Error("failed to fetch backlinks", "err", err)
110
+
rp.pages.Error503(w)
111
+
return
112
+
}
113
+
99
114
labelDefs, err := db.GetLabelDefinitions(
100
115
rp.db,
101
-
db.FilterIn("at_uri", f.Repo.Labels),
116
+
db.FilterIn("at_uri", f.Labels),
102
117
db.FilterContains("scope", tangled.RepoIssueNSID),
103
118
)
104
119
if err != nil {
···
114
129
115
130
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
116
131
LoggedInUser: user,
117
-
RepoInfo: f.RepoInfo(user),
132
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
118
133
Issue: issue,
119
134
CommentList: issue.CommentList(),
135
+
Backlinks: backlinks,
120
136
OrderedReactionKinds: models.OrderedReactionKinds,
121
137
Reactions: reactionMap,
122
138
UserReacted: userReactions,
···
127
143
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
128
144
l := rp.logger.With("handler", "EditIssue")
129
145
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
146
136
147
issue, ok := r.Context().Value("issue").(*models.Issue)
137
148
if !ok {
···
144
155
case http.MethodGet:
145
156
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
146
157
LoggedInUser: user,
147
-
RepoInfo: f.RepoInfo(user),
158
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
148
159
Issue: issue,
149
160
})
150
161
case http.MethodPost:
···
152
163
newIssue := issue
153
164
newIssue.Title = r.FormValue("title")
154
165
newIssue.Body = r.FormValue("body")
166
+
newIssue.Mentions, newIssue.References = rp.refResolver.Resolve(r.Context(), newIssue.Body)
155
167
156
168
if err := rp.validator.ValidateIssue(newIssue); err != nil {
157
169
l.Error("validation error", "err", err)
···
221
233
l := rp.logger.With("handler", "DeleteIssue")
222
234
noticeId := "issue-actions-error"
223
235
224
-
user := rp.oauth.GetUser(r)
225
-
226
236
f, err := rp.repoResolver.Resolve(r)
227
237
if err != nil {
228
238
l.Error("failed to get repo and knot", "err", err)
···
237
247
}
238
248
l = l.With("did", issue.Did, "rkey", issue.Rkey)
239
249
250
+
tx, err := rp.db.Begin()
251
+
if err != nil {
252
+
l.Error("failed to start transaction", "err", err)
253
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
254
+
return
255
+
}
256
+
defer tx.Rollback()
257
+
240
258
// delete from PDS
241
259
client, err := rp.oauth.AuthorizedClient(r)
242
260
if err != nil {
···
257
275
}
258
276
259
277
// delete from db
260
-
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
278
+
if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
261
279
l.Error("failed to delete issue", "err", err)
262
280
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
263
281
return
264
282
}
283
+
tx.Commit()
265
284
266
285
rp.notifier.DeleteIssue(r.Context(), issue)
267
286
268
287
// return to all issues page
269
-
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
288
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
289
+
rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")
270
290
}
271
291
272
292
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
285
305
return
286
306
}
287
307
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
-
})
308
+
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
309
+
isRepoOwner := roles.IsOwner()
310
+
isCollaborator := roles.IsCollaborator()
295
311
isIssueOwner := user.Did == issue.Did
296
312
297
313
// TODO: make this more granular
298
-
if isIssueOwner || isCollaborator {
314
+
if isIssueOwner || isRepoOwner || isCollaborator {
299
315
err = db.CloseIssues(
300
316
rp.db,
301
317
db.FilterEq("id", issue.Id),
···
311
327
// notify about the issue closure
312
328
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
313
329
314
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
330
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
331
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
315
332
return
316
333
} else {
317
334
l.Error("user is not permitted to close issue")
···
336
353
return
337
354
}
338
355
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
-
})
356
+
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
357
+
isRepoOwner := roles.IsOwner()
358
+
isCollaborator := roles.IsCollaborator()
346
359
isIssueOwner := user.Did == issue.Did
347
360
348
-
if isCollaborator || isIssueOwner {
361
+
if isCollaborator || isRepoOwner || isIssueOwner {
349
362
err := db.ReopenIssues(
350
363
rp.db,
351
364
db.FilterEq("id", issue.Id),
···
361
374
// notify about the issue reopen
362
375
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
363
376
364
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
377
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
378
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
365
379
return
366
380
} else {
367
381
l.Error("user is not the owner of the repo")
···
397
411
if replyToUri != "" {
398
412
replyTo = &replyToUri
399
413
}
414
+
415
+
mentions, references := rp.refResolver.Resolve(r.Context(), body)
400
416
401
417
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(),
418
+
Did: user.Did,
419
+
Rkey: tid.TID(),
420
+
IssueAt: issue.AtUri().String(),
421
+
ReplyTo: replyTo,
422
+
Body: body,
423
+
Created: time.Now(),
424
+
Mentions: mentions,
425
+
References: references,
408
426
}
409
427
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
410
428
l.Error("failed to validate comment", "err", err)
···
441
459
}
442
460
}()
443
461
444
-
commentId, err := db.AddIssueComment(rp.db, comment)
462
+
tx, err := rp.db.Begin()
463
+
if err != nil {
464
+
l.Error("failed to start transaction", "err", err)
465
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
466
+
return
467
+
}
468
+
defer tx.Rollback()
469
+
470
+
commentId, err := db.AddIssueComment(tx, comment)
445
471
if err != nil {
446
472
l.Error("failed to create comment", "err", err)
447
473
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
448
474
return
449
475
}
476
+
err = tx.Commit()
477
+
if err != nil {
478
+
l.Error("failed to commit transaction", "err", err)
479
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
480
+
return
481
+
}
450
482
451
483
// reset atUri to make rollback a no-op
452
484
atUri = ""
453
485
454
486
// notify about the new comment
455
487
comment.Id = commentId
456
-
rp.notifier.NewIssueComment(r.Context(), &comment)
488
+
489
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
457
490
458
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
491
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
492
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
459
493
}
460
494
461
495
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
462
496
l := rp.logger.With("handler", "IssueComment")
463
497
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
498
470
499
issue, ok := r.Context().Value("issue").(*models.Issue)
471
500
if !ok {
···
493
522
494
523
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
495
524
LoggedInUser: user,
496
-
RepoInfo: f.RepoInfo(user),
525
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
497
526
Issue: issue,
498
527
Comment: &comment,
499
528
})
···
502
531
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
503
532
l := rp.logger.With("handler", "EditIssueComment")
504
533
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
534
511
535
issue, ok := r.Context().Value("issue").(*models.Issue)
512
536
if !ok {
···
542
566
case http.MethodGet:
543
567
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
544
568
LoggedInUser: user,
545
-
RepoInfo: f.RepoInfo(user),
569
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
546
570
Issue: issue,
547
571
Comment: &comment,
548
572
})
···
560
584
newComment := comment
561
585
newComment.Body = newBody
562
586
newComment.Edited = &now
587
+
newComment.Mentions, newComment.References = rp.refResolver.Resolve(r.Context(), newBody)
588
+
563
589
record := newComment.AsRecord()
564
590
565
-
_, err = db.AddIssueComment(rp.db, newComment)
591
+
tx, err := rp.db.Begin()
592
+
if err != nil {
593
+
l.Error("failed to start transaction", "err", err)
594
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
595
+
return
596
+
}
597
+
defer tx.Rollback()
598
+
599
+
_, err = db.AddIssueComment(tx, newComment)
566
600
if err != nil {
567
601
l.Error("failed to perferom update-description query", "err", err)
568
602
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
569
603
return
570
604
}
605
+
tx.Commit()
571
606
572
607
// rkey is optional, it was introduced later
573
608
if newComment.Rkey != "" {
···
596
631
// return new comment body with htmx
597
632
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
598
633
LoggedInUser: user,
599
-
RepoInfo: f.RepoInfo(user),
634
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
600
635
Issue: issue,
601
636
Comment: &newComment,
602
637
})
···
606
641
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
607
642
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
608
643
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
644
615
645
issue, ok := r.Context().Value("issue").(*models.Issue)
616
646
if !ok {
···
638
668
639
669
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
640
670
LoggedInUser: user,
641
-
RepoInfo: f.RepoInfo(user),
671
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
642
672
Issue: issue,
643
673
Comment: &comment,
644
674
})
···
647
677
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
648
678
l := rp.logger.With("handler", "ReplyIssueComment")
649
679
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
680
656
681
issue, ok := r.Context().Value("issue").(*models.Issue)
657
682
if !ok {
···
679
704
680
705
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
681
706
LoggedInUser: user,
682
-
RepoInfo: f.RepoInfo(user),
707
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
683
708
Issue: issue,
684
709
Comment: &comment,
685
710
})
···
688
713
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
689
714
l := rp.logger.With("handler", "DeleteIssueComment")
690
715
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
716
697
717
issue, ok := r.Context().Value("issue").(*models.Issue)
698
718
if !ok {
···
763
783
// htmx fragment of comment after deletion
764
784
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
765
785
LoggedInUser: user,
766
-
RepoInfo: f.RepoInfo(user),
786
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
767
787
Issue: issue,
768
788
Comment: &comment,
769
789
})
···
793
813
return
794
814
}
795
815
816
+
totalIssues := 0
817
+
if isOpen {
818
+
totalIssues = f.RepoStats.IssueCount.Open
819
+
} else {
820
+
totalIssues = f.RepoStats.IssueCount.Closed
821
+
}
822
+
796
823
keyword := params.Get("q")
797
824
798
-
var ids []int64
825
+
var issues []models.Issue
799
826
searchOpts := models.IssueSearchOptions{
800
827
Keyword: keyword,
801
828
RepoAt: f.RepoAt().String(),
···
808
835
l.Error("failed to search for issues", "err", err)
809
836
return
810
837
}
811
-
ids = res.Hits
812
-
l.Debug("searched issues with indexer", "count", len(ids))
813
-
} else {
814
-
ids, err = db.GetIssueIDs(rp.db, searchOpts)
838
+
l.Debug("searched issues with indexer", "count", len(res.Hits))
839
+
totalIssues = int(res.Total)
840
+
841
+
issues, err = db.GetIssues(
842
+
rp.db,
843
+
db.FilterIn("id", res.Hits),
844
+
)
815
845
if err != nil {
816
-
l.Error("failed to search for issues", "err", err)
846
+
l.Error("failed to get issues", "err", err)
847
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
817
848
return
818
849
}
819
-
l.Debug("indexed all issues from the db", "count", len(ids))
820
-
}
821
850
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
851
+
} else {
852
+
openInt := 0
853
+
if isOpen {
854
+
openInt = 1
855
+
}
856
+
issues, err = db.GetIssuesPaginated(
857
+
rp.db,
858
+
page,
859
+
db.FilterEq("repo_at", f.RepoAt()),
860
+
db.FilterEq("open", openInt),
861
+
)
862
+
if err != nil {
863
+
l.Error("failed to get issues", "err", err)
864
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
865
+
return
866
+
}
830
867
}
831
868
832
869
labelDefs, err := db.GetLabelDefinitions(
833
870
rp.db,
834
-
db.FilterIn("at_uri", f.Repo.Labels),
871
+
db.FilterIn("at_uri", f.Labels),
835
872
db.FilterContains("scope", tangled.RepoIssueNSID),
836
873
)
837
874
if err != nil {
···
847
884
848
885
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
849
886
LoggedInUser: rp.oauth.GetUser(r),
850
-
RepoInfo: f.RepoInfo(user),
887
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
851
888
Issues: issues,
889
+
IssueCount: totalIssues,
852
890
LabelDefs: defs,
853
891
FilteringByOpen: isOpen,
854
892
FilterQuery: keyword,
···
870
908
case http.MethodGet:
871
909
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
872
910
LoggedInUser: user,
873
-
RepoInfo: f.RepoInfo(user),
911
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
874
912
})
875
913
case http.MethodPost:
914
+
body := r.FormValue("body")
915
+
mentions, references := rp.refResolver.Resolve(r.Context(), body)
916
+
876
917
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,
918
+
RepoAt: f.RepoAt(),
919
+
Rkey: tid.TID(),
920
+
Title: r.FormValue("title"),
921
+
Body: body,
922
+
Open: true,
923
+
Did: user.Did,
924
+
Created: time.Now(),
925
+
Mentions: mentions,
926
+
References: references,
927
+
Repo: f,
885
928
}
886
929
887
930
if err := rp.validator.ValidateIssue(issue); err != nil {
···
948
991
949
992
// everything is successful, do not rollback the atproto record
950
993
atUri = ""
951
-
rp.notifier.NewIssue(r.Context(), issue)
952
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
994
+
995
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
996
+
997
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
998
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
953
999
return
954
1000
}
955
1001
}
+3
-3
appview/issues/opengraph.go
+3
-3
appview/issues/opengraph.go
···
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)
+18
-1
appview/knots/knots.go
+18
-1
appview/knots/knots.go
···
39
39
Knotstream *eventconsumer.Consumer
40
40
}
41
41
42
+
type tab = map[string]any
43
+
44
+
var (
45
+
knotsTabs []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
+
42
55
func (k *Knots) Router() http.Handler {
43
56
r := chi.NewRouter()
44
57
···
70
83
k.Pages.Knots(w, pages.KnotsParams{
71
84
LoggedInUser: user,
72
85
Registrations: registrations,
86
+
Tabs: knotsTabs,
87
+
Tab: "knots",
73
88
})
74
89
}
75
90
···
132
147
Members: members,
133
148
Repos: repoMap,
134
149
IsOwner: true,
150
+
Tabs: knotsTabs,
151
+
Tab: "knots",
135
152
})
136
153
}
137
154
···
596
613
}
597
614
598
615
// success
599
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
616
+
k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain))
600
617
}
601
618
602
619
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+4
-2
appview/middleware/middleware.go
+4
-2
appview/middleware/middleware.go
···
164
164
ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
165
165
if err != nil || !ok {
166
166
// 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())
167
+
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo())
168
168
http.Error(w, "Forbiden", http.StatusUnauthorized)
169
169
return
170
170
}
···
206
206
return func(next http.Handler) http.Handler {
207
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
209
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
212
if !ok {
211
213
log.Println("malformed middleware")
···
325
327
return
326
328
}
327
329
328
-
fullName := f.OwnerHandle() + "/" + f.Name
330
+
fullName := reporesolver.GetBaseRepoPath(r, f)
329
331
330
332
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
331
333
if r.URL.Query().Get("go-get") == "1" {
+70
-34
appview/models/issue.go
+70
-34
appview/models/issue.go
···
10
10
)
11
11
12
12
type Issue struct {
13
-
Id int64
14
-
Did string
15
-
Rkey string
16
-
RepoAt syntax.ATURI
17
-
IssueId int
18
-
Created time.Time
19
-
Edited *time.Time
20
-
Deleted *time.Time
21
-
Title string
22
-
Body string
23
-
Open bool
13
+
Id int64
14
+
Did string
15
+
Rkey string
16
+
RepoAt syntax.ATURI
17
+
IssueId int
18
+
Created time.Time
19
+
Edited *time.Time
20
+
Deleted *time.Time
21
+
Title string
22
+
Body string
23
+
Open bool
24
+
Mentions []syntax.DID
25
+
References []syntax.ATURI
24
26
25
27
// optionally, populate this when querying for reverse mappings
26
28
// like comment counts, parent repo etc.
···
34
36
}
35
37
36
38
func (i *Issue) AsRecord() tangled.RepoIssue {
39
+
mentions := make([]string, len(i.Mentions))
40
+
for i, did := range i.Mentions {
41
+
mentions[i] = string(did)
42
+
}
43
+
references := make([]string, len(i.References))
44
+
for i, uri := range i.References {
45
+
references[i] = string(uri)
46
+
}
37
47
return tangled.RepoIssue{
38
-
Repo: i.RepoAt.String(),
39
-
Title: i.Title,
40
-
Body: &i.Body,
41
-
CreatedAt: i.Created.Format(time.RFC3339),
48
+
Repo: i.RepoAt.String(),
49
+
Title: i.Title,
50
+
Body: &i.Body,
51
+
Mentions: mentions,
52
+
References: references,
53
+
CreatedAt: i.Created.Format(time.RFC3339),
42
54
}
43
55
}
44
56
···
161
173
}
162
174
163
175
type IssueComment struct {
164
-
Id int64
165
-
Did string
166
-
Rkey string
167
-
IssueAt string
168
-
ReplyTo *string
169
-
Body string
170
-
Created time.Time
171
-
Edited *time.Time
172
-
Deleted *time.Time
176
+
Id int64
177
+
Did string
178
+
Rkey string
179
+
IssueAt string
180
+
ReplyTo *string
181
+
Body string
182
+
Created time.Time
183
+
Edited *time.Time
184
+
Deleted *time.Time
185
+
Mentions []syntax.DID
186
+
References []syntax.ATURI
173
187
}
174
188
175
189
func (i *IssueComment) AtUri() syntax.ATURI {
···
177
191
}
178
192
179
193
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
194
+
mentions := make([]string, len(i.Mentions))
195
+
for i, did := range i.Mentions {
196
+
mentions[i] = string(did)
197
+
}
198
+
references := make([]string, len(i.References))
199
+
for i, uri := range i.References {
200
+
references[i] = string(uri)
201
+
}
180
202
return tangled.RepoIssueComment{
181
-
Body: i.Body,
182
-
Issue: i.IssueAt,
183
-
CreatedAt: i.Created.Format(time.RFC3339),
184
-
ReplyTo: i.ReplyTo,
203
+
Body: i.Body,
204
+
Issue: i.IssueAt,
205
+
CreatedAt: i.Created.Format(time.RFC3339),
206
+
ReplyTo: i.ReplyTo,
207
+
Mentions: mentions,
208
+
References: references,
185
209
}
186
210
}
187
211
···
205
229
return nil, err
206
230
}
207
231
232
+
i := record
233
+
mentions := make([]syntax.DID, len(record.Mentions))
234
+
for i, did := range record.Mentions {
235
+
mentions[i] = syntax.DID(did)
236
+
}
237
+
references := make([]syntax.ATURI, len(record.References))
238
+
for i, uri := range i.References {
239
+
references[i] = syntax.ATURI(uri)
240
+
}
241
+
208
242
comment := IssueComment{
209
-
Did: ownerDid,
210
-
Rkey: rkey,
211
-
Body: record.Body,
212
-
IssueAt: record.Issue,
213
-
ReplyTo: record.ReplyTo,
214
-
Created: created,
243
+
Did: ownerDid,
244
+
Rkey: rkey,
245
+
Body: record.Body,
246
+
IssueAt: record.Issue,
247
+
ReplyTo: record.ReplyTo,
248
+
Created: created,
249
+
Mentions: mentions,
250
+
References: references,
215
251
}
216
252
217
253
return &comment, nil
+25
-43
appview/models/label.go
+25
-43
appview/models/label.go
···
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"tangled.org/core/api/tangled"
17
-
"tangled.org/core/consts"
18
17
"tangled.org/core/idresolver"
19
18
)
20
19
···
461
460
return result
462
461
}
463
462
464
-
var (
465
-
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
-
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
-
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
-
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
-
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
-
)
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
+
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
471
466
472
-
func DefaultLabelDefs() []string {
473
-
return []string{
474
-
LabelWontfix,
475
-
LabelDuplicate,
476
-
LabelAssignee,
477
-
LabelGoodFirstIssue,
478
-
LabelDocumentation,
479
-
}
480
-
}
467
+
for _, dl := range aturis {
468
+
atUri, err := syntax.ParseATURI(dl)
469
+
if err != nil {
470
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err)
471
+
}
472
+
if atUri.Collection() != tangled.LabelDefinitionNSID {
473
+
return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri)
474
+
}
481
475
482
-
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
483
-
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
484
-
if err != nil {
485
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
486
-
}
487
-
pdsEndpoint := resolved.PDSEndpoint()
488
-
if pdsEndpoint == "" {
489
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
490
-
}
491
-
client := &xrpc.Client{
492
-
Host: pdsEndpoint,
493
-
}
476
+
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
477
+
if err != nil {
478
+
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
479
+
}
494
480
495
-
var labelDefs []LabelDefinition
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
483
+
}
496
484
497
-
for _, dl := range DefaultLabelDefs() {
498
-
atUri := syntax.ATURI(dl)
499
-
parsedUri, err := syntax.ParseATURI(string(atUri))
500
-
if err != nil {
501
-
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
502
-
}
503
485
record, err := atproto.RepoGetRecord(
504
-
context.Background(),
505
-
client,
486
+
ctx,
487
+
&xrpcc,
506
488
"",
507
-
parsedUri.Collection().String(),
508
-
parsedUri.Authority().String(),
509
-
parsedUri.RecordKey().String(),
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
510
492
)
511
493
if err != nil {
512
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
526
508
}
527
509
528
510
labelDef, err := LabelDefinitionFromRecord(
529
-
parsedUri.Authority().String(),
530
-
parsedUri.RecordKey().String(),
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
531
513
labelRecord,
532
514
)
533
515
if err != nil {
+7
appview/models/notifications.go
+7
appview/models/notifications.go
···
20
20
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
22
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
23
24
)
24
25
25
26
type Notification struct {
···
63
64
return "git-pull-request-create"
64
65
case NotificationTypeFollowed:
65
66
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
66
69
default:
67
70
return ""
68
71
}
···
84
87
PullCreated bool
85
88
PullCommented bool
86
89
Followed bool
90
+
UserMentioned bool
87
91
PullMerged bool
88
92
IssueClosed bool
89
93
EmailNotifications bool
···
113
117
return prefs.PullCreated // same pref for now
114
118
case NotificationTypeFollowed:
115
119
return prefs.Followed
120
+
case NotificationTypeUserMentioned:
121
+
return prefs.UserMentioned
116
122
default:
117
123
return false
118
124
}
···
127
133
PullCreated: true,
128
134
PullCommented: true,
129
135
Followed: true,
136
+
UserMentioned: true,
130
137
PullMerged: true,
131
138
IssueClosed: true,
132
139
EmailNotifications: false,
+3
-1
appview/models/profile.go
+3
-1
appview/models/profile.go
···
111
111
}
112
112
113
113
type ByMonth struct {
114
+
Commits int
114
115
RepoEvents []RepoEvent
115
116
IssueEvents IssueEvents
116
117
PullEvents PullEvents
···
119
120
func (b ByMonth) IsEmpty() bool {
120
121
return len(b.RepoEvents) == 0 &&
121
122
len(b.IssueEvents.Items) == 0 &&
122
-
len(b.PullEvents.Items) == 0
123
+
len(b.PullEvents.Items) == 0 &&
124
+
b.Commits == 0
123
125
}
124
126
125
127
type IssueEvents struct {
+41
-3
appview/models/pull.go
+41
-3
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,
···
146
158
147
159
// content
148
160
Body string
161
+
162
+
// meta
163
+
Mentions []syntax.DID
164
+
References []syntax.ATURI
149
165
150
166
// meta
151
167
Created time.Time
152
168
}
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
+
// }
153
191
154
192
func (p *Pull) LastRoundNumber() int {
155
193
return len(p.Submissions) - 1
+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
+
}
+47
appview/models/repo.go
+47
appview/models/repo.go
···
104
104
Repo *Repo
105
105
Issues []Issue
106
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
+48
-8
appview/notify/db/db.go
+48
-8
appview/notify/db/db.go
···
7
7
"slices"
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/api/tangled"
10
11
"tangled.org/core/appview/db"
11
12
"tangled.org/core/appview/models"
12
13
"tangled.org/core/appview/notify"
13
14
"tangled.org/core/idresolver"
15
+
)
16
+
17
+
const (
18
+
maxMentions = 5
14
19
)
15
20
16
21
type databaseNotifier struct {
···
32
37
}
33
38
34
39
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
40
+
if star.RepoAt.Collection().String() != tangled.RepoNSID {
41
+
// skip string stars for now
42
+
return
43
+
}
35
44
var err error
36
45
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
37
46
if err != nil {
···
39
48
return
40
49
}
41
50
42
-
actorDid := syntax.DID(star.StarredByDid)
51
+
actorDid := syntax.DID(star.Did)
43
52
recipients := []syntax.DID{syntax.DID(repo.Did)}
44
53
eventType := models.NotificationTypeRepoStarred
45
54
entityType := "repo"
···
64
73
// no-op
65
74
}
66
75
67
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
76
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
68
77
69
78
// build the recipients list
70
79
// - owner of the repo
···
81
90
}
82
91
83
92
actorDid := syntax.DID(issue.Did)
84
-
eventType := models.NotificationTypeIssueCreated
85
93
entityType := "issue"
86
94
entityId := issue.AtUri().String()
87
95
repoId := &issue.Repo.Id
···
91
99
n.notifyEvent(
92
100
actorDid,
93
101
recipients,
94
-
eventType,
102
+
models.NotificationTypeIssueCreated,
103
+
entityType,
104
+
entityId,
105
+
repoId,
106
+
issueId,
107
+
pullId,
108
+
)
109
+
n.notifyEvent(
110
+
actorDid,
111
+
mentions,
112
+
models.NotificationTypeUserMentioned,
95
113
entityType,
96
114
entityId,
97
115
repoId,
···
100
118
)
101
119
}
102
120
103
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
121
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
104
122
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
105
123
if err != nil {
106
124
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
132
150
}
133
151
134
152
actorDid := syntax.DID(comment.Did)
135
-
eventType := models.NotificationTypeIssueCommented
136
153
entityType := "issue"
137
154
entityId := issue.AtUri().String()
138
155
repoId := &issue.Repo.Id
···
142
159
n.notifyEvent(
143
160
actorDid,
144
161
recipients,
145
-
eventType,
162
+
models.NotificationTypeIssueCommented,
163
+
entityType,
164
+
entityId,
165
+
repoId,
166
+
issueId,
167
+
pullId,
168
+
)
169
+
n.notifyEvent(
170
+
actorDid,
171
+
mentions,
172
+
models.NotificationTypeUserMentioned,
146
173
entityType,
147
174
entityId,
148
175
repoId,
···
221
248
)
222
249
}
223
250
224
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
251
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
225
252
pull, err := db.GetPull(n.db,
226
253
syntax.ATURI(comment.RepoAt),
227
254
comment.PullId,
···
259
286
actorDid,
260
287
recipients,
261
288
eventType,
289
+
entityType,
290
+
entityId,
291
+
repoId,
292
+
issueId,
293
+
pullId,
294
+
)
295
+
n.notifyEvent(
296
+
actorDid,
297
+
mentions,
298
+
models.NotificationTypeUserMentioned,
262
299
entityType,
263
300
entityId,
264
301
repoId,
···
393
430
issueId *int64,
394
431
pullId *int64,
395
432
) {
433
+
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
434
+
recipients = recipients[:maxMentions]
435
+
}
396
436
recipientSet := make(map[syntax.DID]struct{})
397
437
for _, did := range recipients {
398
438
// everybody except actor themselves
+6
-6
appview/notify/merged_notifier.go
+6
-6
appview/notify/merged_notifier.go
···
54
54
m.fanout("DeleteStar", ctx, star)
55
55
}
56
56
57
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
58
-
m.fanout("NewIssue", ctx, issue)
57
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
+
m.fanout("NewIssue", ctx, issue, mentions)
59
59
}
60
60
61
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
62
-
m.fanout("NewIssueComment", ctx, comment)
61
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
+
m.fanout("NewIssueComment", ctx, comment, mentions)
63
63
}
64
64
65
65
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
···
82
82
m.fanout("NewPull", ctx, pull)
83
83
}
84
84
85
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
86
-
m.fanout("NewPullComment", ctx, comment)
85
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
+
m.fanout("NewPullComment", ctx, comment, mentions)
87
87
}
88
88
89
89
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+9
-7
appview/notify/notifier.go
+9
-7
appview/notify/notifier.go
···
13
13
NewStar(ctx context.Context, star *models.Star)
14
14
DeleteStar(ctx context.Context, star *models.Star)
15
15
16
-
NewIssue(ctx context.Context, issue *models.Issue)
17
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
16
+
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
18
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
19
DeleteIssue(ctx context.Context, issue *models.Issue)
20
20
···
22
22
DeleteFollow(ctx context.Context, follow *models.Follow)
23
23
24
24
NewPull(ctx context.Context, pull *models.Pull)
25
-
NewPullComment(ctx context.Context, comment *models.PullComment)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
26
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
27
27
28
28
UpdateProfile(ctx context.Context, profile *models.Profile)
···
42
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
44
45
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
46
-
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
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
+
}
47
48
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
48
49
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
49
50
50
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
51
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
52
53
53
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
54
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
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
+
}
55
57
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
56
58
57
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+10
-7
appview/notify/posthog/notifier.go
+10
-7
appview/notify/posthog/notifier.go
···
37
37
38
38
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
39
39
err := n.client.Enqueue(posthog.Capture{
40
-
DistinctId: star.StarredByDid,
40
+
DistinctId: star.Did,
41
41
Event: "star",
42
42
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
43
43
})
···
48
48
49
49
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
50
50
err := n.client.Enqueue(posthog.Capture{
51
-
DistinctId: star.StarredByDid,
51
+
DistinctId: star.Did,
52
52
Event: "unstar",
53
53
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
54
54
})
···
57
57
}
58
58
}
59
59
60
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
61
61
err := n.client.Enqueue(posthog.Capture{
62
62
DistinctId: issue.Did,
63
63
Event: "new_issue",
64
64
Properties: posthog.Properties{
65
65
"repo_at": issue.RepoAt.String(),
66
66
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
67
68
},
68
69
})
69
70
if err != nil {
···
85
86
}
86
87
}
87
88
88
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
89
90
err := n.client.Enqueue(posthog.Capture{
90
91
DistinctId: comment.OwnerDid,
91
92
Event: "new_pull_comment",
92
93
Properties: posthog.Properties{
93
-
"repo_at": comment.RepoAt,
94
-
"pull_id": comment.PullId,
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
95
97
},
96
98
})
97
99
if err != nil {
···
178
180
}
179
181
}
180
182
181
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
182
184
err := n.client.Enqueue(posthog.Capture{
183
185
DistinctId: comment.Did,
184
186
Event: "new_issue_comment",
185
187
Properties: posthog.Properties{
186
188
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
187
190
},
188
191
})
189
192
if err != nil {
+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
}
+80
-9
appview/pages/funcmap.go
+80
-9
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
20
-
"github.com/bluesky-social/indigo/atproto/syntax"
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"
21
25
"github.com/dustin/go-humanize"
22
26
"github.com/go-enry/go-enry/v2"
27
+
"github.com/yuin/goldmark"
23
28
"tangled.org/core/appview/filetree"
29
+
"tangled.org/core/appview/models"
24
30
"tangled.org/core/appview/pages/markup"
25
31
"tangled.org/core/crypto"
26
32
)
···
66
72
67
73
return identity.Handle.String()
68
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()
85
+
},
69
86
"truncateAt30": func(s string) string {
70
87
if len(s) <= 30 {
71
88
return s
···
94
111
"sub": func(a, b int) int {
95
112
return a - b
96
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
122
+
},
97
123
"f64": func(a int) float64 {
98
124
return float64(a)
99
125
},
···
125
151
}
126
152
127
153
return b
128
-
},
129
-
"didOrHandle": func(did, handle string) string {
130
-
if handle != "" && handle != syntax.HandleInvalid.String() {
131
-
return handle
132
-
} else {
133
-
return did
134
-
}
135
154
},
136
155
"assoc": func(values ...string) ([][]string, error) {
137
156
if len(values)%2 != 0 {
···
242
261
},
243
262
"description": func(text string) template.HTML {
244
263
p.rctx.RendererType = markup.RendererTypeDefault
245
-
htmlString := p.rctx.RenderMarkdown(text)
264
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
246
265
sanitized := p.rctx.SanitizeDescription(htmlString)
247
266
return template.HTML(sanitized)
248
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
+
},
249
304
"trimUriScheme": func(text string) string {
250
305
text = strings.TrimPrefix(text, "https://")
251
306
text = strings.TrimPrefix(text, "http://")
···
328
383
}
329
384
}
330
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
+
331
400
func (p *Pages) AvatarUrl(handle, size string) string {
332
401
handle = strings.TrimPrefix(handle, "@")
402
+
403
+
handle = p.resolveDid(handle)
333
404
334
405
secret := p.avatar.SharedSecret
335
406
h := hmac.New(sha256.New, []byte(secret))
+111
appview/pages/markup/extension/atlink.go
+111
appview/pages/markup/extension/atlink.go
···
1
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
2
+
3
+
package extension
4
+
5
+
import (
6
+
"regexp"
7
+
8
+
"github.com/yuin/goldmark"
9
+
"github.com/yuin/goldmark/ast"
10
+
"github.com/yuin/goldmark/parser"
11
+
"github.com/yuin/goldmark/renderer"
12
+
"github.com/yuin/goldmark/renderer/html"
13
+
"github.com/yuin/goldmark/text"
14
+
"github.com/yuin/goldmark/util"
15
+
)
16
+
17
+
// An AtNode struct represents an AtNode
18
+
type AtNode struct {
19
+
Handle string
20
+
ast.BaseInline
21
+
}
22
+
23
+
var _ ast.Node = &AtNode{}
24
+
25
+
// Dump implements Node.Dump.
26
+
func (n *AtNode) Dump(source []byte, level int) {
27
+
ast.DumpHelper(n, source, level, nil, nil)
28
+
}
29
+
30
+
// KindAt is a NodeKind of the At node.
31
+
var KindAt = ast.NewNodeKind("At")
32
+
33
+
// Kind implements Node.Kind.
34
+
func (n *AtNode) Kind() ast.NodeKind {
35
+
return KindAt
36
+
}
37
+
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
+
40
+
type atParser struct{}
41
+
42
+
// NewAtParser return a new InlineParser that parses
43
+
// at expressions.
44
+
func NewAtParser() parser.InlineParser {
45
+
return &atParser{}
46
+
}
47
+
48
+
func (s *atParser) Trigger() []byte {
49
+
return []byte{'@'}
50
+
}
51
+
52
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
53
+
line, segment := block.PeekLine()
54
+
m := atRegexp.FindSubmatchIndex(line)
55
+
if m == nil {
56
+
return nil
57
+
}
58
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
59
+
block.Advance(m[1])
60
+
node := &AtNode{}
61
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
62
+
node.Handle = string(atSegment.Value(block.Source())[1:])
63
+
return node
64
+
}
65
+
66
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
67
+
// renders At nodes.
68
+
type atHtmlRenderer struct {
69
+
html.Config
70
+
}
71
+
72
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
73
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
74
+
r := &atHtmlRenderer{
75
+
Config: html.NewConfig(),
76
+
}
77
+
for _, opt := range opts {
78
+
opt.SetHTMLOption(&r.Config)
79
+
}
80
+
return r
81
+
}
82
+
83
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
84
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
85
+
reg.Register(KindAt, r.renderAt)
86
+
}
87
+
88
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
89
+
if entering {
90
+
w.WriteString(`<a href="/@`)
91
+
w.WriteString(n.(*AtNode).Handle)
92
+
w.WriteString(`" class="mention font-bold">`)
93
+
} else {
94
+
w.WriteString("</a>")
95
+
}
96
+
return ast.WalkContinue, nil
97
+
}
98
+
99
+
type atExt struct{}
100
+
101
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
102
+
var AtExt = &atExt{}
103
+
104
+
func (e *atExt) Extend(m goldmark.Markdown) {
105
+
m.Parser().AddOptions(parser.WithInlineParsers(
106
+
util.Prioritized(NewAtParser(), 500),
107
+
))
108
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
109
+
util.Prioritized(NewAtHTMLRenderer(), 500),
110
+
))
111
+
}
+11
-2
appview/pages/markup/markdown.go
+11
-2
appview/pages/markup/markdown.go
···
25
25
htmlparse "golang.org/x/net/html"
26
26
27
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
28
29
"tangled.org/core/appview/pages/repoinfo"
29
30
)
30
31
···
50
51
Files fs.FS
51
52
}
52
53
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
+
func NewMarkdown() goldmark.Markdown {
54
55
md := goldmark.New(
55
56
goldmark.WithExtensions(
56
57
extension.GFM,
···
66
67
),
67
68
treeblood.MathML(),
68
69
callout.CalloutExtention,
70
+
textension.AtExt,
69
71
),
70
72
goldmark.WithParserOptions(
71
73
parser.WithAutoHeadingID(),
72
74
),
73
75
goldmark.WithRendererOptions(html.WithUnsafe()),
74
76
)
77
+
return md
78
+
}
75
79
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
return rctx.RenderMarkdownWith(source, NewMarkdown())
82
+
}
83
+
84
+
func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
76
85
if rctx != nil {
77
86
var transformers []util.PrioritizedValue
78
87
···
240
249
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
241
250
242
251
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
243
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
252
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
244
253
245
254
parsedURL := &url.URL{
246
255
Scheme: scheme,
+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
+38
-114
appview/pages/pages.go
+38
-114
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"
···
29
28
"tangled.org/core/patchutil"
30
29
"tangled.org/core/types"
31
30
32
-
"github.com/alecthomas/chroma/v2"
33
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
34
-
"github.com/alecthomas/chroma/v2/lexers"
35
-
"github.com/alecthomas/chroma/v2/styles"
36
31
"github.com/bluesky-social/indigo/atproto/identity"
37
32
"github.com/bluesky-social/indigo/atproto/syntax"
38
33
"github.com/go-git/go-git/v5/plumbing"
···
412
407
type KnotsParams struct {
413
408
LoggedInUser *oauth.User
414
409
Registrations []models.Registration
410
+
Tabs []map[string]any
411
+
Tab string
415
412
}
416
413
417
414
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
424
421
Members []string
425
422
Repos map[string][]models.Repo
426
423
IsOwner bool
424
+
Tabs []map[string]any
425
+
Tab string
427
426
}
428
427
429
428
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
···
441
440
type SpindlesParams struct {
442
441
LoggedInUser *oauth.User
443
442
Spindles []models.Spindle
443
+
Tabs []map[string]any
444
+
Tab string
444
445
}
445
446
446
447
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
449
450
450
451
type SpindleListingParams struct {
451
452
models.Spindle
453
+
Tabs []map[string]any
454
+
Tab string
452
455
}
453
456
454
457
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
460
463
Spindle models.Spindle
461
464
Members []string
462
465
Repos map[string][]models.Repo
466
+
Tabs []map[string]any
467
+
Tab string
463
468
}
464
469
465
470
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
487
492
488
493
type ProfileCard struct {
489
494
UserDid string
490
-
UserHandle string
491
495
FollowStatus models.FollowStatus
492
496
Punchcard *models.Punchcard
493
497
Profile *models.Profile
···
630
634
return p.executePlain("user/fragments/editPins", w, params)
631
635
}
632
636
633
-
type RepoStarFragmentParams struct {
637
+
type StarBtnFragmentParams struct {
634
638
IsStarred bool
635
-
RepoAt syntax.ATURI
636
-
Stats models.RepoStats
639
+
SubjectAt syntax.ATURI
640
+
StarCount int
637
641
}
638
642
639
-
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
640
-
return p.executePlain("repo/fragments/repoStar", w, params)
643
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
644
+
return p.executePlain("fragments/starBtn", w, params)
641
645
}
642
646
643
647
type RepoIndexParams struct {
···
744
748
func (r RepoTreeParams) TreeStats() RepoTreeStats {
745
749
numFolders, numFiles := 0, 0
746
750
for _, f := range r.Files {
747
-
if !f.IsFile {
751
+
if !f.IsFile() {
748
752
numFolders += 1
749
-
} else if f.IsFile {
753
+
} else if f.IsFile() {
750
754
numFiles += 1
751
755
}
752
756
}
···
817
821
}
818
822
819
823
type RepoBlobParams struct {
820
-
LoggedInUser *oauth.User
821
-
RepoInfo repoinfo.RepoInfo
822
-
Active string
823
-
Unsupported bool
824
-
IsImage bool
825
-
IsVideo bool
826
-
ContentSrc string
827
-
BreadCrumbs [][]string
828
-
ShowRendered bool
829
-
RenderToggle bool
830
-
RenderedContents template.HTML
824
+
LoggedInUser *oauth.User
825
+
RepoInfo repoinfo.RepoInfo
826
+
Active string
827
+
BreadCrumbs [][]string
828
+
BlobView models.BlobView
831
829
*tangled.RepoBlob_Output
832
-
// Computed fields for template compatibility
833
-
Contents string
834
-
Lines int
835
-
SizeHint uint64
836
-
IsBinary bool
837
830
}
838
831
839
832
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
840
-
var style *chroma.Style = styles.Get("catpuccin-latte")
841
-
842
-
if params.ShowRendered {
843
-
switch markup.GetFormat(params.Path) {
844
-
case markup.FormatMarkdown:
845
-
p.rctx.RepoInfo = params.RepoInfo
846
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
847
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
848
-
sanitized := p.rctx.SanitizeDefault(htmlString)
849
-
params.RenderedContents = template.HTML(sanitized)
850
-
}
851
-
}
852
-
853
-
c := params.Contents
854
-
formatter := chromahtml.New(
855
-
chromahtml.InlineCode(false),
856
-
chromahtml.WithLineNumbers(true),
857
-
chromahtml.WithLinkableLineNumbers(true, "L"),
858
-
chromahtml.Standalone(false),
859
-
chromahtml.WithClasses(true),
860
-
)
861
-
862
-
lexer := lexers.Get(filepath.Base(params.Path))
863
-
if lexer == nil {
864
-
lexer = lexers.Fallback
865
-
}
866
-
867
-
iterator, err := lexer.Tokenise(nil, c)
868
-
if err != nil {
869
-
return fmt.Errorf("chroma tokenize: %w", err)
833
+
switch params.BlobView.ContentType {
834
+
case models.BlobContentTypeMarkup:
835
+
p.rctx.RepoInfo = params.RepoInfo
870
836
}
871
837
872
-
var code bytes.Buffer
873
-
err = formatter.Format(&code, style, iterator)
874
-
if err != nil {
875
-
return fmt.Errorf("chroma format: %w", err)
876
-
}
877
-
878
-
params.Contents = code.String()
879
838
params.Active = "overview"
880
839
return p.executeRepo("repo/blob", w, params)
881
840
}
882
841
883
842
type Collaborator struct {
884
-
Did string
885
-
Handle string
886
-
Role string
843
+
Did string
844
+
Role string
887
845
}
888
846
889
847
type RepoSettingsParams struct {
···
958
916
RepoInfo repoinfo.RepoInfo
959
917
Active string
960
918
Issues []models.Issue
919
+
IssueCount int
961
920
LabelDefs map[string]*models.LabelDefinition
962
921
Page pagination.Page
963
922
FilteringByOpen bool
···
975
934
Active string
976
935
Issue *models.Issue
977
936
CommentList []models.CommentListItem
937
+
Backlinks []models.RichReferenceLink
978
938
LabelDefs map[string]*models.LabelDefinition
979
939
980
940
OrderedReactionKinds []models.ReactionKind
···
1128
1088
Pull *models.Pull
1129
1089
Stack models.Stack
1130
1090
AbandonedPulls []*models.Pull
1091
+
Backlinks []models.RichReferenceLink
1131
1092
BranchDeleteStatus *models.BranchDeleteStatus
1132
1093
MergeCheck types.MergeCheckResponse
1133
1094
ResubmitCheck ResubmitResult
···
1299
1260
return p.executePlain("repo/fragments/compareAllowPull", w, params)
1300
1261
}
1301
1262
1302
-
type RepoCompareDiffParams struct {
1303
-
LoggedInUser *oauth.User
1304
-
RepoInfo repoinfo.RepoInfo
1305
-
Diff types.NiceDiff
1263
+
type RepoCompareDiffFragmentParams struct {
1264
+
Diff types.NiceDiff
1265
+
DiffOpts types.DiffOpts
1306
1266
}
1307
1267
1308
-
func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error {
1309
-
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1268
+
func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1269
+
return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1310
1270
}
1311
1271
1312
1272
type LabelPanelParams struct {
···
1426
1386
ShowRendered bool
1427
1387
RenderToggle bool
1428
1388
RenderedContents template.HTML
1429
-
String models.String
1389
+
String *models.String
1430
1390
Stats models.StringStats
1391
+
IsStarred bool
1392
+
StarCount int
1431
1393
Owner identity.Identity
1432
1394
}
1433
1395
1434
1396
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
1397
return p.execute("strings/string", w, params)
1474
1398
}
1475
1399
+25
-22
appview/pages/repoinfo/repoinfo.go
+25
-22
appview/pages/repoinfo/repoinfo.go
···
1
1
package repoinfo
2
2
3
3
import (
4
+
"fmt"
4
5
"path"
5
6
"slices"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
8
10
"tangled.org/core/appview/models"
9
11
"tangled.org/core/appview/state/userutil"
10
12
)
11
13
12
-
func (r RepoInfo) Owner() string {
14
+
func (r RepoInfo) owner() string {
13
15
if r.OwnerHandle != "" {
14
16
return r.OwnerHandle
15
17
} else {
···
18
20
}
19
21
20
22
func (r RepoInfo) FullName() string {
21
-
return path.Join(r.Owner(), r.Name)
23
+
return path.Join(r.owner(), r.Name)
22
24
}
23
25
24
-
func (r RepoInfo) OwnerWithoutAt() string {
26
+
func (r RepoInfo) ownerWithoutAt() string {
25
27
if r.OwnerHandle != "" {
26
28
return r.OwnerHandle
27
29
} else {
···
30
32
}
31
33
32
34
func (r RepoInfo) FullNameWithoutAt() string {
33
-
return path.Join(r.OwnerWithoutAt(), r.Name)
35
+
return path.Join(r.ownerWithoutAt(), r.Name)
34
36
}
35
37
36
38
func (r RepoInfo) GetTabs() [][]string {
···
48
50
return tabs
49
51
}
50
52
53
+
func (r RepoInfo) RepoAt() syntax.ATURI {
54
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey))
55
+
}
56
+
51
57
type RepoInfo struct {
52
-
Name string
53
-
Rkey string
54
-
OwnerDid string
55
-
OwnerHandle string
56
-
Description string
57
-
Website string
58
-
Topics []string
59
-
Knot string
60
-
Spindle string
61
-
RepoAt syntax.ATURI
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:
+28
appview/pages/templates/fragments/starBtn.html
+28
appview/pages/templates/fragments/starBtn.html
···
1
+
{{ define "fragments/starBtn" }}
2
+
<button
3
+
id="starBtn"
4
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
+
data-star-subject-at="{{ .SubjectAt }}"
6
+
{{ if .IsStarred }}
7
+
hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
8
+
{{ else }}
9
+
hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
10
+
{{ end }}
11
+
12
+
hx-trigger="click"
13
+
hx-target="this"
14
+
hx-swap="outerHTML"
15
+
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
16
+
hx-disabled-elt="#starBtn"
17
+
>
18
+
{{ if .IsStarred }}
19
+
{{ i "star" "w-4 h-4 fill-current" }}
20
+
{{ else }}
21
+
{{ i "star" "w-4 h-4" }}
22
+
{{ end }}
23
+
<span class="text-sm">
24
+
{{ .StarCount }}
25
+
</span>
26
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
+
</button>
28
+
{{ end }}
+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
+
+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
-13
appview/pages/templates/knots/fragments/addMemberModal.html
+18
-13
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
-
autocapitalize="none"
34
-
autocorrect="off"
35
-
autocomplete="off"
36
-
type="text"
37
-
id="member-did-{{ .Id }}"
38
-
name="member"
39
-
required
40
-
placeholder="foo.bsky.social"
41
-
/>
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>
42
47
<div class="flex gap-2 pt-2">
43
48
<button
44
49
type="button"
···
57
62
</div>
58
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
59
64
</form>
60
-
{{ 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
+16
-10
appview/pages/templates/layouts/repobase.html
+16
-10
appview/pages/templates/layouts/repobase.html
···
2
2
3
3
{{ define "content" }}
4
4
<section id="repo-header" class="mb-4 p-2 dark:text-white">
5
-
{{ if .RepoInfo.Source }}
6
-
<div class="flex items-center">
7
-
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
8
-
forked from
9
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
10
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
11
-
</div>
12
-
{{ end }}
13
5
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
14
6
<!-- left items -->
15
7
<div class="flex flex-col gap-2">
···
19
11
<span class="select-none">/</span>
20
12
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
21
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 }}
22
25
23
26
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
24
27
{{ if .RepoInfo.Description }}
···
46
49
</div>
47
50
48
51
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
49
-
{{ template "repo/fragments/repoStar" .RepoInfo }}
52
+
{{ template "fragments/starBtn"
53
+
(dict "SubjectAt" .RepoInfo.RepoAt
54
+
"IsStarred" .RepoInfo.IsStarred
55
+
"StarCount" .RepoInfo.Stats.StarCount) }}
50
56
<a
51
57
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
52
58
hx-boost="true"
···
104
110
</div>
105
111
</nav>
106
112
{{ block "repoContentLayout" . }}
107
-
<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">
108
114
{{ block "repoContent" . }}{{ end }}
109
115
</section>
110
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 }}
+1
-1
appview/pages/templates/repo/commit.html
+1
-1
appview/pages/templates/repo/commit.html
+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 | stripPort }}:{{ .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" "w-4 h-4" }}
18
+
</span>
19
+
{{ else if eq .Kind.String "issues" }}
20
+
<span class="text-green-600 dark:text-green-500">
21
+
{{ i "circle-dot" "w-4 h-4" }}
22
+
</span>
23
+
{{ else if .State.IsOpen }}
24
+
<span class="text-green-600 dark:text-green-500">
25
+
{{ i "git-pull-request" "w-4 h-4" }}
26
+
</span>
27
+
{{ else if .State.IsMerged }}
28
+
<span class="text-purple-600 dark:text-purple-500">
29
+
{{ i "git-merge" "w-4 h-4" }}
30
+
</span>
31
+
{{ else }}
32
+
<span class="text-gray-600 dark:text-gray-300">
33
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
34
+
</span>
35
+
{{ end }}
36
+
<a href="{{ . }}"><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 }}
+3
-2
appview/pages/templates/repo/fragments/cloneDropdown.html
+3
-2
appview/pages/templates/repo/fragments/cloneDropdown.html
···
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 | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot | stripPort }}:{{ .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" }}
-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 }}
+9
-2
appview/pages/templates/repo/index.html
+9
-2
appview/pages/templates/repo/index.html
···
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" }}
···
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" }}
+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 }}
+3
appview/pages/templates/repo/issues/issue.html
+3
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) }}
23
26
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
24
27
</div>
25
28
</div>
+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 }}
+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 }}
+3
-3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+3
-3
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">{{ template "stepHeader" . }}</div>
6
-
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</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>
7
7
</summary>
8
8
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
9
9
</details>
···
11
11
{{ end }}
12
12
13
13
{{ define "stepHeader" }}
14
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
14
+
{{ .Name }}
15
15
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
16
16
{{ end }}
+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>
+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/patch.html
+1
-1
appview/pages/templates/repo/pulls/patch.html
+3
appview/pages/templates/repo/pulls/pull.html
+3
appview/pages/templates/repo/pulls/pull.html
···
21
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) }}
24
27
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
25
28
</div>
26
29
</div>
+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"
+1
-1
appview/pages/templates/repo/settings/general.html
+1
-1
appview/pages/templates/repo/settings/general.html
+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
-12
appview/pages/templates/spindles/fragments/addMemberModal.html
+17
-12
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
-
autocomplete="off"
36
-
type="text"
37
-
id="member-did-{{ .Id }}"
38
-
name="member"
39
-
required
40
-
placeholder="foo.bsky.social"
41
-
/>
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>
42
47
<div class="flex gap-2 pt-2">
43
48
<button
44
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 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>
+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 }}
+1
-1
appview/pages/templates/user/followers.html
+1
-1
appview/pages/templates/user/followers.html
+1
-1
appview/pages/templates/user/following.html
+1
-1
appview/pages/templates/user/following.html
+7
-1
appview/pages/templates/user/fragments/editBio.html
+7
-1
appview/pages/templates/user/fragments/editBio.html
···
26
26
{{ if and .Profile .Profile.Pronouns }}
27
27
{{ $pronouns = .Profile.Pronouns }}
28
28
{{ end }}
29
-
<input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}">
29
+
<input
30
+
type="text"
31
+
class="py-1 px-1 w-full"
32
+
name="pronouns"
33
+
placeholder="they/them"
34
+
value="{{ $pronouns }}"
35
+
>
30
36
</div>
31
37
</div>
32
38
+1
-1
appview/pages/templates/user/fragments/profileCard.html
+1
-1
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">
+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>
+12
-2
appview/pages/templates/user/overview.html
+12
-2
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">
···
33
33
</p>
34
34
35
35
<div class="flex flex-col gap-1">
36
+
{{ block "commits" .Commits }} {{ end }}
36
37
{{ block "repoEvents" .RepoEvents }} {{ end }}
37
38
{{ block "issueEvents" .IssueEvents }} {{ end }}
38
39
{{ block "pullEvents" .PullEvents }} {{ end }}
···
43
44
{{ end }}
44
45
{{ end }}
45
46
</div>
47
+
{{ end }}
48
+
49
+
{{ define "commits" }}
50
+
{{ if . }}
51
+
<div class="flex flex-wrap items-center gap-1">
52
+
{{ i "git-commit-horizontal" "size-5" }}
53
+
created {{ . }} commits
54
+
</div>
55
+
{{ end }}
46
56
{{ end }}
47
57
48
58
{{ define "repoEvents" }}
···
224
234
{{ define "ownRepos" }}
225
235
<div>
226
236
<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"
237
+
<a href="/{{ resolve $.Card.UserDid }}?tab=repos"
228
238
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
229
239
<span>PINNED REPOS</span>
230
240
</a>
+1
-1
appview/pages/templates/user/repos.html
+1
-1
appview/pages/templates/user/repos.html
+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>
+1
-1
appview/pages/templates/user/starred.html
+1
-1
appview/pages/templates/user/starred.html
+2
-2
appview/pages/templates/user/strings.html
+2
-2
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">
···
23
23
{{ $s := index . 1 }}
24
24
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
25
25
<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>
26
+
<a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
27
27
</div>
28
28
{{ with $s.Description }}
29
29
<div class="text-gray-600 dark:text-gray-300 text-sm">
+16
-20
appview/pipelines/pipelines.go
+16
-20
appview/pipelines/pipelines.go
···
78
78
return
79
79
}
80
80
81
-
repoInfo := f.RepoInfo(user)
82
-
83
81
ps, err := db.GetPipelineStatuses(
84
82
p.db,
85
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
86
-
db.FilterEq("repo_name", repoInfo.Name),
87
-
db.FilterEq("knot", repoInfo.Knot),
83
+
30,
84
+
db.FilterEq("repo_owner", f.Did),
85
+
db.FilterEq("repo_name", f.Name),
86
+
db.FilterEq("knot", f.Knot),
88
87
)
89
88
if err != nil {
90
89
l.Error("failed to query db", "err", err)
···
93
92
94
93
p.pages.Pipelines(w, pages.PipelinesParams{
95
94
LoggedInUser: user,
96
-
RepoInfo: repoInfo,
95
+
RepoInfo: p.repoResolver.GetRepoInfo(r, user),
97
96
Pipelines: ps,
98
97
})
99
98
}
···
108
107
return
109
108
}
110
109
111
-
repoInfo := f.RepoInfo(user)
112
-
113
110
pipelineId := chi.URLParam(r, "pipeline")
114
111
if pipelineId == "" {
115
112
l.Error("empty pipeline ID")
···
124
121
125
122
ps, err := db.GetPipelineStatuses(
126
123
p.db,
127
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
128
-
db.FilterEq("repo_name", repoInfo.Name),
129
-
db.FilterEq("knot", repoInfo.Knot),
124
+
1,
125
+
db.FilterEq("repo_owner", f.Did),
126
+
db.FilterEq("repo_name", f.Name),
127
+
db.FilterEq("knot", f.Knot),
130
128
db.FilterEq("id", pipelineId),
131
129
)
132
130
if err != nil {
···
143
141
144
142
p.pages.Workflow(w, pages.WorkflowParams{
145
143
LoggedInUser: user,
146
-
RepoInfo: repoInfo,
144
+
RepoInfo: p.repoResolver.GetRepoInfo(r, user),
147
145
Pipeline: singlePipeline,
148
146
Workflow: workflow,
149
147
})
···
174
172
ctx, cancel := context.WithCancel(r.Context())
175
173
defer cancel()
176
174
177
-
user := p.oauth.GetUser(r)
178
175
f, err := p.repoResolver.Resolve(r)
179
176
if err != nil {
180
177
l.Error("failed to get repo and knot", "err", err)
···
182
179
return
183
180
}
184
181
185
-
repoInfo := f.RepoInfo(user)
186
-
187
182
pipelineId := chi.URLParam(r, "pipeline")
188
183
workflow := chi.URLParam(r, "workflow")
189
184
if pipelineId == "" || workflow == "" {
···
193
188
194
189
ps, err := db.GetPipelineStatuses(
195
190
p.db,
196
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
197
-
db.FilterEq("repo_name", repoInfo.Name),
198
-
db.FilterEq("knot", repoInfo.Knot),
191
+
1,
192
+
db.FilterEq("repo_owner", f.Did),
193
+
db.FilterEq("repo_name", f.Name),
194
+
db.FilterEq("knot", f.Knot),
199
195
db.FilterEq("id", pipelineId),
200
196
)
201
197
if err != nil || len(ps) != 1 {
···
205
201
}
206
202
207
203
singlePipeline := ps[0]
208
-
spindle := repoInfo.Spindle
209
-
knot := repoInfo.Knot
204
+
spindle := f.Spindle
205
+
knot := f.Knot
210
206
rkey := singlePipeline.Rkey
211
207
212
208
if spindle == "" || knot == "" || rkey == "" {
+1
-1
appview/pulls/opengraph.go
+1
-1
appview/pulls/opengraph.go
···
293
293
filesChanged = niceDiff.Stat.FilesChanged
294
294
}
295
295
296
-
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
296
+
card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged)
297
297
if err != nil {
298
298
log.Println("failed to draw pull summary card", err)
299
299
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+108
-101
appview/pulls/pulls.go
+108
-101
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"
···
23
24
"tangled.org/core/appview/oauth"
24
25
"tangled.org/core/appview/pages"
25
26
"tangled.org/core/appview/pages/markup"
27
+
"tangled.org/core/appview/pages/repoinfo"
28
+
"tangled.org/core/appview/refresolver"
26
29
"tangled.org/core/appview/reporesolver"
27
30
"tangled.org/core/appview/validator"
28
31
"tangled.org/core/appview/xrpcclient"
···
45
48
repoResolver *reporesolver.RepoResolver
46
49
pages *pages.Pages
47
50
idResolver *idresolver.Resolver
51
+
refResolver *refresolver.Resolver
48
52
db *db.DB
49
53
config *config.Config
50
54
notifier notify.Notifier
···
59
63
repoResolver *reporesolver.RepoResolver,
60
64
pages *pages.Pages,
61
65
resolver *idresolver.Resolver,
66
+
refResolver *refresolver.Resolver,
62
67
db *db.DB,
63
68
config *config.Config,
64
69
notifier notify.Notifier,
···
72
77
repoResolver: repoResolver,
73
78
pages: pages,
74
79
idResolver: resolver,
80
+
refResolver: refResolver,
75
81
db: db,
76
82
config: config,
77
83
notifier: notifier,
···
123
129
124
130
s.pages.PullActionsFragment(w, pages.PullActionsParams{
125
131
LoggedInUser: user,
126
-
RepoInfo: f.RepoInfo(user),
132
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
127
133
Pull: pull,
128
134
RoundNumber: roundNumber,
129
135
MergeCheck: mergeCheckResponse,
···
150
156
return
151
157
}
152
158
159
+
backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
160
+
if err != nil {
161
+
log.Println("failed to get pull backlinks", err)
162
+
s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
163
+
return
164
+
}
165
+
153
166
// can be nil if this pull is not stacked
154
167
stack, _ := r.Context().Value("stack").(models.Stack)
155
168
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
···
160
173
if user != nil && user.Did == pull.OwnerDid {
161
174
resubmitResult = s.resubmitCheck(r, f, pull, stack)
162
175
}
163
-
164
-
repoInfo := f.RepoInfo(user)
165
176
166
177
m := make(map[string]models.Pipeline)
167
178
···
178
189
179
190
ps, err := db.GetPipelineStatuses(
180
191
s.db,
181
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
182
-
db.FilterEq("repo_name", repoInfo.Name),
183
-
db.FilterEq("knot", repoInfo.Knot),
192
+
len(shas),
193
+
db.FilterEq("repo_owner", f.Did),
194
+
db.FilterEq("repo_name", f.Name),
195
+
db.FilterEq("knot", f.Knot),
184
196
db.FilterIn("sha", shas),
185
197
)
186
198
if err != nil {
···
205
217
206
218
labelDefs, err := db.GetLabelDefinitions(
207
219
s.db,
208
-
db.FilterIn("at_uri", f.Repo.Labels),
220
+
db.FilterIn("at_uri", f.Labels),
209
221
db.FilterContains("scope", tangled.RepoPullNSID),
210
222
)
211
223
if err != nil {
···
221
233
222
234
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
223
235
LoggedInUser: user,
224
-
RepoInfo: repoInfo,
236
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
225
237
Pull: pull,
226
238
Stack: stack,
227
239
AbandonedPulls: abandonedPulls,
240
+
Backlinks: backlinks,
228
241
BranchDeleteStatus: branchDeleteStatus,
229
242
MergeCheck: mergeCheckResponse,
230
243
ResubmitCheck: resubmitResult,
···
238
251
})
239
252
}
240
253
241
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
254
+
func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
242
255
if pull.State == models.PullMerged {
243
256
return types.MergeCheckResponse{}
244
257
}
···
267
280
r.Context(),
268
281
&xrpcc,
269
282
&tangled.RepoMergeCheck_Input{
270
-
Did: f.OwnerDid(),
283
+
Did: f.Did,
271
284
Name: f.Name,
272
285
Branch: pull.TargetBranch,
273
286
Patch: patch,
···
305
318
return result
306
319
}
307
320
308
-
func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
321
+
func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
309
322
if pull.State != models.PullMerged {
310
323
return nil
311
324
}
···
316
329
}
317
330
318
331
var branch string
319
-
var repo *models.Repo
320
332
// check if the branch exists
321
333
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
322
334
if pull.IsBranchBased() {
323
335
branch = pull.PullSource.Branch
324
-
repo = &f.Repo
325
336
} else if pull.IsForkBased() {
326
337
branch = pull.PullSource.Branch
327
338
repo = pull.PullSource.Repo
···
360
371
}
361
372
}
362
373
363
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
374
+
func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
364
375
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
365
376
return pages.Unknown
366
377
}
···
380
391
repoName = sourceRepo.Name
381
392
} else {
382
393
// pulls within the same repo
383
-
knot = f.Knot
384
-
ownerDid = f.OwnerDid()
385
-
repoName = f.Name
394
+
knot = repo.Knot
395
+
ownerDid = repo.Did
396
+
repoName = repo.Name
386
397
}
387
398
388
399
scheme := "http"
···
394
405
Host: host,
395
406
}
396
407
397
-
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
398
-
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
408
+
didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName)
409
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName)
399
410
if err != nil {
400
411
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
401
412
log.Println("failed to call XRPC repo.branches", xrpcerr)
···
423
434
424
435
func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
425
436
user := s.oauth.GetUser(r)
426
-
f, err := s.repoResolver.Resolve(r)
427
-
if err != nil {
428
-
log.Println("failed to get repo and knot", err)
429
-
return
430
-
}
431
437
432
438
var diffOpts types.DiffOpts
433
439
if d := r.URL.Query().Get("diff"); d == "split" {
···
456
462
457
463
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
458
464
LoggedInUser: user,
459
-
RepoInfo: f.RepoInfo(user),
465
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
460
466
Pull: pull,
461
467
Stack: stack,
462
468
Round: roundIdInt,
···
470
476
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
471
477
user := s.oauth.GetUser(r)
472
478
473
-
f, err := s.repoResolver.Resolve(r)
474
-
if err != nil {
475
-
log.Println("failed to get repo and knot", err)
476
-
return
477
-
}
478
-
479
479
var diffOpts types.DiffOpts
480
480
if d := r.URL.Query().Get("diff"); d == "split" {
481
481
diffOpts.Split = true
···
520
520
521
521
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
522
522
LoggedInUser: s.oauth.GetUser(r),
523
-
RepoInfo: f.RepoInfo(user),
523
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
524
524
Pull: pull,
525
525
Round: roundIdInt,
526
526
Interdiff: interdiff,
···
645
645
}
646
646
pulls = pulls[:n]
647
647
648
-
repoInfo := f.RepoInfo(user)
649
648
ps, err := db.GetPipelineStatuses(
650
649
s.db,
651
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
652
-
db.FilterEq("repo_name", repoInfo.Name),
653
-
db.FilterEq("knot", repoInfo.Knot),
650
+
len(shas),
651
+
db.FilterEq("repo_owner", f.Did),
652
+
db.FilterEq("repo_name", f.Name),
653
+
db.FilterEq("knot", f.Knot),
654
654
db.FilterIn("sha", shas),
655
655
)
656
656
if err != nil {
···
664
664
665
665
labelDefs, err := db.GetLabelDefinitions(
666
666
s.db,
667
-
db.FilterIn("at_uri", f.Repo.Labels),
667
+
db.FilterIn("at_uri", f.Labels),
668
668
db.FilterContains("scope", tangled.RepoPullNSID),
669
669
)
670
670
if err != nil {
···
680
680
681
681
s.pages.RepoPulls(w, pages.RepoPullsParams{
682
682
LoggedInUser: s.oauth.GetUser(r),
683
-
RepoInfo: f.RepoInfo(user),
683
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
684
684
Pulls: pulls,
685
685
LabelDefs: defs,
686
686
FilteringBy: state,
···
717
717
case http.MethodGet:
718
718
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
719
719
LoggedInUser: user,
720
-
RepoInfo: f.RepoInfo(user),
720
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
721
721
Pull: pull,
722
722
RoundNumber: roundNumber,
723
723
})
···
729
729
return
730
730
}
731
731
732
+
mentions, references := s.refResolver.Resolve(r.Context(), body)
733
+
732
734
// Start a transaction
733
735
tx, err := s.db.BeginTx(r.Context(), nil)
734
736
if err != nil {
···
771
773
Body: body,
772
774
CommentAt: atResp.Uri,
773
775
SubmissionId: pull.Submissions[roundNumber].ID,
776
+
Mentions: mentions,
777
+
References: references,
774
778
}
775
779
776
780
// Create the pull comment in the database with the commentAt field
···
788
792
return
789
793
}
790
794
791
-
s.notifier.NewPullComment(r.Context(), comment)
795
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
792
796
793
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
797
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
798
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
794
799
return
795
800
}
796
801
}
···
814
819
Host: host,
815
820
}
816
821
817
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
822
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
818
823
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
819
824
if err != nil {
820
825
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
841
846
842
847
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
843
848
LoggedInUser: user,
844
-
RepoInfo: f.RepoInfo(user),
849
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
845
850
Branches: result.Branches,
846
851
Strategy: strategy,
847
852
SourceBranch: sourceBranch,
···
864
869
}
865
870
866
871
// Determine PR type based on input parameters
867
-
isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed()
872
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
873
+
isPushAllowed := roles.IsPushAllowed()
868
874
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
869
875
isForkBased := fromFork != "" && sourceBranch != ""
870
876
isPatchBased := patch != "" && !isBranchBased && !isForkBased
···
962
968
func (s *Pulls) handleBranchBasedPull(
963
969
w http.ResponseWriter,
964
970
r *http.Request,
965
-
f *reporesolver.ResolvedRepo,
971
+
repo *models.Repo,
966
972
user *oauth.User,
967
973
title,
968
974
body,
···
974
980
if !s.config.Core.Dev {
975
981
scheme = "https"
976
982
}
977
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
983
+
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
978
984
xrpcc := &indigoxrpc.Client{
979
985
Host: host,
980
986
}
981
987
982
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
983
-
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch)
988
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
989
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch)
984
990
if err != nil {
985
991
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
986
992
log.Println("failed to call XRPC repo.compare", xrpcerr)
···
1017
1023
Sha: comparison.Rev2,
1018
1024
}
1019
1025
1020
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1026
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1021
1027
}
1022
1028
1023
-
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1029
+
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1024
1030
if err := s.validator.ValidatePatch(&patch); err != nil {
1025
1031
s.logger.Error("patch validation failed", "err", err)
1026
1032
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1027
1033
return
1028
1034
}
1029
1035
1030
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1036
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1031
1037
}
1032
1038
1033
-
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) {
1039
+
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) {
1034
1040
repoString := strings.SplitN(forkRepo, "/", 2)
1035
1041
forkOwnerDid := repoString[0]
1036
1042
repoName := repoString[1]
···
1132
1138
Sha: sourceRev,
1133
1139
}
1134
1140
1135
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1141
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1136
1142
}
1137
1143
1138
1144
func (s *Pulls) createPullRequest(
1139
1145
w http.ResponseWriter,
1140
1146
r *http.Request,
1141
-
f *reporesolver.ResolvedRepo,
1147
+
repo *models.Repo,
1142
1148
user *oauth.User,
1143
1149
title, body, targetBranch string,
1144
1150
patch string,
···
1153
1159
s.createStackedPullRequest(
1154
1160
w,
1155
1161
r,
1156
-
f,
1162
+
repo,
1157
1163
user,
1158
1164
targetBranch,
1159
1165
patch,
···
1198
1204
body = formatPatches[0].Body
1199
1205
}
1200
1206
}
1207
+
1208
+
mentions, references := s.refResolver.Resolve(r.Context(), body)
1201
1209
1202
1210
rkey := tid.TID()
1203
1211
initialSubmission := models.PullSubmission{
···
1210
1218
Body: body,
1211
1219
TargetBranch: targetBranch,
1212
1220
OwnerDid: user.Did,
1213
-
RepoAt: f.RepoAt(),
1221
+
RepoAt: repo.RepoAt(),
1214
1222
Rkey: rkey,
1223
+
Mentions: mentions,
1224
+
References: references,
1215
1225
Submissions: []*models.PullSubmission{
1216
1226
&initialSubmission,
1217
1227
},
···
1223
1233
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1224
1234
return
1225
1235
}
1226
-
pullId, err := db.NextPullId(tx, f.RepoAt())
1236
+
pullId, err := db.NextPullId(tx, repo.RepoAt())
1227
1237
if err != nil {
1228
1238
log.Println("failed to get pull id", err)
1229
1239
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1238
1248
Val: &tangled.RepoPull{
1239
1249
Title: title,
1240
1250
Target: &tangled.RepoPull_Target{
1241
-
Repo: string(f.RepoAt()),
1251
+
Repo: string(repo.RepoAt()),
1242
1252
Branch: targetBranch,
1243
1253
},
1244
1254
Patch: patch,
···
1261
1271
1262
1272
s.notifier.NewPull(r.Context(), pull)
1263
1273
1264
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1274
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1275
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
1265
1276
}
1266
1277
1267
1278
func (s *Pulls) createStackedPullRequest(
1268
1279
w http.ResponseWriter,
1269
1280
r *http.Request,
1270
-
f *reporesolver.ResolvedRepo,
1281
+
repo *models.Repo,
1271
1282
user *oauth.User,
1272
1283
targetBranch string,
1273
1284
patch string,
···
1299
1310
1300
1311
// build a stack out of this patch
1301
1312
stackId := uuid.New()
1302
-
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1313
+
stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String())
1303
1314
if err != nil {
1304
1315
log.Println("failed to create stack", err)
1305
1316
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
···
1362
1373
return
1363
1374
}
1364
1375
1365
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1376
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1377
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1366
1378
}
1367
1379
1368
1380
func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
···
1393
1405
1394
1406
func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1395
1407
user := s.oauth.GetUser(r)
1396
-
f, err := s.repoResolver.Resolve(r)
1397
-
if err != nil {
1398
-
log.Println("failed to get repo and knot", err)
1399
-
return
1400
-
}
1401
1408
1402
1409
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1403
-
RepoInfo: f.RepoInfo(user),
1410
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1404
1411
})
1405
1412
}
1406
1413
···
1421
1428
Host: host,
1422
1429
}
1423
1430
1424
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1431
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1425
1432
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1426
1433
if err != nil {
1427
1434
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1454
1461
}
1455
1462
1456
1463
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1457
-
RepoInfo: f.RepoInfo(user),
1464
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1458
1465
Branches: withoutDefault,
1459
1466
})
1460
1467
}
1461
1468
1462
1469
func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1463
1470
user := s.oauth.GetUser(r)
1464
-
f, err := s.repoResolver.Resolve(r)
1465
-
if err != nil {
1466
-
log.Println("failed to get repo and knot", err)
1467
-
return
1468
-
}
1469
1471
1470
1472
forks, err := db.GetForksByDid(s.db, user.Did)
1471
1473
if err != nil {
···
1474
1476
}
1475
1477
1476
1478
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1477
-
RepoInfo: f.RepoInfo(user),
1479
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1478
1480
Forks: forks,
1479
1481
Selected: r.URL.Query().Get("fork"),
1480
1482
})
···
1542
1544
Host: targetHost,
1543
1545
}
1544
1546
1545
-
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1547
+
targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1546
1548
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1547
1549
if err != nil {
1548
1550
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1567
1569
})
1568
1570
1569
1571
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1570
-
RepoInfo: f.RepoInfo(user),
1572
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1571
1573
SourceBranches: sourceBranches.Branches,
1572
1574
TargetBranches: targetBranches.Branches,
1573
1575
})
···
1575
1577
1576
1578
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1577
1579
user := s.oauth.GetUser(r)
1578
-
f, err := s.repoResolver.Resolve(r)
1579
-
if err != nil {
1580
-
log.Println("failed to get repo and knot", err)
1581
-
return
1582
-
}
1583
1580
1584
1581
pull, ok := r.Context().Value("pull").(*models.Pull)
1585
1582
if !ok {
···
1591
1588
switch r.Method {
1592
1589
case http.MethodGet:
1593
1590
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1594
-
RepoInfo: f.RepoInfo(user),
1591
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1595
1592
Pull: pull,
1596
1593
})
1597
1594
return
···
1658
1655
return
1659
1656
}
1660
1657
1661
-
if !f.RepoInfo(user).Roles.IsPushAllowed() {
1658
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
1659
+
if !roles.IsPushAllowed() {
1662
1660
log.Println("unauthorized user")
1663
1661
w.WriteHeader(http.StatusUnauthorized)
1664
1662
return
···
1673
1671
Host: host,
1674
1672
}
1675
1673
1676
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1674
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1677
1675
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1678
1676
if err != nil {
1679
1677
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1800
1798
func (s *Pulls) resubmitPullHelper(
1801
1799
w http.ResponseWriter,
1802
1800
r *http.Request,
1803
-
f *reporesolver.ResolvedRepo,
1801
+
repo *models.Repo,
1804
1802
user *oauth.User,
1805
1803
pull *models.Pull,
1806
1804
patch string,
···
1809
1807
) {
1810
1808
if pull.IsStacked() {
1811
1809
log.Println("resubmitting stacked PR")
1812
-
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1810
+
s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId)
1813
1811
return
1814
1812
}
1815
1813
···
1889
1887
Val: &tangled.RepoPull{
1890
1888
Title: pull.Title,
1891
1889
Target: &tangled.RepoPull_Target{
1892
-
Repo: string(f.RepoAt()),
1890
+
Repo: string(repo.RepoAt()),
1893
1891
Branch: pull.TargetBranch,
1894
1892
},
1895
1893
Patch: patch, // new patch
···
1910
1908
return
1911
1909
}
1912
1910
1913
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1911
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1912
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1914
1913
}
1915
1914
1916
1915
func (s *Pulls) resubmitStackedPullHelper(
1917
1916
w http.ResponseWriter,
1918
1917
r *http.Request,
1919
-
f *reporesolver.ResolvedRepo,
1918
+
repo *models.Repo,
1920
1919
user *oauth.User,
1921
1920
pull *models.Pull,
1922
1921
patch string,
···
1925
1924
targetBranch := pull.TargetBranch
1926
1925
1927
1926
origStack, _ := r.Context().Value("stack").(models.Stack)
1928
-
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1927
+
newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId)
1929
1928
if err != nil {
1930
1929
log.Println("failed to create resubmitted stack", err)
1931
1930
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
2103
2102
return
2104
2103
}
2105
2104
2106
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2105
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2106
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2107
2107
}
2108
2108
2109
2109
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2156
2156
2157
2157
authorName := ident.Handle.String()
2158
2158
mergeInput := &tangled.RepoMerge_Input{
2159
-
Did: f.OwnerDid(),
2159
+
Did: f.Did,
2160
2160
Name: f.Name,
2161
2161
Branch: pull.TargetBranch,
2162
2162
Patch: patch,
···
2221
2221
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2222
2222
}
2223
2223
2224
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2224
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2225
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2225
2226
}
2226
2227
2227
2228
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2241
2242
}
2242
2243
2243
2244
// auth filter: only owner or collaborators can close
2244
-
roles := f.RolesInRepo(user)
2245
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2245
2246
isOwner := roles.IsOwner()
2246
2247
isCollaborator := roles.IsCollaborator()
2247
2248
isPullAuthor := user.Did == pull.OwnerDid
···
2293
2294
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2294
2295
}
2295
2296
2296
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2297
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2298
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2297
2299
}
2298
2300
2299
2301
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2314
2316
}
2315
2317
2316
2318
// auth filter: only owner or collaborators can close
2317
-
roles := f.RolesInRepo(user)
2319
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2318
2320
isOwner := roles.IsOwner()
2319
2321
isCollaborator := roles.IsCollaborator()
2320
2322
isPullAuthor := user.Did == pull.OwnerDid
···
2366
2368
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2367
2369
}
2368
2370
2369
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2371
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2372
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2370
2373
}
2371
2374
2372
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2375
+
func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2373
2376
formatPatches, err := patchutil.ExtractPatches(patch)
2374
2377
if err != nil {
2375
2378
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2394
2397
body := fp.Body
2395
2398
rkey := tid.TID()
2396
2399
2400
+
mentions, references := s.refResolver.Resolve(ctx, body)
2401
+
2397
2402
initialSubmission := models.PullSubmission{
2398
2403
Patch: fp.Raw,
2399
2404
SourceRev: fp.SHA,
···
2404
2409
Body: body,
2405
2410
TargetBranch: targetBranch,
2406
2411
OwnerDid: user.Did,
2407
-
RepoAt: f.RepoAt(),
2412
+
RepoAt: repo.RepoAt(),
2408
2413
Rkey: rkey,
2414
+
Mentions: mentions,
2415
+
References: references,
2409
2416
Submissions: []*models.PullSubmission{
2410
2417
&initialSubmission,
2411
2418
},
+65
appview/refresolver/resolver.go
+65
appview/refresolver/resolver.go
···
1
+
package refresolver
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
+
rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source)
39
+
l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs)
40
+
idents := r.idResolver.ResolveIdents(ctx, rawMentions)
41
+
var mentions []syntax.DID
42
+
for _, ident := range idents {
43
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
44
+
mentions = append(mentions, ident.DID)
45
+
}
46
+
}
47
+
l.Debug("found mentions", "mentions", mentions)
48
+
49
+
var resolvedRefs []models.ReferenceLink
50
+
for _, rawRef := range rawRefs {
51
+
ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle)
52
+
if err != nil || ident == nil || ident.Handle.IsInvalidHandle() {
53
+
continue
54
+
}
55
+
rawRef.Handle = string(ident.DID)
56
+
resolvedRefs = append(resolvedRefs, rawRef)
57
+
}
58
+
aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs)
59
+
if err != nil {
60
+
l.Error("failed running query", "err", err)
61
+
}
62
+
l.Debug("found references", "refs", aturiRefs)
63
+
64
+
return mentions, aturiRefs
65
+
}
+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
+
}
+11
-5
appview/repo/artifact.go
+11
-5
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"
19
18
"tangled.org/core/tid"
20
19
"tangled.org/core/types"
···
131
130
132
131
rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
133
132
LoggedInUser: user,
134
-
RepoInfo: f.RepoInfo(user),
133
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
135
134
Artifact: artifact,
136
135
})
137
136
}
···
174
173
175
174
artifact := artifacts[0]
176
175
177
-
ownerPds := f.OwnerId.PDSEndpoint()
176
+
ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did)
177
+
if err != nil {
178
+
log.Println("failed to resolve repo owner did", f.Did, err)
179
+
http.Error(w, "repository owner not found", http.StatusNotFound)
180
+
return
181
+
}
182
+
183
+
ownerPds := ownerId.PDSEndpoint()
178
184
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
179
185
q := url.Query()
180
186
q.Set("cid", artifact.BlobCid.String())
···
290
296
w.Write([]byte{})
291
297
}
292
298
293
-
func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
299
+
func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) {
294
300
tagParam, err := url.QueryUnescape(tagParam)
295
301
if err != nil {
296
302
return nil, err
···
305
311
Host: host,
306
312
}
307
313
308
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
314
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
309
315
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
310
316
if err != nil {
311
317
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
+
}
+24
-18
appview/repo/feed.go
+24
-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"
15
14
15
+
"github.com/bluesky-social/indigo/atproto/identity"
16
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
17
"github.com/gorilla/feeds"
18
18
)
19
19
20
-
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
20
+
func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) {
21
21
const feedLimitPerType = 100
22
22
23
-
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
23
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", repo.RepoAt()))
24
24
if err != nil {
25
25
return nil, err
26
26
}
···
28
28
issues, err := db.GetIssuesPaginated(
29
29
rp.db,
30
30
pagination.Page{Limit: feedLimitPerType},
31
-
db.FilterEq("repo_at", f.RepoAt()),
31
+
db.FilterEq("repo_at", repo.RepoAt()),
32
32
)
33
33
if err != nil {
34
34
return nil, err
35
35
}
36
36
37
37
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"},
38
+
Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo),
39
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"},
40
40
Items: make([]*feeds.Item, 0),
41
41
Updated: time.UnixMilli(0),
42
42
}
43
43
44
44
for _, pull := range pulls {
45
-
items, err := rp.createPullItems(ctx, pull, f)
45
+
items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo)
46
46
if err != nil {
47
47
return nil, err
48
48
}
···
50
50
}
51
51
52
52
for _, issue := range issues {
53
-
item, err := rp.createIssueItem(ctx, issue, f)
53
+
item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo)
54
54
if err != nil {
55
55
return nil, err
56
56
}
···
71
71
return feed, nil
72
72
}
73
73
74
-
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
75
75
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
76
76
if err != nil {
77
77
return nil, err
···
80
80
var items []*feeds.Item
81
81
82
82
state := rp.getPullState(pull)
83
-
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
83
+
description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo)
84
84
85
85
mainItem := &feeds.Item{
86
86
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
87
87
Description: description,
88
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
88
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)},
89
89
Created: pull.Created,
90
90
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
91
91
}
···
98
98
99
99
roundItem := &feeds.Item{
100
100
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)},
101
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo),
102
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)},
103
103
Created: round.Created,
104
104
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
105
105
}
···
109
109
return items, nil
110
110
}
111
111
112
-
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) {
113
113
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
114
114
if err != nil {
115
115
return nil, err
···
122
122
123
123
return &feeds.Item{
124
124
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)},
125
+
Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo),
126
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)},
127
127
Created: issue.Created,
128
128
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
129
129
}, nil
···
146
146
return fmt.Sprintf("%s in %s", base, repoName)
147
147
}
148
148
149
-
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
149
+
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
150
150
f, err := rp.repoResolver.Resolve(r)
151
151
if err != nil {
152
152
log.Println("failed to fully resolve repo:", err)
153
153
return
154
154
}
155
+
repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity)
156
+
if !ok || repoOwnerId.Handle.IsInvalidHandle() {
157
+
log.Println("failed to get resolved repo owner id")
158
+
return
159
+
}
160
+
ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name
155
161
156
-
feed, err := rp.getRepoFeed(r.Context(), f)
162
+
feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo)
157
163
if err != nil {
158
164
log.Println("failed to get repo feed:", err)
159
165
rp.pages.Error500(w)
+20
-23
appview/repo/index.go
+20
-23
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"
27
26
"tangled.org/core/types"
28
27
···
30
29
"github.com/go-enry/go-enry/v2"
31
30
)
32
31
33
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
32
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
33
l := rp.logger.With("handler", "RepoIndex")
35
34
36
35
ref := chi.URLParam(r, "ref")
···
52
51
}
53
52
54
53
user := rp.oauth.GetUser(r)
55
-
repoInfo := f.RepoInfo(user)
56
54
57
55
// Build index response from multiple XRPC calls
58
56
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
···
62
60
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
63
61
LoggedInUser: user,
64
62
NeedsKnotUpgrade: true,
65
-
RepoInfo: repoInfo,
63
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
66
64
})
67
65
return
68
66
}
···
140
138
for _, c := range commitsTrunc {
141
139
shas = append(shas, c.Hash.String())
142
140
}
143
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
141
+
pipelines, err := getPipelineStatuses(rp.db, f, shas)
144
142
if err != nil {
145
143
l.Error("failed to fetch pipeline statuses", "err", err)
146
144
// non-fatal
···
148
146
149
147
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
150
148
LoggedInUser: user,
151
-
RepoInfo: repoInfo,
149
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
152
150
TagMap: tagMap,
153
151
RepoIndexResponse: *result,
154
152
CommitsTrunc: commitsTrunc,
···
165
163
func (rp *Repo) getLanguageInfo(
166
164
ctx context.Context,
167
165
l *slog.Logger,
168
-
f *reporesolver.ResolvedRepo,
166
+
repo *models.Repo,
169
167
xrpcc *indigoxrpc.Client,
170
168
currentRef string,
171
169
isDefaultRef bool,
···
173
171
// first attempt to fetch from db
174
172
langs, err := db.GetRepoLanguages(
175
173
rp.db,
176
-
db.FilterEq("repo_at", f.RepoAt()),
174
+
db.FilterEq("repo_at", repo.RepoAt()),
177
175
db.FilterEq("ref", currentRef),
178
176
)
179
177
180
178
if err != nil || langs == nil {
181
179
// 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)
180
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
181
+
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo)
184
182
if err != nil {
185
183
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
186
184
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
···
195
193
196
194
for _, lang := range ls.Languages {
197
195
langs = append(langs, models.RepoLanguage{
198
-
RepoAt: f.RepoAt(),
196
+
RepoAt: repo.RepoAt(),
199
197
Ref: currentRef,
200
198
IsDefaultRef: isDefaultRef,
201
199
Language: lang.Name,
···
210
208
defer tx.Rollback()
211
209
212
210
// update appview's cache
213
-
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
211
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs)
214
212
if err != nil {
215
213
// non-fatal
216
214
l.Error("failed to cache lang results", "err", err)
···
255
253
}
256
254
257
255
// 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)
256
+
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) {
257
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
260
258
261
259
// first get branches to determine the ref if not specified
262
-
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
260
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo)
263
261
if err != nil {
264
262
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
265
263
}
···
303
301
wg.Add(1)
304
302
go func() {
305
303
defer wg.Done()
306
-
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
304
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo)
307
305
if err != nil {
308
306
errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err))
309
307
return
···
318
316
wg.Add(1)
319
317
go func() {
320
318
defer wg.Done()
321
-
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
319
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo)
322
320
if err != nil {
323
321
errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err))
324
322
return
···
330
328
wg.Add(1)
331
329
go func() {
332
330
defer wg.Done()
333
-
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
331
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo)
334
332
if err != nil {
335
333
errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err))
336
334
return
···
351
349
if treeResp != nil && treeResp.Files != nil {
352
350
for _, file := range treeResp.Files {
353
351
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,
352
+
Name: file.Name,
353
+
Mode: file.Mode,
354
+
Size: file.Size,
359
355
}
356
+
360
357
if file.Last_commit != nil {
361
358
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
362
359
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.GetVerifiedObjectCommits(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.NiceDiff{*result.Diff})
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
+
}
+2
-2
appview/repo/opengraph.go
+2
-2
appview/repo/opengraph.go
···
327
327
return nil
328
328
}
329
329
330
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
330
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
331
f, err := rp.repoResolver.Resolve(r)
332
332
if err != nil {
333
333
log.Println("failed to get repo and knot", err)
···
374
374
})
375
375
}
376
376
377
-
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
377
+
card, err := rp.drawRepoSummaryCard(f, languageStats)
378
378
if err != nil {
379
379
log.Println("failed to draw repo summary card", err)
380
380
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+23
-1392
appview/repo/repo.go
+23
-1392
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"
34
27
"tangled.org/core/rbac"
35
28
"tangled.org/core/tid"
36
-
"tangled.org/core/types"
37
29
"tangled.org/core/xrpc/serviceauth"
38
30
39
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
32
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
33
"github.com/bluesky-social/indigo/atproto/syntax"
42
34
lexutil "github.com/bluesky-social/indigo/lex/util"
43
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44
35
securejoin "github.com/cyphar/filepath-securejoin"
45
36
"github.com/go-chi/chi/v5"
46
-
"github.com/go-git/go-git/v5/plumbing"
47
37
)
48
38
49
39
type Repo struct {
···
88
78
}
89
79
}
90
80
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) RepoCommit(w http.ResponseWriter, r *http.Request) {
256
-
l := rp.logger.With("handler", "RepoCommit")
257
-
258
-
f, err := rp.repoResolver.Resolve(r)
259
-
if err != nil {
260
-
l.Error("failed to fully resolve repo", "err", err)
261
-
return
262
-
}
263
-
ref := chi.URLParam(r, "ref")
264
-
ref, _ = url.PathUnescape(ref)
265
-
266
-
var diffOpts types.DiffOpts
267
-
if d := r.URL.Query().Get("diff"); d == "split" {
268
-
diffOpts.Split = true
269
-
}
270
-
271
-
if !plumbing.IsHash(ref) {
272
-
rp.pages.Error404(w)
273
-
return
274
-
}
275
-
276
-
scheme := "http"
277
-
if !rp.config.Core.Dev {
278
-
scheme = "https"
279
-
}
280
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
281
-
xrpcc := &indigoxrpc.Client{
282
-
Host: host,
283
-
}
284
-
285
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
286
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
287
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
288
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
289
-
rp.pages.Error503(w)
290
-
return
291
-
}
292
-
293
-
var result types.RepoCommitResponse
294
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
295
-
l.Error("failed to decode XRPC response", "err", err)
296
-
rp.pages.Error503(w)
297
-
return
298
-
}
299
-
300
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
301
-
if err != nil {
302
-
l.Error("failed to get email to did mapping", "err", err)
303
-
}
304
-
305
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
306
-
if err != nil {
307
-
l.Error("failed to GetVerifiedCommits", "err", err)
308
-
}
309
-
310
-
user := rp.oauth.GetUser(r)
311
-
repoInfo := f.RepoInfo(user)
312
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
313
-
if err != nil {
314
-
l.Error("failed to getPipelineStatuses", "err", err)
315
-
// non-fatal
316
-
}
317
-
var pipeline *models.Pipeline
318
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
319
-
pipeline = &p
320
-
}
321
-
322
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
323
-
LoggedInUser: user,
324
-
RepoInfo: f.RepoInfo(user),
325
-
RepoCommitResponse: result,
326
-
EmailToDid: emailToDidMap,
327
-
VerifiedCommit: vc,
328
-
Pipeline: pipeline,
329
-
DiffOpts: diffOpts,
330
-
})
331
-
}
332
-
333
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
334
-
l := rp.logger.With("handler", "RepoTree")
335
-
336
-
f, err := rp.repoResolver.Resolve(r)
337
-
if err != nil {
338
-
l.Error("failed to fully resolve repo", "err", err)
339
-
return
340
-
}
341
-
342
-
ref := chi.URLParam(r, "ref")
343
-
ref, _ = url.PathUnescape(ref)
344
-
345
-
// if the tree path has a trailing slash, let's strip it
346
-
// so we don't 404
347
-
treePath := chi.URLParam(r, "*")
348
-
treePath, _ = url.PathUnescape(treePath)
349
-
treePath = strings.TrimSuffix(treePath, "/")
350
-
351
-
scheme := "http"
352
-
if !rp.config.Core.Dev {
353
-
scheme = "https"
354
-
}
355
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
356
-
xrpcc := &indigoxrpc.Client{
357
-
Host: host,
358
-
}
359
-
360
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
361
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
362
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
363
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
364
-
rp.pages.Error503(w)
365
-
return
366
-
}
367
-
368
-
// Convert XRPC response to internal types.RepoTreeResponse
369
-
files := make([]types.NiceTree, len(xrpcResp.Files))
370
-
for i, xrpcFile := range xrpcResp.Files {
371
-
file := types.NiceTree{
372
-
Name: xrpcFile.Name,
373
-
Mode: xrpcFile.Mode,
374
-
Size: int64(xrpcFile.Size),
375
-
IsFile: xrpcFile.Is_file,
376
-
IsSubtree: xrpcFile.Is_subtree,
377
-
}
378
-
379
-
// Convert last commit info if present
380
-
if xrpcFile.Last_commit != nil {
381
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
382
-
file.LastCommit = &types.LastCommitInfo{
383
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
384
-
Message: xrpcFile.Last_commit.Message,
385
-
When: commitWhen,
386
-
}
387
-
}
388
-
389
-
files[i] = file
390
-
}
391
-
392
-
result := types.RepoTreeResponse{
393
-
Ref: xrpcResp.Ref,
394
-
Files: files,
395
-
}
396
-
397
-
if xrpcResp.Parent != nil {
398
-
result.Parent = *xrpcResp.Parent
399
-
}
400
-
if xrpcResp.Dotdot != nil {
401
-
result.DotDot = *xrpcResp.Dotdot
402
-
}
403
-
if xrpcResp.Readme != nil {
404
-
result.ReadmeFileName = xrpcResp.Readme.Filename
405
-
result.Readme = xrpcResp.Readme.Contents
406
-
}
407
-
408
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
409
-
// so we can safely redirect to the "parent" (which is the same file).
410
-
if len(result.Files) == 0 && result.Parent == treePath {
411
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
412
-
http.Redirect(w, r, redirectTo, http.StatusFound)
413
-
return
414
-
}
415
-
416
-
user := rp.oauth.GetUser(r)
417
-
418
-
var breadcrumbs [][]string
419
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
420
-
if treePath != "" {
421
-
for idx, elem := range strings.Split(treePath, "/") {
422
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
423
-
}
424
-
}
425
-
426
-
sortFiles(result.Files)
427
-
428
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
429
-
LoggedInUser: user,
430
-
BreadCrumbs: breadcrumbs,
431
-
TreePath: treePath,
432
-
RepoInfo: f.RepoInfo(user),
433
-
RepoTreeResponse: result,
434
-
})
435
-
}
436
-
437
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
438
-
l := rp.logger.With("handler", "RepoTags")
439
-
440
-
f, err := rp.repoResolver.Resolve(r)
441
-
if err != nil {
442
-
l.Error("failed to get repo and knot", "err", err)
443
-
return
444
-
}
445
-
446
-
scheme := "http"
447
-
if !rp.config.Core.Dev {
448
-
scheme = "https"
449
-
}
450
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
451
-
xrpcc := &indigoxrpc.Client{
452
-
Host: host,
453
-
}
454
-
455
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
456
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
457
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
458
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
459
-
rp.pages.Error503(w)
460
-
return
461
-
}
462
-
463
-
var result types.RepoTagsResponse
464
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
465
-
l.Error("failed to decode XRPC response", "err", err)
466
-
rp.pages.Error503(w)
467
-
return
468
-
}
469
-
470
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
471
-
if err != nil {
472
-
l.Error("failed grab artifacts", "err", err)
473
-
return
474
-
}
475
-
476
-
// convert artifacts to map for easy UI building
477
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
478
-
for _, a := range artifacts {
479
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
480
-
}
481
-
482
-
var danglingArtifacts []models.Artifact
483
-
for _, a := range artifacts {
484
-
found := false
485
-
for _, t := range result.Tags {
486
-
if t.Tag != nil {
487
-
if t.Tag.Hash == a.Tag {
488
-
found = true
489
-
}
490
-
}
491
-
}
492
-
493
-
if !found {
494
-
danglingArtifacts = append(danglingArtifacts, a)
495
-
}
496
-
}
497
-
498
-
user := rp.oauth.GetUser(r)
499
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
500
-
LoggedInUser: user,
501
-
RepoInfo: f.RepoInfo(user),
502
-
RepoTagsResponse: result,
503
-
ArtifactMap: artifactMap,
504
-
DanglingArtifacts: danglingArtifacts,
505
-
})
506
-
}
507
-
508
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
509
-
l := rp.logger.With("handler", "RepoBranches")
510
-
511
-
f, err := rp.repoResolver.Resolve(r)
512
-
if err != nil {
513
-
l.Error("failed to get repo and knot", "err", err)
514
-
return
515
-
}
516
-
517
-
scheme := "http"
518
-
if !rp.config.Core.Dev {
519
-
scheme = "https"
520
-
}
521
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
522
-
xrpcc := &indigoxrpc.Client{
523
-
Host: host,
524
-
}
525
-
526
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
527
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
528
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
529
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
530
-
rp.pages.Error503(w)
531
-
return
532
-
}
533
-
534
-
var result types.RepoBranchesResponse
535
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
536
-
l.Error("failed to decode XRPC response", "err", err)
537
-
rp.pages.Error503(w)
538
-
return
539
-
}
540
-
541
-
sortBranches(result.Branches)
542
-
543
-
user := rp.oauth.GetUser(r)
544
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
545
-
LoggedInUser: user,
546
-
RepoInfo: f.RepoInfo(user),
547
-
RepoBranchesResponse: result,
548
-
})
549
-
}
550
-
551
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
552
-
l := rp.logger.With("handler", "DeleteBranch")
553
-
554
-
f, err := rp.repoResolver.Resolve(r)
555
-
if err != nil {
556
-
l.Error("failed to get repo and knot", "err", err)
557
-
return
558
-
}
559
-
560
-
noticeId := "delete-branch-error"
561
-
fail := func(msg string, err error) {
562
-
l.Error(msg, "err", err)
563
-
rp.pages.Notice(w, noticeId, msg)
564
-
}
565
-
566
-
branch := r.FormValue("branch")
567
-
if branch == "" {
568
-
fail("No branch provided.", nil)
569
-
return
570
-
}
571
-
572
-
client, err := rp.oauth.ServiceClient(
573
-
r,
574
-
oauth.WithService(f.Knot),
575
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
576
-
oauth.WithDev(rp.config.Core.Dev),
577
-
)
578
-
if err != nil {
579
-
fail("Failed to connect to knotserver", nil)
580
-
return
581
-
}
582
-
583
-
err = tangled.RepoDeleteBranch(
584
-
r.Context(),
585
-
client,
586
-
&tangled.RepoDeleteBranch_Input{
587
-
Branch: branch,
588
-
Repo: f.RepoAt().String(),
589
-
},
590
-
)
591
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
592
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
593
-
return
594
-
}
595
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
596
-
597
-
rp.pages.HxRefresh(w)
598
-
}
599
-
600
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
601
-
l := rp.logger.With("handler", "RepoBlob")
602
-
603
-
f, err := rp.repoResolver.Resolve(r)
604
-
if err != nil {
605
-
l.Error("failed to get repo and knot", "err", err)
606
-
return
607
-
}
608
-
609
-
ref := chi.URLParam(r, "ref")
610
-
ref, _ = url.PathUnescape(ref)
611
-
612
-
filePath := chi.URLParam(r, "*")
613
-
filePath, _ = url.PathUnescape(filePath)
614
-
615
-
scheme := "http"
616
-
if !rp.config.Core.Dev {
617
-
scheme = "https"
618
-
}
619
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
620
-
xrpcc := &indigoxrpc.Client{
621
-
Host: host,
622
-
}
623
-
624
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
625
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
626
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
627
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
628
-
rp.pages.Error503(w)
629
-
return
630
-
}
631
-
632
-
// Use XRPC response directly instead of converting to internal types
633
-
634
-
var breadcrumbs [][]string
635
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
636
-
if filePath != "" {
637
-
for idx, elem := range strings.Split(filePath, "/") {
638
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
639
-
}
640
-
}
641
-
642
-
showRendered := false
643
-
renderToggle := false
644
-
645
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
646
-
renderToggle = true
647
-
showRendered = r.URL.Query().Get("code") != "true"
648
-
}
649
-
650
-
var unsupported bool
651
-
var isImage bool
652
-
var isVideo bool
653
-
var contentSrc string
654
-
655
-
if resp.IsBinary != nil && *resp.IsBinary {
656
-
ext := strings.ToLower(filepath.Ext(resp.Path))
657
-
switch ext {
658
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
659
-
isImage = true
660
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
661
-
isVideo = true
662
-
default:
663
-
unsupported = true
664
-
}
665
-
666
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
667
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
668
-
669
-
baseURL := &url.URL{
670
-
Scheme: scheme,
671
-
Host: f.Knot,
672
-
Path: "/xrpc/sh.tangled.repo.blob",
673
-
}
674
-
query := baseURL.Query()
675
-
query.Set("repo", repoName)
676
-
query.Set("ref", ref)
677
-
query.Set("path", filePath)
678
-
query.Set("raw", "true")
679
-
baseURL.RawQuery = query.Encode()
680
-
blobURL := baseURL.String()
681
-
682
-
contentSrc = blobURL
683
-
if !rp.config.Core.Dev {
684
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
685
-
}
686
-
}
687
-
688
-
lines := 0
689
-
if resp.IsBinary == nil || !*resp.IsBinary {
690
-
lines = strings.Count(resp.Content, "\n") + 1
691
-
}
692
-
693
-
var sizeHint uint64
694
-
if resp.Size != nil {
695
-
sizeHint = uint64(*resp.Size)
696
-
} else {
697
-
sizeHint = uint64(len(resp.Content))
698
-
}
699
-
700
-
user := rp.oauth.GetUser(r)
701
-
702
-
// Determine if content is binary (dereference pointer)
703
-
isBinary := false
704
-
if resp.IsBinary != nil {
705
-
isBinary = *resp.IsBinary
706
-
}
707
-
708
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
709
-
LoggedInUser: user,
710
-
RepoInfo: f.RepoInfo(user),
711
-
BreadCrumbs: breadcrumbs,
712
-
ShowRendered: showRendered,
713
-
RenderToggle: renderToggle,
714
-
Unsupported: unsupported,
715
-
IsImage: isImage,
716
-
IsVideo: isVideo,
717
-
ContentSrc: contentSrc,
718
-
RepoBlob_Output: resp,
719
-
Contents: resp.Content,
720
-
Lines: lines,
721
-
SizeHint: sizeHint,
722
-
IsBinary: isBinary,
723
-
})
724
-
}
725
-
726
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
727
-
l := rp.logger.With("handler", "RepoBlobRaw")
728
-
729
-
f, err := rp.repoResolver.Resolve(r)
730
-
if err != nil {
731
-
l.Error("failed to get repo and knot", "err", err)
732
-
w.WriteHeader(http.StatusBadRequest)
733
-
return
734
-
}
735
-
736
-
ref := chi.URLParam(r, "ref")
737
-
ref, _ = url.PathUnescape(ref)
738
-
739
-
filePath := chi.URLParam(r, "*")
740
-
filePath, _ = url.PathUnescape(filePath)
741
-
742
-
scheme := "http"
743
-
if !rp.config.Core.Dev {
744
-
scheme = "https"
745
-
}
746
-
747
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
748
-
baseURL := &url.URL{
749
-
Scheme: scheme,
750
-
Host: f.Knot,
751
-
Path: "/xrpc/sh.tangled.repo.blob",
752
-
}
753
-
query := baseURL.Query()
754
-
query.Set("repo", repo)
755
-
query.Set("ref", ref)
756
-
query.Set("path", filePath)
757
-
query.Set("raw", "true")
758
-
baseURL.RawQuery = query.Encode()
759
-
blobURL := baseURL.String()
760
-
761
-
req, err := http.NewRequest("GET", blobURL, nil)
762
-
if err != nil {
763
-
l.Error("failed to create request", "err", err)
764
-
return
765
-
}
766
-
767
-
// forward the If-None-Match header
768
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
769
-
req.Header.Set("If-None-Match", clientETag)
770
-
}
771
-
772
-
client := &http.Client{}
773
-
resp, err := client.Do(req)
774
-
if err != nil {
775
-
l.Error("failed to reach knotserver", "err", err)
776
-
rp.pages.Error503(w)
777
-
return
778
-
}
779
-
defer resp.Body.Close()
780
-
781
-
// forward 304 not modified
782
-
if resp.StatusCode == http.StatusNotModified {
783
-
w.WriteHeader(http.StatusNotModified)
784
-
return
785
-
}
786
-
787
-
if resp.StatusCode != http.StatusOK {
788
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
789
-
w.WriteHeader(resp.StatusCode)
790
-
_, _ = io.Copy(w, resp.Body)
791
-
return
792
-
}
793
-
794
-
contentType := resp.Header.Get("Content-Type")
795
-
body, err := io.ReadAll(resp.Body)
796
-
if err != nil {
797
-
l.Error("error reading response body from knotserver", "err", err)
798
-
w.WriteHeader(http.StatusInternalServerError)
799
-
return
800
-
}
801
-
802
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
803
-
// serve all textual content as text/plain
804
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
805
-
w.Write(body)
806
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
807
-
// serve images and videos with their original content type
808
-
w.Header().Set("Content-Type", contentType)
809
-
w.Write(body)
810
-
} else {
811
-
w.WriteHeader(http.StatusUnsupportedMediaType)
812
-
w.Write([]byte("unsupported content type"))
813
-
return
814
-
}
815
-
}
816
-
817
-
// isTextualMimeType returns true if the MIME type represents textual content
818
-
// that should be served as text/plain
819
-
func isTextualMimeType(mimeType string) bool {
820
-
textualTypes := []string{
821
-
"application/json",
822
-
"application/xml",
823
-
"application/yaml",
824
-
"application/x-yaml",
825
-
"application/toml",
826
-
"application/javascript",
827
-
"application/ecmascript",
828
-
"message/",
829
-
}
830
-
831
-
return slices.Contains(textualTypes, mimeType)
832
-
}
833
-
834
81
// modify the spindle configured for this repo
835
82
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
836
83
user := rp.oauth.GetUser(r)
···
871
118
}
872
119
}
873
120
874
-
newRepo := f.Repo
121
+
newRepo := *f
875
122
newRepo.Spindle = newSpindle
876
123
record := newRepo.AsRecord()
877
124
···
1010
257
l.Info("wrote label record to PDS")
1011
258
1012
259
// update the repo to subscribe to this label
1013
-
newRepo := f.Repo
260
+
newRepo := *f
1014
261
newRepo.Labels = append(newRepo.Labels, aturi)
1015
262
repoRecord := newRepo.AsRecord()
1016
263
···
1122
369
}
1123
370
1124
371
// update repo record to remove the label reference
1125
-
newRepo := f.Repo
372
+
newRepo := *f
1126
373
var updated []string
1127
374
removedAt := label.AtUri().String()
1128
375
for _, l := range newRepo.Labels {
···
1215
462
return
1216
463
}
1217
464
1218
-
newRepo := f.Repo
465
+
newRepo := *f
1219
466
newRepo.Labels = append(newRepo.Labels, labelAts...)
1220
467
1221
468
// dedup
···
1230
477
return
1231
478
}
1232
479
1233
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
480
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
1234
481
if err != nil {
1235
482
fail("Failed to update labels, no record found on PDS.", err)
1236
483
return
···
1302
549
}
1303
550
1304
551
// update repo record to remove the label reference
1305
-
newRepo := f.Repo
552
+
newRepo := *f
1306
553
var updated []string
1307
554
for _, l := range newRepo.Labels {
1308
555
if !slices.Contains(labelAts, l) {
···
1318
565
return
1319
566
}
1320
567
1321
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
568
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
1322
569
if err != nil {
1323
570
fail("Failed to update labels, no record found on PDS.", err)
1324
571
return
···
1365
612
1366
613
labelDefs, err := db.GetLabelDefinitions(
1367
614
rp.db,
1368
-
db.FilterIn("at_uri", f.Repo.Labels),
615
+
db.FilterIn("at_uri", f.Labels),
1369
616
db.FilterContains("scope", subject.Collection().String()),
1370
617
)
1371
618
if err != nil {
···
1388
635
user := rp.oauth.GetUser(r)
1389
636
rp.pages.LabelPanel(w, pages.LabelPanelParams{
1390
637
LoggedInUser: user,
1391
-
RepoInfo: f.RepoInfo(user),
638
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1392
639
Defs: defs,
1393
640
Subject: subject.String(),
1394
641
State: state,
···
1413
660
1414
661
labelDefs, err := db.GetLabelDefinitions(
1415
662
rp.db,
1416
-
db.FilterIn("at_uri", f.Repo.Labels),
663
+
db.FilterIn("at_uri", f.Labels),
1417
664
db.FilterContains("scope", subject.Collection().String()),
1418
665
)
1419
666
if err != nil {
···
1436
683
user := rp.oauth.GetUser(r)
1437
684
rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1438
685
LoggedInUser: user,
1439
-
RepoInfo: f.RepoInfo(user),
686
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1440
687
Defs: defs,
1441
688
Subject: subject.String(),
1442
689
State: state,
···
1617
864
r.Context(),
1618
865
client,
1619
866
&tangled.RepoDelete_Input{
1620
-
Did: f.OwnerDid(),
867
+
Did: f.Did,
1621
868
Name: f.Name,
1622
869
Rkey: f.Rkey,
1623
870
},
···
1655
902
l.Info("removed collaborators")
1656
903
1657
904
// remove repo RBAC
1658
-
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
905
+
err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo())
1659
906
if err != nil {
1660
907
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1661
908
return
1662
909
}
1663
910
1664
911
// remove repo from db
1665
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
912
+
err = db.RemoveRepo(tx, f.Did, f.Name)
1666
913
if err != nil {
1667
914
rp.pages.Notice(w, noticeId, "Failed to update appview")
1668
915
return
···
1683
930
return
1684
931
}
1685
932
1686
-
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1687
-
}
1688
-
1689
-
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
1690
-
l := rp.logger.With("handler", "EditBaseSettings")
1691
-
1692
-
noticeId := "repo-base-settings-error"
1693
-
1694
-
f, err := rp.repoResolver.Resolve(r)
1695
-
if err != nil {
1696
-
l.Error("failed to get repo and knot", "err", err)
1697
-
w.WriteHeader(http.StatusBadRequest)
1698
-
return
1699
-
}
1700
-
1701
-
client, err := rp.oauth.AuthorizedClient(r)
1702
-
if err != nil {
1703
-
l.Error("failed to get client")
1704
-
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
1705
-
return
1706
-
}
1707
-
1708
-
var (
1709
-
description = r.FormValue("description")
1710
-
website = r.FormValue("website")
1711
-
topicStr = r.FormValue("topics")
1712
-
)
1713
-
1714
-
err = rp.validator.ValidateURI(website)
1715
-
if err != nil {
1716
-
l.Error("invalid uri", "err", err)
1717
-
rp.pages.Notice(w, noticeId, err.Error())
1718
-
return
1719
-
}
1720
-
1721
-
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
1722
-
if err != nil {
1723
-
l.Error("invalid topics", "err", err)
1724
-
rp.pages.Notice(w, noticeId, err.Error())
1725
-
return
1726
-
}
1727
-
l.Debug("got", "topicsStr", topicStr, "topics", topics)
1728
-
1729
-
newRepo := f.Repo
1730
-
newRepo.Description = description
1731
-
newRepo.Website = website
1732
-
newRepo.Topics = topics
1733
-
record := newRepo.AsRecord()
1734
-
1735
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1736
-
if err != nil {
1737
-
l.Error("failed to begin transaction", "err", err)
1738
-
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1739
-
return
1740
-
}
1741
-
defer tx.Rollback()
1742
-
1743
-
err = db.PutRepo(tx, newRepo)
1744
-
if err != nil {
1745
-
l.Error("failed to update repository", "err", err)
1746
-
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1747
-
return
1748
-
}
1749
-
1750
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1751
-
if err != nil {
1752
-
// failed to get record
1753
-
l.Error("failed to get repo record", "err", err)
1754
-
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
1755
-
return
1756
-
}
1757
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1758
-
Collection: tangled.RepoNSID,
1759
-
Repo: newRepo.Did,
1760
-
Rkey: newRepo.Rkey,
1761
-
SwapRecord: ex.Cid,
1762
-
Record: &lexutil.LexiconTypeDecoder{
1763
-
Val: &record,
1764
-
},
1765
-
})
1766
-
1767
-
if err != nil {
1768
-
l.Error("failed to perferom update-repo query", "err", err)
1769
-
// failed to get record
1770
-
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
1771
-
return
1772
-
}
1773
-
1774
-
err = tx.Commit()
1775
-
if err != nil {
1776
-
l.Error("failed to commit", "err", err)
1777
-
}
1778
-
1779
-
rp.pages.HxRefresh(w)
1780
-
}
1781
-
1782
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1783
-
l := rp.logger.With("handler", "SetDefaultBranch")
1784
-
1785
-
f, err := rp.repoResolver.Resolve(r)
1786
-
if err != nil {
1787
-
l.Error("failed to get repo and knot", "err", err)
1788
-
return
1789
-
}
1790
-
1791
-
noticeId := "operation-error"
1792
-
branch := r.FormValue("branch")
1793
-
if branch == "" {
1794
-
http.Error(w, "malformed form", http.StatusBadRequest)
1795
-
return
1796
-
}
1797
-
1798
-
client, err := rp.oauth.ServiceClient(
1799
-
r,
1800
-
oauth.WithService(f.Knot),
1801
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1802
-
oauth.WithDev(rp.config.Core.Dev),
1803
-
)
1804
-
if err != nil {
1805
-
l.Error("failed to connect to knot server", "err", err)
1806
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1807
-
return
1808
-
}
1809
-
1810
-
xe := tangled.RepoSetDefaultBranch(
1811
-
r.Context(),
1812
-
client,
1813
-
&tangled.RepoSetDefaultBranch_Input{
1814
-
Repo: f.RepoAt().String(),
1815
-
DefaultBranch: branch,
1816
-
},
1817
-
)
1818
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1819
-
l.Error("xrpc failed", "err", xe)
1820
-
rp.pages.Notice(w, noticeId, err.Error())
1821
-
return
1822
-
}
1823
-
1824
-
rp.pages.HxRefresh(w)
1825
-
}
1826
-
1827
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1828
-
user := rp.oauth.GetUser(r)
1829
-
l := rp.logger.With("handler", "Secrets")
1830
-
l = l.With("did", user.Did)
1831
-
1832
-
f, err := rp.repoResolver.Resolve(r)
1833
-
if err != nil {
1834
-
l.Error("failed to get repo and knot", "err", err)
1835
-
return
1836
-
}
1837
-
1838
-
if f.Spindle == "" {
1839
-
l.Error("empty spindle cannot add/rm secret", "err", err)
1840
-
return
1841
-
}
1842
-
1843
-
lxm := tangled.RepoAddSecretNSID
1844
-
if r.Method == http.MethodDelete {
1845
-
lxm = tangled.RepoRemoveSecretNSID
1846
-
}
1847
-
1848
-
spindleClient, err := rp.oauth.ServiceClient(
1849
-
r,
1850
-
oauth.WithService(f.Spindle),
1851
-
oauth.WithLxm(lxm),
1852
-
oauth.WithExp(60),
1853
-
oauth.WithDev(rp.config.Core.Dev),
1854
-
)
1855
-
if err != nil {
1856
-
l.Error("failed to create spindle client", "err", err)
1857
-
return
1858
-
}
1859
-
1860
-
key := r.FormValue("key")
1861
-
if key == "" {
1862
-
w.WriteHeader(http.StatusBadRequest)
1863
-
return
1864
-
}
1865
-
1866
-
switch r.Method {
1867
-
case http.MethodPut:
1868
-
errorId := "add-secret-error"
1869
-
1870
-
value := r.FormValue("value")
1871
-
if value == "" {
1872
-
w.WriteHeader(http.StatusBadRequest)
1873
-
return
1874
-
}
1875
-
1876
-
err = tangled.RepoAddSecret(
1877
-
r.Context(),
1878
-
spindleClient,
1879
-
&tangled.RepoAddSecret_Input{
1880
-
Repo: f.RepoAt().String(),
1881
-
Key: key,
1882
-
Value: value,
1883
-
},
1884
-
)
1885
-
if err != nil {
1886
-
l.Error("Failed to add secret.", "err", err)
1887
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
1888
-
return
1889
-
}
1890
-
1891
-
case http.MethodDelete:
1892
-
errorId := "operation-error"
1893
-
1894
-
err = tangled.RepoRemoveSecret(
1895
-
r.Context(),
1896
-
spindleClient,
1897
-
&tangled.RepoRemoveSecret_Input{
1898
-
Repo: f.RepoAt().String(),
1899
-
Key: key,
1900
-
},
1901
-
)
1902
-
if err != nil {
1903
-
l.Error("Failed to delete secret.", "err", err)
1904
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1905
-
return
1906
-
}
1907
-
}
1908
-
1909
-
rp.pages.HxRefresh(w)
1910
-
}
1911
-
1912
-
type tab = map[string]any
1913
-
1914
-
var (
1915
-
// would be great to have ordered maps right about now
1916
-
settingsTabs []tab = []tab{
1917
-
{"Name": "general", "Icon": "sliders-horizontal"},
1918
-
{"Name": "access", "Icon": "users"},
1919
-
{"Name": "pipelines", "Icon": "layers-2"},
1920
-
}
1921
-
)
1922
-
1923
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1924
-
tabVal := r.URL.Query().Get("tab")
1925
-
if tabVal == "" {
1926
-
tabVal = "general"
1927
-
}
1928
-
1929
-
switch tabVal {
1930
-
case "general":
1931
-
rp.generalSettings(w, r)
1932
-
1933
-
case "access":
1934
-
rp.accessSettings(w, r)
1935
-
1936
-
case "pipelines":
1937
-
rp.pipelineSettings(w, r)
1938
-
}
1939
-
}
1940
-
1941
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1942
-
l := rp.logger.With("handler", "generalSettings")
1943
-
1944
-
f, err := rp.repoResolver.Resolve(r)
1945
-
user := rp.oauth.GetUser(r)
1946
-
1947
-
scheme := "http"
1948
-
if !rp.config.Core.Dev {
1949
-
scheme = "https"
1950
-
}
1951
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1952
-
xrpcc := &indigoxrpc.Client{
1953
-
Host: host,
1954
-
}
1955
-
1956
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1957
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1958
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1959
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1960
-
rp.pages.Error503(w)
1961
-
return
1962
-
}
1963
-
1964
-
var result types.RepoBranchesResponse
1965
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1966
-
l.Error("failed to decode XRPC response", "err", err)
1967
-
rp.pages.Error503(w)
1968
-
return
1969
-
}
1970
-
1971
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1972
-
if err != nil {
1973
-
l.Error("failed to fetch labels", "err", err)
1974
-
rp.pages.Error503(w)
1975
-
return
1976
-
}
1977
-
1978
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1979
-
if err != nil {
1980
-
l.Error("failed to fetch labels", "err", err)
1981
-
rp.pages.Error503(w)
1982
-
return
1983
-
}
1984
-
// remove default labels from the labels list, if present
1985
-
defaultLabelMap := make(map[string]bool)
1986
-
for _, dl := range defaultLabels {
1987
-
defaultLabelMap[dl.AtUri().String()] = true
1988
-
}
1989
-
n := 0
1990
-
for _, l := range labels {
1991
-
if !defaultLabelMap[l.AtUri().String()] {
1992
-
labels[n] = l
1993
-
n++
1994
-
}
1995
-
}
1996
-
labels = labels[:n]
1997
-
1998
-
subscribedLabels := make(map[string]struct{})
1999
-
for _, l := range f.Repo.Labels {
2000
-
subscribedLabels[l] = struct{}{}
2001
-
}
2002
-
2003
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
2004
-
// if all default labels are subbed, show the "unsubscribe all" button
2005
-
shouldSubscribeAll := false
2006
-
for _, dl := range defaultLabels {
2007
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
2008
-
// one of the default labels is not subscribed to
2009
-
shouldSubscribeAll = true
2010
-
break
2011
-
}
2012
-
}
2013
-
2014
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
2015
-
LoggedInUser: user,
2016
-
RepoInfo: f.RepoInfo(user),
2017
-
Branches: result.Branches,
2018
-
Labels: labels,
2019
-
DefaultLabels: defaultLabels,
2020
-
SubscribedLabels: subscribedLabels,
2021
-
ShouldSubscribeAll: shouldSubscribeAll,
2022
-
Tabs: settingsTabs,
2023
-
Tab: "general",
2024
-
})
2025
-
}
2026
-
2027
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2028
-
l := rp.logger.With("handler", "accessSettings")
2029
-
2030
-
f, err := rp.repoResolver.Resolve(r)
2031
-
user := rp.oauth.GetUser(r)
2032
-
2033
-
repoCollaborators, err := f.Collaborators(r.Context())
2034
-
if err != nil {
2035
-
l.Error("failed to get collaborators", "err", err)
2036
-
}
2037
-
2038
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
2039
-
LoggedInUser: user,
2040
-
RepoInfo: f.RepoInfo(user),
2041
-
Tabs: settingsTabs,
2042
-
Tab: "access",
2043
-
Collaborators: repoCollaborators,
2044
-
})
2045
-
}
2046
-
2047
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2048
-
l := rp.logger.With("handler", "pipelineSettings")
2049
-
2050
-
f, err := rp.repoResolver.Resolve(r)
2051
-
user := rp.oauth.GetUser(r)
2052
-
2053
-
// all spindles that the repo owner is a member of
2054
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2055
-
if err != nil {
2056
-
l.Error("failed to fetch spindles", "err", err)
2057
-
return
2058
-
}
2059
-
2060
-
var secrets []*tangled.RepoListSecrets_Secret
2061
-
if f.Spindle != "" {
2062
-
if spindleClient, err := rp.oauth.ServiceClient(
2063
-
r,
2064
-
oauth.WithService(f.Spindle),
2065
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
2066
-
oauth.WithExp(60),
2067
-
oauth.WithDev(rp.config.Core.Dev),
2068
-
); err != nil {
2069
-
l.Error("failed to create spindle client", "err", err)
2070
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2071
-
l.Error("failed to fetch secrets", "err", err)
2072
-
} else {
2073
-
secrets = resp.Secrets
2074
-
}
2075
-
}
2076
-
2077
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2078
-
return strings.Compare(a.Key, b.Key)
2079
-
})
2080
-
2081
-
var dids []string
2082
-
for _, s := range secrets {
2083
-
dids = append(dids, s.CreatedBy)
2084
-
}
2085
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2086
-
2087
-
// convert to a more manageable form
2088
-
var niceSecret []map[string]any
2089
-
for id, s := range secrets {
2090
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2091
-
niceSecret = append(niceSecret, map[string]any{
2092
-
"Id": id,
2093
-
"Key": s.Key,
2094
-
"CreatedAt": when,
2095
-
"CreatedBy": resolvedIdents[id].Handle.String(),
2096
-
})
2097
-
}
2098
-
2099
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2100
-
LoggedInUser: user,
2101
-
RepoInfo: f.RepoInfo(user),
2102
-
Tabs: settingsTabs,
2103
-
Tab: "pipelines",
2104
-
Spindles: spindles,
2105
-
CurrentSpindle: f.Spindle,
2106
-
Secrets: niceSecret,
2107
-
})
933
+
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did))
2108
934
}
2109
935
2110
936
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
2133
959
return
2134
960
}
2135
961
2136
-
repoInfo := f.RepoInfo(user)
2137
-
if repoInfo.Source == nil {
962
+
if f.Source == "" {
2138
963
rp.pages.Notice(w, "repo", "This repository is not a fork.")
2139
964
return
2140
965
}
···
2145
970
&tangled.RepoForkSync_Input{
2146
971
Did: user.Did,
2147
972
Name: f.Name,
2148
-
Source: repoInfo.Source.RepoAt().String(),
973
+
Source: f.Source,
2149
974
Branch: ref,
2150
975
},
2151
976
)
···
2181
1006
rp.pages.ForkRepo(w, pages.ForkRepoParams{
2182
1007
LoggedInUser: user,
2183
1008
Knots: knots,
2184
-
RepoInfo: f.RepoInfo(user),
1009
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
2185
1010
})
2186
1011
2187
1012
case http.MethodPost:
···
2232
1057
uri = "http"
2233
1058
}
2234
1059
2235
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1060
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name)
2236
1061
l = l.With("cloneUrl", forkSourceUrl)
2237
1062
2238
1063
sourceAt := f.RepoAt().String()
···
2245
1070
Knot: targetKnot,
2246
1071
Rkey: rkey,
2247
1072
Source: sourceAt,
2248
-
Description: f.Repo.Description,
1073
+
Description: f.Description,
2249
1074
Created: time.Now(),
2250
-
Labels: models.DefaultLabelDefs(),
1075
+
Labels: rp.config.Label.DefaultLabelDefs,
2251
1076
}
2252
1077
record := repo.AsRecord()
2253
1078
···
2304
1129
}
2305
1130
defer rollback()
2306
1131
1132
+
// TODO: this could coordinate better with the knot to recieve a clone status
2307
1133
client, err := rp.oauth.ServiceClient(
2308
1134
r,
2309
1135
oauth.WithService(targetKnot),
2310
1136
oauth.WithLxm(tangled.RepoCreateNSID),
2311
1137
oauth.WithDev(rp.config.Core.Dev),
1138
+
oauth.WithTimeout(time.Second*20), // big repos take time to clone
2312
1139
)
2313
1140
if err != nil {
2314
1141
l.Error("could not create service client", "err", err)
···
2388
1215
})
2389
1216
return err
2390
1217
}
2391
-
2392
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2393
-
l := rp.logger.With("handler", "RepoCompareNew")
2394
-
2395
-
user := rp.oauth.GetUser(r)
2396
-
f, err := rp.repoResolver.Resolve(r)
2397
-
if err != nil {
2398
-
l.Error("failed to get repo and knot", "err", err)
2399
-
return
2400
-
}
2401
-
2402
-
scheme := "http"
2403
-
if !rp.config.Core.Dev {
2404
-
scheme = "https"
2405
-
}
2406
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2407
-
xrpcc := &indigoxrpc.Client{
2408
-
Host: host,
2409
-
}
2410
-
2411
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2412
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2413
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2414
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2415
-
rp.pages.Error503(w)
2416
-
return
2417
-
}
2418
-
2419
-
var branchResult types.RepoBranchesResponse
2420
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2421
-
l.Error("failed to decode XRPC branches response", "err", err)
2422
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2423
-
return
2424
-
}
2425
-
branches := branchResult.Branches
2426
-
2427
-
sortBranches(branches)
2428
-
2429
-
var defaultBranch string
2430
-
for _, b := range branches {
2431
-
if b.IsDefault {
2432
-
defaultBranch = b.Name
2433
-
}
2434
-
}
2435
-
2436
-
base := defaultBranch
2437
-
head := defaultBranch
2438
-
2439
-
params := r.URL.Query()
2440
-
queryBase := params.Get("base")
2441
-
queryHead := params.Get("head")
2442
-
if queryBase != "" {
2443
-
base = queryBase
2444
-
}
2445
-
if queryHead != "" {
2446
-
head = queryHead
2447
-
}
2448
-
2449
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2450
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2451
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2452
-
rp.pages.Error503(w)
2453
-
return
2454
-
}
2455
-
2456
-
var tags types.RepoTagsResponse
2457
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2458
-
l.Error("failed to decode XRPC tags response", "err", err)
2459
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2460
-
return
2461
-
}
2462
-
2463
-
repoinfo := f.RepoInfo(user)
2464
-
2465
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2466
-
LoggedInUser: user,
2467
-
RepoInfo: repoinfo,
2468
-
Branches: branches,
2469
-
Tags: tags.Tags,
2470
-
Base: base,
2471
-
Head: head,
2472
-
})
2473
-
}
2474
-
2475
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2476
-
l := rp.logger.With("handler", "RepoCompare")
2477
-
2478
-
user := rp.oauth.GetUser(r)
2479
-
f, err := rp.repoResolver.Resolve(r)
2480
-
if err != nil {
2481
-
l.Error("failed to get repo and knot", "err", err)
2482
-
return
2483
-
}
2484
-
2485
-
var diffOpts types.DiffOpts
2486
-
if d := r.URL.Query().Get("diff"); d == "split" {
2487
-
diffOpts.Split = true
2488
-
}
2489
-
2490
-
// if user is navigating to one of
2491
-
// /compare/{base}/{head}
2492
-
// /compare/{base}...{head}
2493
-
base := chi.URLParam(r, "base")
2494
-
head := chi.URLParam(r, "head")
2495
-
if base == "" && head == "" {
2496
-
rest := chi.URLParam(r, "*") // master...feature/xyz
2497
-
parts := strings.SplitN(rest, "...", 2)
2498
-
if len(parts) == 2 {
2499
-
base = parts[0]
2500
-
head = parts[1]
2501
-
}
2502
-
}
2503
-
2504
-
base, _ = url.PathUnescape(base)
2505
-
head, _ = url.PathUnescape(head)
2506
-
2507
-
if base == "" || head == "" {
2508
-
l.Error("invalid comparison")
2509
-
rp.pages.Error404(w)
2510
-
return
2511
-
}
2512
-
2513
-
scheme := "http"
2514
-
if !rp.config.Core.Dev {
2515
-
scheme = "https"
2516
-
}
2517
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2518
-
xrpcc := &indigoxrpc.Client{
2519
-
Host: host,
2520
-
}
2521
-
2522
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2523
-
2524
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2525
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2526
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2527
-
rp.pages.Error503(w)
2528
-
return
2529
-
}
2530
-
2531
-
var branches types.RepoBranchesResponse
2532
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2533
-
l.Error("failed to decode XRPC branches response", "err", err)
2534
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2535
-
return
2536
-
}
2537
-
2538
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2539
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2540
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2541
-
rp.pages.Error503(w)
2542
-
return
2543
-
}
2544
-
2545
-
var tags types.RepoTagsResponse
2546
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2547
-
l.Error("failed to decode XRPC tags response", "err", err)
2548
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2549
-
return
2550
-
}
2551
-
2552
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2553
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2554
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2555
-
rp.pages.Error503(w)
2556
-
return
2557
-
}
2558
-
2559
-
var formatPatch types.RepoFormatPatchResponse
2560
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2561
-
l.Error("failed to decode XRPC compare response", "err", err)
2562
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2563
-
return
2564
-
}
2565
-
2566
-
var diff types.NiceDiff
2567
-
if formatPatch.CombinedPatchRaw != "" {
2568
-
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2569
-
} else {
2570
-
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2571
-
}
2572
-
2573
-
repoinfo := f.RepoInfo(user)
2574
-
2575
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2576
-
LoggedInUser: user,
2577
-
RepoInfo: repoinfo,
2578
-
Branches: branches.Branches,
2579
-
Tags: tags.Tags,
2580
-
Base: base,
2581
-
Head: head,
2582
-
Diff: &diff,
2583
-
DiffOpts: diffOpts,
2584
-
})
2585
-
2586
-
}
+7
-21
appview/repo/repo_util.go
+7
-21
appview/repo/repo_util.go
···
1
1
package repo
2
2
3
3
import (
4
-
"crypto/rand"
5
-
"math/big"
6
4
"slices"
7
5
"sort"
8
6
"strings"
9
7
10
8
"tangled.org/core/appview/db"
11
9
"tangled.org/core/appview/models"
12
-
"tangled.org/core/appview/pages/repoinfo"
13
10
"tangled.org/core/types"
14
11
15
12
"github.com/go-git/go-git/v5/plumbing/object"
···
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
}
···
90
87
return
91
88
}
92
89
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
90
// grab pipelines from DB and munge that into a hashmap with commit sha as key
106
91
//
107
92
// golang is so blessed that it requires 35 lines of imperative code for this
108
93
func getPipelineStatuses(
109
94
d *db.DB,
110
-
repoInfo repoinfo.RepoInfo,
95
+
repo *models.Repo,
111
96
shas []string,
112
97
) (map[string]models.Pipeline, error) {
113
98
m := make(map[string]models.Pipeline)
···
118
103
119
104
ps, err := db.GetPipelineStatuses(
120
105
d,
121
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
122
-
db.FilterEq("repo_name", repoInfo.Name),
123
-
db.FilterEq("knot", repoInfo.Knot),
106
+
len(shas),
107
+
db.FilterEq("repo_owner", repo.Did),
108
+
db.FilterEq("repo_name", repo.Name),
109
+
db.FilterEq("knot", repo.Knot),
124
110
db.FilterIn("sha", shas),
125
111
)
126
112
if err != nil {
+13
-14
appview/repo/router.go
+13
-14
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
···
75
74
r.Group(func(r chi.Router) {
76
75
r.Use(middleware.AuthMiddleware(rp.oauth))
77
76
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
78
-
r.Get("/", rp.RepoSettings)
77
+
r.Get("/", rp.Settings)
79
78
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
80
79
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
81
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
+470
appview/repo/settings.go
+470
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/types"
18
+
19
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
20
+
lexutil "github.com/bluesky-social/indigo/lex/util"
21
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
+
)
23
+
24
+
type tab = map[string]any
25
+
26
+
var (
27
+
// would be great to have ordered maps right about now
28
+
settingsTabs []tab = []tab{
29
+
{"Name": "general", "Icon": "sliders-horizontal"},
30
+
{"Name": "access", "Icon": "users"},
31
+
{"Name": "pipelines", "Icon": "layers-2"},
32
+
}
33
+
)
34
+
35
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
36
+
l := rp.logger.With("handler", "SetDefaultBranch")
37
+
38
+
f, err := rp.repoResolver.Resolve(r)
39
+
if err != nil {
40
+
l.Error("failed to get repo and knot", "err", err)
41
+
return
42
+
}
43
+
44
+
noticeId := "operation-error"
45
+
branch := r.FormValue("branch")
46
+
if branch == "" {
47
+
http.Error(w, "malformed form", http.StatusBadRequest)
48
+
return
49
+
}
50
+
51
+
client, err := rp.oauth.ServiceClient(
52
+
r,
53
+
oauth.WithService(f.Knot),
54
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
55
+
oauth.WithDev(rp.config.Core.Dev),
56
+
)
57
+
if err != nil {
58
+
l.Error("failed to connect to knot server", "err", err)
59
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
60
+
return
61
+
}
62
+
63
+
xe := tangled.RepoSetDefaultBranch(
64
+
r.Context(),
65
+
client,
66
+
&tangled.RepoSetDefaultBranch_Input{
67
+
Repo: f.RepoAt().String(),
68
+
DefaultBranch: branch,
69
+
},
70
+
)
71
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
72
+
l.Error("xrpc failed", "err", xe)
73
+
rp.pages.Notice(w, noticeId, err.Error())
74
+
return
75
+
}
76
+
77
+
rp.pages.HxRefresh(w)
78
+
}
79
+
80
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
81
+
user := rp.oauth.GetUser(r)
82
+
l := rp.logger.With("handler", "Secrets")
83
+
l = l.With("did", user.Did)
84
+
85
+
f, err := rp.repoResolver.Resolve(r)
86
+
if err != nil {
87
+
l.Error("failed to get repo and knot", "err", err)
88
+
return
89
+
}
90
+
91
+
if f.Spindle == "" {
92
+
l.Error("empty spindle cannot add/rm secret", "err", err)
93
+
return
94
+
}
95
+
96
+
lxm := tangled.RepoAddSecretNSID
97
+
if r.Method == http.MethodDelete {
98
+
lxm = tangled.RepoRemoveSecretNSID
99
+
}
100
+
101
+
spindleClient, err := rp.oauth.ServiceClient(
102
+
r,
103
+
oauth.WithService(f.Spindle),
104
+
oauth.WithLxm(lxm),
105
+
oauth.WithExp(60),
106
+
oauth.WithDev(rp.config.Core.Dev),
107
+
)
108
+
if err != nil {
109
+
l.Error("failed to create spindle client", "err", err)
110
+
return
111
+
}
112
+
113
+
key := r.FormValue("key")
114
+
if key == "" {
115
+
w.WriteHeader(http.StatusBadRequest)
116
+
return
117
+
}
118
+
119
+
switch r.Method {
120
+
case http.MethodPut:
121
+
errorId := "add-secret-error"
122
+
123
+
value := r.FormValue("value")
124
+
if value == "" {
125
+
w.WriteHeader(http.StatusBadRequest)
126
+
return
127
+
}
128
+
129
+
err = tangled.RepoAddSecret(
130
+
r.Context(),
131
+
spindleClient,
132
+
&tangled.RepoAddSecret_Input{
133
+
Repo: f.RepoAt().String(),
134
+
Key: key,
135
+
Value: value,
136
+
},
137
+
)
138
+
if err != nil {
139
+
l.Error("Failed to add secret.", "err", err)
140
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
141
+
return
142
+
}
143
+
144
+
case http.MethodDelete:
145
+
errorId := "operation-error"
146
+
147
+
err = tangled.RepoRemoveSecret(
148
+
r.Context(),
149
+
spindleClient,
150
+
&tangled.RepoRemoveSecret_Input{
151
+
Repo: f.RepoAt().String(),
152
+
Key: key,
153
+
},
154
+
)
155
+
if err != nil {
156
+
l.Error("Failed to delete secret.", "err", err)
157
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
158
+
return
159
+
}
160
+
}
161
+
162
+
rp.pages.HxRefresh(w)
163
+
}
164
+
165
+
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
166
+
tabVal := r.URL.Query().Get("tab")
167
+
if tabVal == "" {
168
+
tabVal = "general"
169
+
}
170
+
171
+
switch tabVal {
172
+
case "general":
173
+
rp.generalSettings(w, r)
174
+
175
+
case "access":
176
+
rp.accessSettings(w, r)
177
+
178
+
case "pipelines":
179
+
rp.pipelineSettings(w, r)
180
+
}
181
+
}
182
+
183
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
184
+
l := rp.logger.With("handler", "generalSettings")
185
+
186
+
f, err := rp.repoResolver.Resolve(r)
187
+
user := rp.oauth.GetUser(r)
188
+
189
+
scheme := "http"
190
+
if !rp.config.Core.Dev {
191
+
scheme = "https"
192
+
}
193
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
194
+
xrpcc := &indigoxrpc.Client{
195
+
Host: host,
196
+
}
197
+
198
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
199
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
200
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
201
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
202
+
rp.pages.Error503(w)
203
+
return
204
+
}
205
+
206
+
var result types.RepoBranchesResponse
207
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
208
+
l.Error("failed to decode XRPC response", "err", err)
209
+
rp.pages.Error503(w)
210
+
return
211
+
}
212
+
213
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
214
+
if err != nil {
215
+
l.Error("failed to fetch labels", "err", err)
216
+
rp.pages.Error503(w)
217
+
return
218
+
}
219
+
220
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Labels))
221
+
if err != nil {
222
+
l.Error("failed to fetch labels", "err", err)
223
+
rp.pages.Error503(w)
224
+
return
225
+
}
226
+
// remove default labels from the labels list, if present
227
+
defaultLabelMap := make(map[string]bool)
228
+
for _, dl := range defaultLabels {
229
+
defaultLabelMap[dl.AtUri().String()] = true
230
+
}
231
+
n := 0
232
+
for _, l := range labels {
233
+
if !defaultLabelMap[l.AtUri().String()] {
234
+
labels[n] = l
235
+
n++
236
+
}
237
+
}
238
+
labels = labels[:n]
239
+
240
+
subscribedLabels := make(map[string]struct{})
241
+
for _, l := range f.Labels {
242
+
subscribedLabels[l] = struct{}{}
243
+
}
244
+
245
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
246
+
// if all default labels are subbed, show the "unsubscribe all" button
247
+
shouldSubscribeAll := false
248
+
for _, dl := range defaultLabels {
249
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
250
+
// one of the default labels is not subscribed to
251
+
shouldSubscribeAll = true
252
+
break
253
+
}
254
+
}
255
+
256
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
257
+
LoggedInUser: user,
258
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
259
+
Branches: result.Branches,
260
+
Labels: labels,
261
+
DefaultLabels: defaultLabels,
262
+
SubscribedLabels: subscribedLabels,
263
+
ShouldSubscribeAll: shouldSubscribeAll,
264
+
Tabs: settingsTabs,
265
+
Tab: "general",
266
+
})
267
+
}
268
+
269
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
270
+
l := rp.logger.With("handler", "accessSettings")
271
+
272
+
f, err := rp.repoResolver.Resolve(r)
273
+
user := rp.oauth.GetUser(r)
274
+
275
+
collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) {
276
+
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot)
277
+
if err != nil {
278
+
return nil, err
279
+
}
280
+
var collaborators []pages.Collaborator
281
+
for _, item := range repoCollaborators {
282
+
// currently only two roles: owner and member
283
+
var role string
284
+
switch item[3] {
285
+
case "repo:owner":
286
+
role = "owner"
287
+
case "repo:collaborator":
288
+
role = "collaborator"
289
+
default:
290
+
continue
291
+
}
292
+
293
+
did := item[0]
294
+
295
+
c := pages.Collaborator{
296
+
Did: did,
297
+
Role: role,
298
+
}
299
+
collaborators = append(collaborators, c)
300
+
}
301
+
return collaborators, nil
302
+
}(f)
303
+
if err != nil {
304
+
l.Error("failed to get collaborators", "err", err)
305
+
}
306
+
307
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
308
+
LoggedInUser: user,
309
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
310
+
Tabs: settingsTabs,
311
+
Tab: "access",
312
+
Collaborators: collaborators,
313
+
})
314
+
}
315
+
316
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
317
+
l := rp.logger.With("handler", "pipelineSettings")
318
+
319
+
f, err := rp.repoResolver.Resolve(r)
320
+
user := rp.oauth.GetUser(r)
321
+
322
+
// all spindles that the repo owner is a member of
323
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
324
+
if err != nil {
325
+
l.Error("failed to fetch spindles", "err", err)
326
+
return
327
+
}
328
+
329
+
var secrets []*tangled.RepoListSecrets_Secret
330
+
if f.Spindle != "" {
331
+
if spindleClient, err := rp.oauth.ServiceClient(
332
+
r,
333
+
oauth.WithService(f.Spindle),
334
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
335
+
oauth.WithExp(60),
336
+
oauth.WithDev(rp.config.Core.Dev),
337
+
); err != nil {
338
+
l.Error("failed to create spindle client", "err", err)
339
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
340
+
l.Error("failed to fetch secrets", "err", err)
341
+
} else {
342
+
secrets = resp.Secrets
343
+
}
344
+
}
345
+
346
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
347
+
return strings.Compare(a.Key, b.Key)
348
+
})
349
+
350
+
var dids []string
351
+
for _, s := range secrets {
352
+
dids = append(dids, s.CreatedBy)
353
+
}
354
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
355
+
356
+
// convert to a more manageable form
357
+
var niceSecret []map[string]any
358
+
for id, s := range secrets {
359
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
360
+
niceSecret = append(niceSecret, map[string]any{
361
+
"Id": id,
362
+
"Key": s.Key,
363
+
"CreatedAt": when,
364
+
"CreatedBy": resolvedIdents[id].Handle.String(),
365
+
})
366
+
}
367
+
368
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
369
+
LoggedInUser: user,
370
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
371
+
Tabs: settingsTabs,
372
+
Tab: "pipelines",
373
+
Spindles: spindles,
374
+
CurrentSpindle: f.Spindle,
375
+
Secrets: niceSecret,
376
+
})
377
+
}
378
+
379
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
380
+
l := rp.logger.With("handler", "EditBaseSettings")
381
+
382
+
noticeId := "repo-base-settings-error"
383
+
384
+
f, err := rp.repoResolver.Resolve(r)
385
+
if err != nil {
386
+
l.Error("failed to get repo and knot", "err", err)
387
+
w.WriteHeader(http.StatusBadRequest)
388
+
return
389
+
}
390
+
391
+
client, err := rp.oauth.AuthorizedClient(r)
392
+
if err != nil {
393
+
l.Error("failed to get client")
394
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
395
+
return
396
+
}
397
+
398
+
var (
399
+
description = r.FormValue("description")
400
+
website = r.FormValue("website")
401
+
topicStr = r.FormValue("topics")
402
+
)
403
+
404
+
err = rp.validator.ValidateURI(website)
405
+
if website != "" && err != nil {
406
+
l.Error("invalid uri", "err", err)
407
+
rp.pages.Notice(w, noticeId, err.Error())
408
+
return
409
+
}
410
+
411
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
412
+
if err != nil {
413
+
l.Error("invalid topics", "err", err)
414
+
rp.pages.Notice(w, noticeId, err.Error())
415
+
return
416
+
}
417
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
418
+
419
+
newRepo := *f
420
+
newRepo.Description = description
421
+
newRepo.Website = website
422
+
newRepo.Topics = topics
423
+
record := newRepo.AsRecord()
424
+
425
+
tx, err := rp.db.BeginTx(r.Context(), nil)
426
+
if err != nil {
427
+
l.Error("failed to begin transaction", "err", err)
428
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
429
+
return
430
+
}
431
+
defer tx.Rollback()
432
+
433
+
err = db.PutRepo(tx, newRepo)
434
+
if err != nil {
435
+
l.Error("failed to update repository", "err", err)
436
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
437
+
return
438
+
}
439
+
440
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
441
+
if err != nil {
442
+
// failed to get record
443
+
l.Error("failed to get repo record", "err", err)
444
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
445
+
return
446
+
}
447
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
448
+
Collection: tangled.RepoNSID,
449
+
Repo: newRepo.Did,
450
+
Rkey: newRepo.Rkey,
451
+
SwapRecord: ex.Cid,
452
+
Record: &lexutil.LexiconTypeDecoder{
453
+
Val: &record,
454
+
},
455
+
})
456
+
457
+
if err != nil {
458
+
l.Error("failed to perferom update-repo query", "err", err)
459
+
// failed to get record
460
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
461
+
return
462
+
}
463
+
464
+
err = tx.Commit()
465
+
if err != nil {
466
+
l.Error("failed to commit", "err", err)
467
+
}
468
+
469
+
rp.pages.HxRefresh(w)
470
+
}
+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
-164
appview/reporesolver/resolver.go
+76
-164
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
70
-
}
71
-
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()
51
+
return repo, nil
78
52
}
79
53
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
-
Website: f.Website,
192
-
Topics: f.Topics,
193
-
IsStarred: isStarred,
194
-
Knot: knot,
195
-
Spindle: f.Spindle,
196
-
Roles: f.RolesInRepo(user),
197
-
Stats: models.RepoStats{
198
-
StarCount: starCount,
199
-
IssueCount: issueCount,
200
-
PullCount: pullCount,
201
-
},
202
-
CurrentDir: f.CurrentDir,
203
-
Ref: f.Ref,
204
-
}
123
+
// page context
124
+
CurrentDir: currentDir,
125
+
Ref: ref,
205
126
206
-
if sourceRepo != nil {
207
-
repoInfo.Source = sourceRepo
208
-
repoInfo.SourceHandle = sourceHandle.Handle.String()
127
+
// info related to the session
128
+
IsStarred: isStarred,
129
+
Roles: roles,
209
130
}
210
131
211
132
return repoInfo
212
-
}
213
-
214
-
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
215
-
if u != nil {
216
-
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
217
-
return repoinfo.RolesInRepo{Roles: r}
218
-
} else {
219
-
return repoinfo.RolesInRepo{}
220
-
}
221
133
}
222
134
223
135
// extractPathAfterRef gets the actual repository path
+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
+19
-2
appview/spindles/spindles.go
+19
-2
appview/spindles/spindles.go
···
38
38
Logger *slog.Logger
39
39
}
40
40
41
+
type tab = map[string]any
42
+
43
+
var (
44
+
spindlesTabs []tab = []tab{
45
+
{"Name": "profile", "Icon": "user"},
46
+
{"Name": "keys", "Icon": "key"},
47
+
{"Name": "emails", "Icon": "mail"},
48
+
{"Name": "notifications", "Icon": "bell"},
49
+
{"Name": "knots", "Icon": "volleyball"},
50
+
{"Name": "spindles", "Icon": "spool"},
51
+
}
52
+
)
53
+
41
54
func (s *Spindles) Router() http.Handler {
42
55
r := chi.NewRouter()
43
56
···
69
82
s.Pages.Spindles(w, pages.SpindlesParams{
70
83
LoggedInUser: user,
71
84
Spindles: all,
85
+
Tabs: spindlesTabs,
86
+
Tab: "spindles",
72
87
})
73
88
}
74
89
···
127
142
Spindle: spindle,
128
143
Members: members,
129
144
Repos: repoMap,
145
+
Tabs: spindlesTabs,
146
+
Tab: "spindles",
130
147
})
131
148
}
132
149
···
365
382
366
383
shouldRedirect := r.Header.Get("shouldRedirect")
367
384
if shouldRedirect == "true" {
368
-
s.Pages.HxRedirect(w, "/spindles")
385
+
s.Pages.HxRedirect(w, "/settings/spindles")
369
386
return
370
387
}
371
388
···
581
598
}
582
599
583
600
// success
584
-
s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance))
601
+
s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance))
585
602
}
586
603
587
604
func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
+10
-4
appview/state/gfi.go
+10
-4
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"
···
20
18
21
19
page := pagination.FromContext(r.Context())
22
20
23
-
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
21
+
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
22
+
23
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
24
+
if err != nil {
25
+
log.Println("failed to get gfi label def", err)
26
+
s.pages.Error500(w)
27
+
return
28
+
}
24
29
25
30
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
26
31
if err != nil {
···
35
40
RepoGroups: []*models.RepoGroup{},
36
41
LabelDefs: make(map[string]*models.LabelDefinition),
37
42
Page: page,
43
+
GfiLabel: gfiLabelDef,
38
44
})
39
45
return
40
46
}
···
143
149
RepoGroups: paginatedGroups,
144
150
LabelDefs: labelDefsMap,
145
151
Page: page,
146
-
GfiLabel: labelDefsMap[goodFirstIssueLabel],
152
+
GfiLabel: gfiLabelDef,
147
153
})
148
154
}
+1
appview/state/login.go
+1
appview/state/login.go
+17
-11
appview/state/profile.go
+17
-11
appview/state/profile.go
···
66
66
return nil, fmt.Errorf("failed to get string count: %w", err)
67
67
}
68
68
69
-
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
69
+
starredCount, err := db.CountStars(s.db, db.FilterEq("did", did))
70
70
if err != nil {
71
71
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
72
}
···
96
96
97
97
return &pages.ProfileCard{
98
98
UserDid: did,
99
-
UserHandle: ident.Handle.String(),
100
99
Profile: profile,
101
100
FollowStatus: followStatus,
102
101
Stats: pages.ProfileStats{
···
119
118
s.pages.Error500(w)
120
119
return
121
120
}
122
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
121
+
l = l.With("profileDid", profile.UserDid)
123
122
124
123
repos, err := db.GetRepos(
125
124
s.db,
···
160
159
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
161
160
if err != nil {
162
161
l.Error("failed to create timeline", "err", err)
162
+
}
163
+
164
+
// populate commit counts in the timeline, using the punchcard
165
+
currentMonth := time.Now().Month()
166
+
for _, p := range profile.Punchcard.Punches {
167
+
idx := currentMonth - p.Date.Month()
168
+
if int(idx) < len(timeline.ByMonth) {
169
+
timeline.ByMonth[idx].Commits += p.Count
170
+
}
163
171
}
164
172
165
173
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
···
180
188
s.pages.Error500(w)
181
189
return
182
190
}
183
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
191
+
l = l.With("profileDid", profile.UserDid)
184
192
185
193
repos, err := db.GetRepos(
186
194
s.db,
···
209
217
s.pages.Error500(w)
210
218
return
211
219
}
212
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
220
+
l = l.With("profileDid", profile.UserDid)
213
221
214
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
222
+
stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid))
215
223
if err != nil {
216
224
l.Error("failed to get stars", "err", err)
217
225
s.pages.Error500(w)
···
219
227
}
220
228
var repos []models.Repo
221
229
for _, s := range stars {
222
-
if s.Repo != nil {
223
-
repos = append(repos, *s.Repo)
224
-
}
230
+
repos = append(repos, *s.Repo)
225
231
}
226
232
227
233
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
240
246
s.pages.Error500(w)
241
247
return
242
248
}
243
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
249
+
l = l.With("profileDid", profile.UserDid)
244
250
245
251
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
246
252
if err != nil {
···
272
278
if err != nil {
273
279
return nil, err
274
280
}
275
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
281
+
l = l.With("profileDid", profile.UserDid)
276
282
277
283
loggedInUser := s.oauth.GetUser(r)
278
284
params := FollowsPageParams{
+19
-11
appview/state/router.go
+19
-11
appview/state/router.go
···
57
57
if userutil.IsFlattenedDid(firstPart) {
58
58
unflattenedDid := userutil.UnflattenDid(firstPart)
59
59
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
60
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
60
+
61
+
redirectURL := *r.URL
62
+
redirectURL.Path = "/" + redirectPath
63
+
64
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
61
65
return
62
66
}
63
67
64
68
// if using a handle with @, rewrite to work without @
65
69
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
66
70
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
67
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
71
+
72
+
redirectURL := *r.URL
73
+
redirectURL.Path = "/" + redirectPath
74
+
75
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
68
76
return
69
77
}
78
+
70
79
}
71
80
72
81
standardRouter.ServeHTTP(w, r)
···
82
91
r.Get("/", s.Profile)
83
92
r.Get("/feed.atom", s.AtomFeedPage)
84
93
85
-
// redirect /@handle/repo.git -> /@handle/repo
86
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
87
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
88
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
89
-
})
90
-
91
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
92
95
r.Use(mw.GoImport())
93
96
r.Mount("/", s.RepoRouter(mw))
···
136
139
// r.Post("/import", s.ImportRepo)
137
140
})
138
141
139
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
142
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
140
143
141
144
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
142
145
r.Post("/", s.Follow)
···
163
166
164
167
r.Mount("/settings", s.SettingsRouter())
165
168
r.Mount("/strings", s.StringsRouter(mw))
166
-
r.Mount("/knots", s.KnotsRouter())
167
-
r.Mount("/spindles", s.SpindlesRouter())
169
+
170
+
r.Mount("/settings/knots", s.KnotsRouter())
171
+
r.Mount("/settings/spindles", s.SpindlesRouter())
172
+
168
173
r.Mount("/notifications", s.NotificationsRouter(mw))
169
174
170
175
r.Mount("/signup", s.SignupRouter())
···
258
263
issues := issues.New(
259
264
s.oauth,
260
265
s.repoResolver,
266
+
s.enforcer,
261
267
s.pages,
262
268
s.idResolver,
269
+
s.refResolver,
263
270
s.db,
264
271
s.config,
265
272
s.notifier,
···
276
283
s.repoResolver,
277
284
s.pages,
278
285
s.idResolver,
286
+
s.refResolver,
279
287
s.db,
280
288
s.config,
281
289
s.notifier,
+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
+13
-9
appview/state/state.go
+13
-9
appview/state/state.go
···
21
21
phnotify "tangled.org/core/appview/notify/posthog"
22
22
"tangled.org/core/appview/oauth"
23
23
"tangled.org/core/appview/pages"
24
+
"tangled.org/core/appview/refresolver"
24
25
"tangled.org/core/appview/reporesolver"
25
26
"tangled.org/core/appview/validator"
26
27
xrpcclient "tangled.org/core/appview/xrpcclient"
···
49
50
enforcer *rbac.Enforcer
50
51
pages *pages.Pages
51
52
idResolver *idresolver.Resolver
53
+
refResolver *refresolver.Resolver
52
54
posthog posthog.Client
53
55
jc *jetstream.JetstreamClient
54
56
config *config.Config
···
78
80
return nil, fmt.Errorf("failed to create enforcer: %w", err)
79
81
}
80
82
81
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
83
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
82
84
if err != nil {
83
85
logger.Error("failed to create redis resolver", "err", err)
84
-
res = idresolver.DefaultResolver()
86
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
85
87
}
86
88
87
89
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
96
98
}
97
99
validator := validator.New(d, res, enforcer)
98
100
99
-
repoResolver := reporesolver.New(config, enforcer, res, d)
101
+
repoResolver := reporesolver.New(config, enforcer, d)
102
+
103
+
refResolver := refresolver.New(config, res, d, log.SubLogger(logger, "refResolver"))
100
104
101
105
wrapper := db.DbWrapper{Execer: d}
102
106
jc, err := jetstream.NewJetstreamClient(
···
129
133
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
130
134
}
131
135
132
-
if err := BackfillDefaultDefs(d, res); err != nil {
136
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
133
137
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
134
138
}
135
139
···
178
182
enforcer,
179
183
pages,
180
184
res,
185
+
refResolver,
181
186
posthog,
182
187
jc,
183
188
config,
···
294
299
return
295
300
}
296
301
297
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
302
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
303
if err != nil {
299
304
// non-fatal
300
305
}
···
517
522
Rkey: rkey,
518
523
Description: description,
519
524
Created: time.Now(),
520
-
Labels: models.DefaultLabelDefs(),
525
+
Labels: s.config.Label.DefaultLabelDefs,
521
526
}
522
527
record := repo.AsRecord()
523
528
···
659
664
return err
660
665
}
661
666
662
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
663
-
defaults := models.DefaultLabelDefs()
667
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
664
668
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
665
669
if err != nil {
666
670
return err
···
670
674
return nil
671
675
}
672
676
673
-
labelDefs, err := models.FetchDefaultDefs(r)
677
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
674
678
if err != nil {
675
679
return err
676
680
}
+14
-2
appview/strings/strings.go
+14
-2
appview/strings/strings.go
···
148
148
showRendered = r.URL.Query().Get("code") != "true"
149
149
}
150
150
151
+
starCount, err := db.GetStarCount(s.Db, string.AtUri())
152
+
if err != nil {
153
+
l.Error("failed to get star count", "err", err)
154
+
}
155
+
user := s.OAuth.GetUser(r)
156
+
isStarred := false
157
+
if user != nil {
158
+
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
159
+
}
160
+
151
161
s.Pages.SingleString(w, pages.SingleStringParams{
152
-
LoggedInUser: s.OAuth.GetUser(r),
162
+
LoggedInUser: user,
153
163
RenderToggle: renderToggle,
154
164
ShowRendered: showRendered,
155
-
String: string,
165
+
String: &string,
156
166
Stats: string.Stats(),
167
+
IsStarred: isStarred,
168
+
StarCount: starCount,
157
169
Owner: id,
158
170
})
159
171
}
+3
-3
docs/hacking.md
+3
-3
docs/hacking.md
···
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
+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
+3
-3
docs/migrations.md
+3
-3
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
+17
flake.lock
+17
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": {
···
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
-10
flake.nix
+12
-10
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"];
···
81
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
82
87
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
88
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;
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
85
90
};
86
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
179
184
air-watcher = name: arg:
180
185
pkgs.writeShellScriptBin "run"
181
186
''
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"
187
+
export PATH=${pkgs.go}/bin:$PATH
188
+
${pkgs.air}/bin/air -c ./.air/${name}.toml \
189
+
-build.args_bin "${arg}"
188
190
'';
189
191
tailwind-watcher =
190
192
pkgs.writeShellScriptBin "run"
···
283
285
}: {
284
286
imports = [./nix/modules/appview.nix];
285
287
286
-
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
288
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview;
287
289
};
288
290
nixosModules.knot = {
289
291
lib,
···
292
294
}: {
293
295
imports = [./nix/modules/knot.nix];
294
296
295
-
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
297
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot;
296
298
};
297
299
nixosModules.spindle = {
298
300
lib,
···
301
303
}: {
302
304
imports = [./nix/modules/spindle.nix];
303
305
304
-
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
306
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
305
307
};
306
308
};
307
309
}
+4
-12
go.mod
+4
-12
go.mod
···
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
···
45
47
github.com/wyatt915/goldmark-treeblood v0.0.1
46
48
github.com/yuin/goldmark v1.7.13
47
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
48
51
golang.org/x/crypto v0.40.0
49
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
53
golang.org/x/image v0.31.0
···
65
68
github.com/aymerick/douceur v0.2.0 // indirect
66
69
github.com/beorn7/perks v1.0.1 // indirect
67
70
github.com/bits-and-blooms/bitset v1.22.0 // indirect
68
-
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
69
71
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
70
72
github.com/blevesearch/geo v0.2.4 // indirect
71
73
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
83
85
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
84
86
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
85
87
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
86
-
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
87
88
github.com/casbin/govaluate v1.3.0 // indirect
88
89
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
89
90
github.com/cespare/xxhash/v2 v2.3.0 // indirect
90
91
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
91
92
github.com/charmbracelet/lipgloss v1.1.0 // indirect
92
-
github.com/charmbracelet/log v0.4.2 // indirect
93
93
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
94
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
95
github.com/charmbracelet/x/term v0.2.1 // indirect
···
98
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
99
github.com/containerd/log v0.1.0 // indirect
100
100
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
101
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
102
101
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
103
102
github.com/distribution/reference v0.6.0 // indirect
104
103
github.com/dlclark/regexp2 v1.11.5 // indirect
···
152
151
github.com/kevinburke/ssh_config v1.2.0 // indirect
153
152
github.com/klauspost/compress v1.18.0 // indirect
154
153
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
154
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
161
155
github.com/mattn/go-isatty v0.0.20 // indirect
162
156
github.com/mattn/go-runewidth v0.0.16 // indirect
···
191
185
github.com/prometheus/procfs v0.16.1 // indirect
192
186
github.com/rivo/uniseg v0.4.7 // indirect
193
187
github.com/ryanuber/go-glob v1.0.0 // indirect
194
-
github.com/segmentio/asm v1.2.0 // indirect
195
188
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
196
189
github.com/spaolacci/murmur3 v1.1.0 // indirect
197
190
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
···
199
192
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
200
193
github.com/wyatt915/treeblood v0.1.16 // indirect
201
194
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
202
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
203
195
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
204
196
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
205
197
go.etcd.io/bbolt v1.4.0 // indirect
-17
go.sum
-17
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=
76
75
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
77
76
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
126
125
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
127
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
128
127
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
129
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
130
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
131
128
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
132
129
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
133
130
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
330
327
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
331
328
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
332
329
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
333
-
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
334
-
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
335
-
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
336
-
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
337
-
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
338
-
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
339
-
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
340
-
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
341
-
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
342
-
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
343
-
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
344
-
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
345
330
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
346
331
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
347
332
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
466
451
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
467
452
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
468
453
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
469
-
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
470
-
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
471
454
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
472
455
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
473
456
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+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
}
+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"`
+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) {
+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)
+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
}
+63
-2
knotserver/internal.go
+63
-2
knotserver/internal.go
···
68
68
writeJSON(w, data)
69
69
}
70
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
+
71
131
type PushOptions struct {
72
132
skipCi bool
73
133
verboseCi bool
···
217
277
218
278
var pipeline workflow.RawPipeline
219
279
for _, e := range workflowDir {
220
-
if !e.IsFile {
280
+
if !e.IsFile() {
221
281
continue
222
282
}
223
283
···
353
413
r := chi.NewRouter()
354
414
l := log.FromContext(ctx)
355
415
l = log.SubLogger(l, "internal")
356
-
res := idresolver.DefaultResolver()
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
357
417
358
418
h := InternalHandle{
359
419
db,
···
366
426
367
427
r.Get("/push-allowed", h.PushAllowed)
368
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
369
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
370
431
r.Mount("/debug", middleware.Profiler())
371
432
+1
-1
knotserver/router.go
+1
-1
knotserver/router.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
+3
-5
knotserver/xrpc/repo_tree.go
+3
-5
knotserver/xrpc/repo_tree.go
···
67
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
68
for i, file := range files {
69
69
entry := &tangled.RepoTree_TreeEntry{
70
-
Name: file.Name,
71
-
Mode: file.Mode,
72
-
Size: file.Size,
73
-
Is_file: file.IsFile,
74
-
Is_subtree: file.IsSubtree,
70
+
Name: file.Name,
71
+
Mode: file.Mode,
72
+
Size: file.Size,
75
73
}
76
74
77
75
if file.LastCommit != nil {
+14
lexicons/issue/comment.json
+14
lexicons/issue/comment.json
···
29
29
"replyTo": {
30
30
"type": "string",
31
31
"format": "at-uri"
32
+
},
33
+
"mentions": {
34
+
"type": "array",
35
+
"items": {
36
+
"type": "string",
37
+
"format": "did"
38
+
}
39
+
},
40
+
"references": {
41
+
"type": "array",
42
+
"items": {
43
+
"type": "string",
44
+
"format": "at-uri"
45
+
}
32
46
}
33
47
}
34
48
}
+14
lexicons/issue/issue.json
+14
lexicons/issue/issue.json
···
24
24
"createdAt": {
25
25
"type": "string",
26
26
"format": "datetime"
27
+
},
28
+
"mentions": {
29
+
"type": "array",
30
+
"items": {
31
+
"type": "string",
32
+
"format": "did"
33
+
}
34
+
},
35
+
"references": {
36
+
"type": "array",
37
+
"items": {
38
+
"type": "string",
39
+
"format": "at-uri"
40
+
}
27
41
}
28
42
}
29
43
}
+14
lexicons/pulls/comment.json
+14
lexicons/pulls/comment.json
···
25
25
"createdAt": {
26
26
"type": "string",
27
27
"format": "datetime"
28
+
},
29
+
"mentions": {
30
+
"type": "array",
31
+
"items": {
32
+
"type": "string",
33
+
"format": "did"
34
+
}
35
+
},
36
+
"references": {
37
+
"type": "array",
38
+
"items": {
39
+
"type": "string",
40
+
"format": "at-uri"
41
+
}
28
42
}
29
43
}
30
44
}
+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
}
+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",
+278
-12
nix/modules/appview.nix
+278
-12
nix/modules/appview.nix
···
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
};
28
+
29
+
listenAddr = mkOption {
30
+
type = types.str;
31
+
default = "0.0.0.0:${toString cfg.port}";
32
+
description = "Listen address for the appview service";
33
+
};
34
+
35
+
dbPath = mkOption {
36
+
type = types.str;
37
+
default = "/var/lib/appview/appview.db";
38
+
description = "Path to the SQLite database file";
39
+
};
40
+
41
+
appviewHost = mkOption {
42
+
type = types.str;
43
+
default = "https://tangled.org";
44
+
example = "https://example.com";
45
+
description = "Public host URL for the appview instance";
46
+
};
47
+
48
+
appviewName = mkOption {
49
+
type = types.str;
50
+
default = "Tangled";
51
+
description = "Display name for the appview instance";
52
+
};
53
+
54
+
dev = mkOption {
55
+
type = types.bool;
56
+
default = false;
57
+
description = "Enable development mode";
58
+
};
59
+
60
+
disallowedNicknamesFile = mkOption {
61
+
type = types.nullOr types.path;
62
+
default = null;
63
+
description = "Path to file containing disallowed nicknames";
64
+
};
65
+
66
+
# redis configuration
67
+
redis = {
68
+
addr = mkOption {
69
+
type = types.str;
70
+
default = "localhost:6379";
71
+
description = "Redis server address";
72
+
};
73
+
74
+
db = mkOption {
75
+
type = types.int;
76
+
default = 0;
77
+
description = "Redis database number";
78
+
};
79
+
};
80
+
81
+
# jetstream configuration
82
+
jetstream = {
83
+
endpoint = mkOption {
84
+
type = types.str;
85
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
86
+
description = "Jetstream WebSocket endpoint";
87
+
};
88
+
};
89
+
90
+
# knotstream consumer configuration
91
+
knotstream = {
92
+
retryInterval = mkOption {
93
+
type = types.str;
94
+
default = "60s";
95
+
description = "Initial retry interval for knotstream consumer";
96
+
};
97
+
98
+
maxRetryInterval = mkOption {
99
+
type = types.str;
100
+
default = "120m";
101
+
description = "Maximum retry interval for knotstream consumer";
102
+
};
103
+
104
+
connectionTimeout = mkOption {
105
+
type = types.str;
106
+
default = "5s";
107
+
description = "Connection timeout for knotstream consumer";
108
+
};
109
+
110
+
workerCount = mkOption {
111
+
type = types.int;
112
+
default = 64;
113
+
description = "Number of workers for knotstream consumer";
114
+
};
115
+
116
+
queueSize = mkOption {
117
+
type = types.int;
118
+
default = 100;
119
+
description = "Queue size for knotstream consumer";
120
+
};
121
+
};
122
+
123
+
# spindlestream consumer configuration
124
+
spindlestream = {
125
+
retryInterval = mkOption {
126
+
type = types.str;
127
+
default = "60s";
128
+
description = "Initial retry interval for spindlestream consumer";
129
+
};
130
+
131
+
maxRetryInterval = mkOption {
132
+
type = types.str;
133
+
default = "120m";
134
+
description = "Maximum retry interval for spindlestream consumer";
135
+
};
136
+
137
+
connectionTimeout = mkOption {
138
+
type = types.str;
139
+
default = "5s";
140
+
description = "Connection timeout for spindlestream consumer";
141
+
};
142
+
143
+
workerCount = mkOption {
144
+
type = types.int;
145
+
default = 64;
146
+
description = "Number of workers for spindlestream consumer";
147
+
};
148
+
149
+
queueSize = mkOption {
150
+
type = types.int;
151
+
default = 100;
152
+
description = "Queue size for spindlestream consumer";
153
+
};
154
+
};
155
+
156
+
# resend configuration
157
+
resend = {
158
+
sentFrom = mkOption {
159
+
type = types.str;
160
+
default = "noreply@notifs.tangled.sh";
161
+
description = "Email address to send notifications from";
162
+
};
163
+
};
164
+
165
+
# posthog configuration
166
+
posthog = {
167
+
endpoint = mkOption {
168
+
type = types.str;
169
+
default = "https://eu.i.posthog.com";
170
+
description = "PostHog API endpoint";
171
+
};
172
+
};
173
+
174
+
# camo configuration
175
+
camo = {
176
+
host = mkOption {
177
+
type = types.str;
178
+
default = "https://camo.tangled.sh";
179
+
description = "Camo proxy host URL";
180
+
};
181
+
};
182
+
183
+
# avatar configuration
184
+
avatar = {
185
+
host = mkOption {
186
+
type = types.str;
187
+
default = "https://avatar.tangled.sh";
188
+
description = "Avatar service host URL";
189
+
};
190
+
};
191
+
192
+
plc = {
193
+
url = mkOption {
194
+
type = types.str;
195
+
default = "https://plc.directory";
196
+
description = "PLC directory URL";
197
+
};
198
+
};
199
+
200
+
pds = {
201
+
host = mkOption {
202
+
type = types.str;
203
+
default = "https://tngl.sh";
204
+
description = "PDS host URL";
205
+
};
206
+
};
207
+
208
+
label = {
209
+
defaults = mkOption {
210
+
type = types.listOf types.str;
211
+
default = [
212
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
213
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
214
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
215
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
216
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
217
+
];
218
+
description = "Default label definitions";
219
+
};
220
+
221
+
goodFirstIssue = mkOption {
222
+
type = types.str;
223
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
224
+
description = "Good first issue label definition";
225
+
};
226
+
};
227
+
25
228
environmentFile = mkOption {
26
229
type = with types; nullOr path;
27
230
default = null;
28
-
example = "/etc-/appview.env";
231
+
example = "/etc/appview.env";
29
232
description = ''
30
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
31
234
32
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
33
-
passed to the service without makeing them world readable in the
34
-
nix store.
35
-
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.
36
245
'';
37
246
};
38
247
};
···
47
256
systemd.services.appview = {
48
257
description = "tangled appview service";
49
258
wantedBy = ["multi-user.target"];
50
-
after = ["redis-appview.service"];
259
+
after = ["redis-appview.service" "network-online.target"];
51
260
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
52
262
53
263
serviceConfig = {
54
-
ListenStream = "0.0.0.0:${toString cfg.port}";
264
+
Type = "simple";
55
265
ExecStart = "${cfg.package}/bin/appview";
56
266
Restart = "always";
57
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
58
-
};
267
+
RestartSec = "10s";
268
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
269
+
270
+
# state directory
271
+
StateDirectory = "appview";
272
+
WorkingDirectory = "/var/lib/appview";
59
273
60
-
environment = {
61
-
TANGLED_DB_PATH = "appview.db";
274
+
# security hardening
275
+
NoNewPrivileges = true;
276
+
PrivateTmp = true;
277
+
ProtectSystem = "strict";
278
+
ProtectHome = true;
279
+
ReadWritePaths = ["/var/lib/appview"];
62
280
};
281
+
282
+
environment =
283
+
{
284
+
TANGLED_DB_PATH = cfg.dbPath;
285
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
286
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
287
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
288
+
TANGLED_DEV =
289
+
if cfg.dev
290
+
then "true"
291
+
else "false";
292
+
}
293
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
294
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
295
+
}
296
+
// {
297
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
298
+
TANGLED_REDIS_DB = toString cfg.redis.db;
299
+
300
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
301
+
302
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
303
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
304
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
305
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
306
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
307
+
308
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
309
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
310
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
311
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
312
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
313
+
314
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
315
+
316
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
317
+
318
+
TANGLED_CAMO_HOST = cfg.camo.host;
319
+
320
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
321
+
322
+
TANGLED_PLC_URL = cfg.plc.url;
323
+
324
+
TANGLED_PDS_HOST = cfg.pds.host;
325
+
326
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
327
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
328
+
};
63
329
};
64
330
};
65
331
}
+74
-2
nix/modules/knot.nix
+74
-2
nix/modules/knot.nix
···
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";
+8
-1
nix/modules/spindle.nix
+8
-1
nix/modules/spindle.nix
···
37
37
description = "Hostname for the server (required)";
38
38
};
39
39
40
+
plcUrl = mkOption {
41
+
type = types.str;
42
+
default = "https://plc.directory";
43
+
description = "atproto PLC directory";
44
+
};
45
+
40
46
jetstreamEndpoint = mkOption {
41
47
type = types.str;
42
48
default = "wss://jetstream1.us-west.bsky.network/subscribe";
···
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
+18
-5
nix/vm.nix
+18
-5
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
{
···
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
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;
+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
}
+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_"`
+5
-6
spindle/engines/nixery/engine.go
+5
-6
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 {
···
310
309
envs.AddEnv("HOME", homeDir)
311
310
312
311
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
313
-
Cmd: []string{"bash", "-c", step.command},
312
+
Cmd: []string{"bash", "-c", step.Command()},
314
313
AttachStdout: true,
315
314
AttachStderr: true,
316
315
Env: envs,
···
333
332
// Docker doesn't provide an API to kill an exec run
334
333
// (sure, we could grab the PID and kill it ourselves,
335
334
// but that's wasted effort)
336
-
e.l.Warn("step timed out", "step", step.Name)
335
+
e.l.Warn("step timed out", "step", step.Name())
337
336
338
337
<-tailDone
339
338
-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
}
+22
-1
types/repo.go
+22
-1
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
)
···
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 {