+727
-27
api/tangled/cbor_gen.go
+727
-27
api/tangled/cbor_gen.go
···
6938
}
6939
6940
cw := cbg.NewCborWriter(w)
6941
-
fieldCount := 5
6942
6943
if t.Body == nil {
6944
fieldCount--
6945
}
6946
···
7045
return err
7046
}
7047
7048
// t.CreatedAt (string) (string)
7049
if len("createdAt") > 1000000 {
7050
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7067
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7068
return err
7069
}
7070
return nil
7071
}
7072
···
7095
7096
n := extra
7097
7098
-
nameBuf := make([]byte, 9)
7099
for i := uint64(0); i < n; i++ {
7100
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7101
if err != nil {
···
7165
7166
t.Title = string(sval)
7167
}
7168
// t.CreatedAt (string) (string)
7169
case "createdAt":
7170
···
7175
}
7176
7177
t.CreatedAt = string(sval)
7178
}
7179
7180
default:
···
7194
}
7195
7196
cw := cbg.NewCborWriter(w)
7197
-
fieldCount := 5
7198
7199
if t.ReplyTo == nil {
7200
fieldCount--
···
7301
}
7302
}
7303
7304
// t.CreatedAt (string) (string)
7305
if len("createdAt") > 1000000 {
7306
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7323
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7324
return err
7325
}
7326
return nil
7327
}
7328
···
7351
7352
n := extra
7353
7354
-
nameBuf := make([]byte, 9)
7355
for i := uint64(0); i < n; i++ {
7356
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7357
if err != nil {
···
7421
t.ReplyTo = (*string)(&sval)
7422
}
7423
}
7424
// t.CreatedAt (string) (string)
7425
case "createdAt":
7426
···
7431
}
7432
7433
t.CreatedAt = string(sval)
7434
}
7435
7436
default:
···
7614
}
7615
7616
cw := cbg.NewCborWriter(w)
7617
-
fieldCount := 7
7618
7619
if t.Body == nil {
7620
fieldCount--
7621
}
7622
···
7680
}
7681
7682
// t.Patch (string) (string)
7683
-
if len("patch") > 1000000 {
7684
-
return xerrors.Errorf("Value in field \"patch\" was too long")
7685
-
}
7686
7687
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
7688
-
return err
7689
-
}
7690
-
if _, err := cw.WriteString(string("patch")); err != nil {
7691
-
return err
7692
-
}
7693
7694
-
if len(t.Patch) > 1000000 {
7695
-
return xerrors.Errorf("Value in field t.Patch was too long")
7696
-
}
7697
7698
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
7699
-
return err
7700
-
}
7701
-
if _, err := cw.WriteString(string(t.Patch)); err != nil {
7702
-
return err
7703
}
7704
7705
// t.Title (string) (string)
···
7760
return err
7761
}
7762
7763
// t.CreatedAt (string) (string)
7764
if len("createdAt") > 1000000 {
7765
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7782
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7783
return err
7784
}
7785
return nil
7786
}
7787
···
7810
7811
n := extra
7812
7813
-
nameBuf := make([]byte, 9)
7814
for i := uint64(0); i < n; i++ {
7815
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7816
if err != nil {
···
7862
case "patch":
7863
7864
{
7865
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7866
if err != nil {
7867
return err
7868
}
7869
7870
-
t.Patch = string(sval)
7871
}
7872
// t.Title (string) (string)
7873
case "title":
···
7920
}
7921
7922
}
7923
// t.CreatedAt (string) (string)
7924
case "createdAt":
7925
···
7931
7932
t.CreatedAt = string(sval)
7933
}
7934
7935
default:
7936
// Field doesn't exist on this type, so ignore it
···
7949
}
7950
7951
cw := cbg.NewCborWriter(w)
7952
7953
-
if _, err := cw.Write([]byte{164}); err != nil {
7954
return err
7955
}
7956
···
8019
return err
8020
}
8021
8022
// t.CreatedAt (string) (string)
8023
if len("createdAt") > 1000000 {
8024
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
8041
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8042
return err
8043
}
8044
return nil
8045
}
8046
···
8069
8070
n := extra
8071
8072
-
nameBuf := make([]byte, 9)
8073
for i := uint64(0); i < n; i++ {
8074
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8075
if err != nil {
···
8118
8119
t.LexiconTypeID = string(sval)
8120
}
8121
// t.CreatedAt (string) (string)
8122
case "createdAt":
8123
···
8128
}
8129
8130
t.CreatedAt = string(sval)
8131
}
8132
8133
default:
···
6938
}
6939
6940
cw := cbg.NewCborWriter(w)
6941
+
fieldCount := 7
6942
6943
if t.Body == nil {
6944
+
fieldCount--
6945
+
}
6946
+
6947
+
if t.Mentions == nil {
6948
+
fieldCount--
6949
+
}
6950
+
6951
+
if t.References == nil {
6952
fieldCount--
6953
}
6954
···
7053
return err
7054
}
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
+
7092
// t.CreatedAt (string) (string)
7093
if len("createdAt") > 1000000 {
7094
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7111
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7112
return err
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
+
}
7150
return nil
7151
}
7152
···
7175
7176
n := extra
7177
7178
+
nameBuf := make([]byte, 10)
7179
for i := uint64(0); i < n; i++ {
7180
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7181
if err != nil {
···
7245
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
+
}
7287
+
}
7288
// t.CreatedAt (string) (string)
7289
case "createdAt":
7290
···
7295
}
7296
7297
t.CreatedAt = string(sval)
7298
+
}
7299
+
// t.References ([]string) (slice)
7300
+
case "references":
7301
+
7302
+
maj, extra, err = cr.ReadHeader()
7303
+
if err != nil {
7304
+
return err
7305
+
}
7306
+
7307
+
if extra > 8192 {
7308
+
return fmt.Errorf("t.References: array too large (%d)", extra)
7309
+
}
7310
+
7311
+
if maj != cbg.MajArray {
7312
+
return fmt.Errorf("expected cbor array")
7313
+
}
7314
+
7315
+
if extra > 0 {
7316
+
t.References = make([]string, extra)
7317
+
}
7318
+
7319
+
for i := 0; i < int(extra); i++ {
7320
+
{
7321
+
var maj byte
7322
+
var extra uint64
7323
+
var err error
7324
+
_ = maj
7325
+
_ = extra
7326
+
_ = err
7327
+
7328
+
{
7329
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7330
+
if err != nil {
7331
+
return err
7332
+
}
7333
+
7334
+
t.References[i] = string(sval)
7335
+
}
7336
+
7337
+
}
7338
}
7339
7340
default:
···
7354
}
7355
7356
cw := cbg.NewCborWriter(w)
7357
+
fieldCount := 7
7358
+
7359
+
if t.Mentions == nil {
7360
+
fieldCount--
7361
+
}
7362
+
7363
+
if t.References == nil {
7364
+
fieldCount--
7365
+
}
7366
7367
if t.ReplyTo == nil {
7368
fieldCount--
···
7469
}
7470
}
7471
7472
+
// t.Mentions ([]string) (slice)
7473
+
if t.Mentions != nil {
7474
+
7475
+
if len("mentions") > 1000000 {
7476
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
7477
+
}
7478
+
7479
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
7480
+
return err
7481
+
}
7482
+
if _, err := cw.WriteString(string("mentions")); err != nil {
7483
+
return err
7484
+
}
7485
+
7486
+
if len(t.Mentions) > 8192 {
7487
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
7488
+
}
7489
+
7490
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
7491
+
return err
7492
+
}
7493
+
for _, v := range t.Mentions {
7494
+
if len(v) > 1000000 {
7495
+
return xerrors.Errorf("Value in field v was too long")
7496
+
}
7497
+
7498
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
7499
+
return err
7500
+
}
7501
+
if _, err := cw.WriteString(string(v)); err != nil {
7502
+
return err
7503
+
}
7504
+
7505
+
}
7506
+
}
7507
+
7508
// t.CreatedAt (string) (string)
7509
if len("createdAt") > 1000000 {
7510
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7527
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7528
return err
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
+
}
7566
return nil
7567
}
7568
···
7591
7592
n := extra
7593
7594
+
nameBuf := make([]byte, 10)
7595
for i := uint64(0); i < n; i++ {
7596
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7597
if err != nil {
···
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
+
7702
+
}
7703
+
}
7704
// t.CreatedAt (string) (string)
7705
case "createdAt":
7706
···
7711
}
7712
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
+
}
7754
}
7755
7756
default:
···
7934
}
7935
7936
cw := cbg.NewCborWriter(w)
7937
+
fieldCount := 10
7938
7939
if t.Body == nil {
7940
+
fieldCount--
7941
+
}
7942
+
7943
+
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.Patch == nil {
7948
+
fieldCount--
7949
+
}
7950
+
7951
+
if t.References == nil {
7952
fieldCount--
7953
}
7954
···
8012
}
8013
8014
// t.Patch (string) (string)
8015
+
if t.Patch != nil {
8016
8017
+
if len("patch") > 1000000 {
8018
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8019
+
}
8020
8021
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8022
+
return err
8023
+
}
8024
+
if _, err := cw.WriteString(string("patch")); err != nil {
8025
+
return err
8026
+
}
8027
8028
+
if t.Patch == nil {
8029
+
if _, err := cw.Write(cbg.CborNull); err != nil {
8030
+
return err
8031
+
}
8032
+
} else {
8033
+
if len(*t.Patch) > 1000000 {
8034
+
return xerrors.Errorf("Value in field t.Patch was too long")
8035
+
}
8036
+
8037
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8038
+
return err
8039
+
}
8040
+
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8041
+
return err
8042
+
}
8043
+
}
8044
}
8045
8046
// t.Title (string) (string)
···
8101
return err
8102
}
8103
8104
+
// t.Mentions ([]string) (slice)
8105
+
if t.Mentions != nil {
8106
+
8107
+
if len("mentions") > 1000000 {
8108
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
8109
+
}
8110
+
8111
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
8112
+
return err
8113
+
}
8114
+
if _, err := cw.WriteString(string("mentions")); err != nil {
8115
+
return err
8116
+
}
8117
+
8118
+
if len(t.Mentions) > 8192 {
8119
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
8120
+
}
8121
+
8122
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
8123
+
return err
8124
+
}
8125
+
for _, v := range t.Mentions {
8126
+
if len(v) > 1000000 {
8127
+
return xerrors.Errorf("Value in field v was too long")
8128
+
}
8129
+
8130
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8131
+
return err
8132
+
}
8133
+
if _, err := cw.WriteString(string(v)); err != nil {
8134
+
return err
8135
+
}
8136
+
8137
+
}
8138
+
}
8139
+
8140
// t.CreatedAt (string) (string)
8141
if len("createdAt") > 1000000 {
8142
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
8159
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8160
return err
8161
}
8162
+
8163
+
// t.PatchBlob (util.LexBlob) (struct)
8164
+
if len("patchBlob") > 1000000 {
8165
+
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8166
+
}
8167
+
8168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8169
+
return err
8170
+
}
8171
+
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8172
+
return err
8173
+
}
8174
+
8175
+
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8176
+
return err
8177
+
}
8178
+
8179
+
// t.References ([]string) (slice)
8180
+
if t.References != nil {
8181
+
8182
+
if len("references") > 1000000 {
8183
+
return xerrors.Errorf("Value in field \"references\" was too long")
8184
+
}
8185
+
8186
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
8187
+
return err
8188
+
}
8189
+
if _, err := cw.WriteString(string("references")); err != nil {
8190
+
return err
8191
+
}
8192
+
8193
+
if len(t.References) > 8192 {
8194
+
return xerrors.Errorf("Slice value in field t.References was too long")
8195
+
}
8196
+
8197
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
8198
+
return err
8199
+
}
8200
+
for _, v := range t.References {
8201
+
if len(v) > 1000000 {
8202
+
return xerrors.Errorf("Value in field v was too long")
8203
+
}
8204
+
8205
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8206
+
return err
8207
+
}
8208
+
if _, err := cw.WriteString(string(v)); err != nil {
8209
+
return err
8210
+
}
8211
+
8212
+
}
8213
+
}
8214
return nil
8215
}
8216
···
8239
8240
n := extra
8241
8242
+
nameBuf := make([]byte, 10)
8243
for i := uint64(0); i < n; i++ {
8244
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8245
if err != nil {
···
8291
case "patch":
8292
8293
{
8294
+
b, err := cr.ReadByte()
8295
if err != nil {
8296
return err
8297
}
8298
+
if b != cbg.CborNull[0] {
8299
+
if err := cr.UnreadByte(); err != nil {
8300
+
return err
8301
+
}
8302
8303
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8304
+
if err != nil {
8305
+
return err
8306
+
}
8307
+
8308
+
t.Patch = (*string)(&sval)
8309
+
}
8310
}
8311
// t.Title (string) (string)
8312
case "title":
···
8359
}
8360
8361
}
8362
+
// t.Mentions ([]string) (slice)
8363
+
case "mentions":
8364
+
8365
+
maj, extra, err = cr.ReadHeader()
8366
+
if err != nil {
8367
+
return err
8368
+
}
8369
+
8370
+
if extra > 8192 {
8371
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
8372
+
}
8373
+
8374
+
if maj != cbg.MajArray {
8375
+
return fmt.Errorf("expected cbor array")
8376
+
}
8377
+
8378
+
if extra > 0 {
8379
+
t.Mentions = make([]string, extra)
8380
+
}
8381
+
8382
+
for i := 0; i < int(extra); i++ {
8383
+
{
8384
+
var maj byte
8385
+
var extra uint64
8386
+
var err error
8387
+
_ = maj
8388
+
_ = extra
8389
+
_ = err
8390
+
8391
+
{
8392
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8393
+
if err != nil {
8394
+
return err
8395
+
}
8396
+
8397
+
t.Mentions[i] = string(sval)
8398
+
}
8399
+
8400
+
}
8401
+
}
8402
// t.CreatedAt (string) (string)
8403
case "createdAt":
8404
···
8410
8411
t.CreatedAt = string(sval)
8412
}
8413
+
// t.PatchBlob (util.LexBlob) (struct)
8414
+
case "patchBlob":
8415
+
8416
+
{
8417
+
8418
+
b, err := cr.ReadByte()
8419
+
if err != nil {
8420
+
return err
8421
+
}
8422
+
if b != cbg.CborNull[0] {
8423
+
if err := cr.UnreadByte(); err != nil {
8424
+
return err
8425
+
}
8426
+
t.PatchBlob = new(util.LexBlob)
8427
+
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8428
+
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8429
+
}
8430
+
}
8431
+
8432
+
}
8433
+
// t.References ([]string) (slice)
8434
+
case "references":
8435
+
8436
+
maj, extra, err = cr.ReadHeader()
8437
+
if err != nil {
8438
+
return err
8439
+
}
8440
+
8441
+
if extra > 8192 {
8442
+
return fmt.Errorf("t.References: array too large (%d)", extra)
8443
+
}
8444
+
8445
+
if maj != cbg.MajArray {
8446
+
return fmt.Errorf("expected cbor array")
8447
+
}
8448
+
8449
+
if extra > 0 {
8450
+
t.References = make([]string, extra)
8451
+
}
8452
+
8453
+
for i := 0; i < int(extra); i++ {
8454
+
{
8455
+
var maj byte
8456
+
var extra uint64
8457
+
var err error
8458
+
_ = maj
8459
+
_ = extra
8460
+
_ = err
8461
+
8462
+
{
8463
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8464
+
if err != nil {
8465
+
return err
8466
+
}
8467
+
8468
+
t.References[i] = string(sval)
8469
+
}
8470
+
8471
+
}
8472
+
}
8473
8474
default:
8475
// Field doesn't exist on this type, so ignore it
···
8488
}
8489
8490
cw := cbg.NewCborWriter(w)
8491
+
fieldCount := 6
8492
8493
+
if t.Mentions == nil {
8494
+
fieldCount--
8495
+
}
8496
+
8497
+
if t.References == nil {
8498
+
fieldCount--
8499
+
}
8500
+
8501
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
8502
return err
8503
}
8504
···
8567
return err
8568
}
8569
8570
+
// t.Mentions ([]string) (slice)
8571
+
if t.Mentions != nil {
8572
+
8573
+
if len("mentions") > 1000000 {
8574
+
return xerrors.Errorf("Value in field \"mentions\" was too long")
8575
+
}
8576
+
8577
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
8578
+
return err
8579
+
}
8580
+
if _, err := cw.WriteString(string("mentions")); err != nil {
8581
+
return err
8582
+
}
8583
+
8584
+
if len(t.Mentions) > 8192 {
8585
+
return xerrors.Errorf("Slice value in field t.Mentions was too long")
8586
+
}
8587
+
8588
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
8589
+
return err
8590
+
}
8591
+
for _, v := range t.Mentions {
8592
+
if len(v) > 1000000 {
8593
+
return xerrors.Errorf("Value in field v was too long")
8594
+
}
8595
+
8596
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8597
+
return err
8598
+
}
8599
+
if _, err := cw.WriteString(string(v)); err != nil {
8600
+
return err
8601
+
}
8602
+
8603
+
}
8604
+
}
8605
+
8606
// t.CreatedAt (string) (string)
8607
if len("createdAt") > 1000000 {
8608
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
8625
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8626
return err
8627
}
8628
+
8629
+
// t.References ([]string) (slice)
8630
+
if t.References != nil {
8631
+
8632
+
if len("references") > 1000000 {
8633
+
return xerrors.Errorf("Value in field \"references\" was too long")
8634
+
}
8635
+
8636
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
8637
+
return err
8638
+
}
8639
+
if _, err := cw.WriteString(string("references")); err != nil {
8640
+
return err
8641
+
}
8642
+
8643
+
if len(t.References) > 8192 {
8644
+
return xerrors.Errorf("Slice value in field t.References was too long")
8645
+
}
8646
+
8647
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
8648
+
return err
8649
+
}
8650
+
for _, v := range t.References {
8651
+
if len(v) > 1000000 {
8652
+
return xerrors.Errorf("Value in field v was too long")
8653
+
}
8654
+
8655
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
8656
+
return err
8657
+
}
8658
+
if _, err := cw.WriteString(string(v)); err != nil {
8659
+
return err
8660
+
}
8661
+
8662
+
}
8663
+
}
8664
return nil
8665
}
8666
···
8689
8690
n := extra
8691
8692
+
nameBuf := make([]byte, 10)
8693
for i := uint64(0); i < n; i++ {
8694
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8695
if err != nil {
···
8738
8739
t.LexiconTypeID = string(sval)
8740
}
8741
+
// t.Mentions ([]string) (slice)
8742
+
case "mentions":
8743
+
8744
+
maj, extra, err = cr.ReadHeader()
8745
+
if err != nil {
8746
+
return err
8747
+
}
8748
+
8749
+
if extra > 8192 {
8750
+
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
8751
+
}
8752
+
8753
+
if maj != cbg.MajArray {
8754
+
return fmt.Errorf("expected cbor array")
8755
+
}
8756
+
8757
+
if extra > 0 {
8758
+
t.Mentions = make([]string, extra)
8759
+
}
8760
+
8761
+
for i := 0; i < int(extra); i++ {
8762
+
{
8763
+
var maj byte
8764
+
var extra uint64
8765
+
var err error
8766
+
_ = maj
8767
+
_ = extra
8768
+
_ = err
8769
+
8770
+
{
8771
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8772
+
if err != nil {
8773
+
return err
8774
+
}
8775
+
8776
+
t.Mentions[i] = string(sval)
8777
+
}
8778
+
8779
+
}
8780
+
}
8781
// t.CreatedAt (string) (string)
8782
case "createdAt":
8783
···
8788
}
8789
8790
t.CreatedAt = string(sval)
8791
+
}
8792
+
// t.References ([]string) (slice)
8793
+
case "references":
8794
+
8795
+
maj, extra, err = cr.ReadHeader()
8796
+
if err != nil {
8797
+
return err
8798
+
}
8799
+
8800
+
if extra > 8192 {
8801
+
return fmt.Errorf("t.References: array too large (%d)", extra)
8802
+
}
8803
+
8804
+
if maj != cbg.MajArray {
8805
+
return fmt.Errorf("expected cbor array")
8806
+
}
8807
+
8808
+
if extra > 0 {
8809
+
t.References = make([]string, extra)
8810
+
}
8811
+
8812
+
for i := 0; i < int(extra); i++ {
8813
+
{
8814
+
var maj byte
8815
+
var extra uint64
8816
+
var err error
8817
+
_ = maj
8818
+
_ = extra
8819
+
_ = err
8820
+
8821
+
{
8822
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8823
+
if err != nil {
8824
+
return err
8825
+
}
8826
+
8827
+
t.References[i] = string(sval)
8828
+
}
8829
+
8830
+
}
8831
}
8832
8833
default:
+7
-5
api/tangled/issuecomment.go
+7
-5
api/tangled/issuecomment.go
···
17
} //
18
// RECORDTYPE: RepoIssueComment
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"`
25
}
···
17
} //
18
// RECORDTYPE: RepoIssueComment
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
+
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"`
27
}
+6
-4
api/tangled/pullcomment.go
+6
-4
api/tangled/pullcomment.go
···
17
} //
18
// RECORDTYPE: RepoPullComment
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"`
24
}
···
17
} //
18
// RECORDTYPE: RepoPullComment
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
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
Pull string `json:"pull" cborgen:"pull"`
25
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
}
+7
-5
api/tangled/repoissue.go
+7
-5
api/tangled/repoissue.go
···
17
} //
18
// RECORDTYPE: RepoIssue
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"`
25
}
···
17
} //
18
// RECORDTYPE: RepoIssue
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
+
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"`
27
}
+12
-7
api/tangled/repopull.go
+12
-7
api/tangled/repopull.go
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Patch string `json:"patch" cborgen:"patch"`
24
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
25
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
26
-
Title string `json:"title" cborgen:"title"`
27
}
28
29
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
// patch: (deprecated) use patchBlob instead
25
+
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
+
// patchBlob: patch content
27
+
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
+
Title string `json:"title" cborgen:"title"`
32
}
33
34
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+6
-45
appview/commitverify/verify.go
+6
-45
appview/commitverify/verify.go
···
3
import (
4
"log"
5
6
-
"github.com/go-git/go-git/v5/plumbing/object"
7
"tangled.org/core/appview/db"
8
"tangled.org/core/appview/models"
9
"tangled.org/core/crypto"
···
35
return ""
36
}
37
38
-
func GetVerifiedObjectCommits(e db.Execer, emailToDid map[string]string, commits []*object.Commit) (VerifiedCommits, error) {
39
-
ndCommits := []types.NiceDiff{}
40
-
for _, commit := range commits {
41
-
ndCommits = append(ndCommits, ObjectCommitToNiceDiff(commit))
42
-
}
43
-
return GetVerifiedCommits(e, emailToDid, ndCommits)
44
-
}
45
-
46
-
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
47
vcs := VerifiedCommits{}
48
49
didPubkeyCache := make(map[string][]models.PublicKey)
50
51
for _, commit := range ndCommits {
52
-
c := commit.Commit
53
-
54
-
committerEmail := c.Committer.Email
55
if did, exists := emailToDid[committerEmail]; exists {
56
// check if we've already fetched public keys for this did
57
pubKeys, ok := didPubkeyCache[did]
···
67
}
68
69
// try to verify with any associated pubkeys
70
for _, pk := range pubKeys {
71
-
if _, ok := crypto.VerifyCommitSignature(pk.Key, commit); ok {
72
73
fp, err := crypto.SSHFingerprint(pk.Key)
74
if err != nil {
75
log.Println("error computing ssh fingerprint:", err)
76
}
77
78
-
vc := verifiedCommit{fingerprint: fp, hash: c.This}
79
vcs[vc] = struct{}{}
80
break
81
}
···
86
87
return vcs, nil
88
}
89
-
90
-
// ObjectCommitToNiceDiff is a compatibility function to convert a
91
-
// commit object into a NiceDiff structure.
92
-
func ObjectCommitToNiceDiff(c *object.Commit) types.NiceDiff {
93
-
var niceDiff types.NiceDiff
94
-
95
-
// set commit information
96
-
niceDiff.Commit.Message = c.Message
97
-
niceDiff.Commit.Author = c.Author
98
-
niceDiff.Commit.This = c.Hash.String()
99
-
niceDiff.Commit.Committer = c.Committer
100
-
niceDiff.Commit.Tree = c.TreeHash.String()
101
-
niceDiff.Commit.PGPSignature = c.PGPSignature
102
-
103
-
changeId, ok := c.ExtraHeaders["change-id"]
104
-
if ok {
105
-
niceDiff.Commit.ChangedId = string(changeId)
106
-
}
107
-
108
-
// set parent hash if available
109
-
if len(c.ParentHashes) > 0 {
110
-
niceDiff.Commit.Parent = c.ParentHashes[0].String()
111
-
}
112
-
113
-
// XXX: Stats and Diff fields are typically populated
114
-
// after fetching the actual diff information, which isn't
115
-
// directly available in the commit object itself.
116
-
117
-
return niceDiff
118
-
}
···
3
import (
4
"log"
5
6
"tangled.org/core/appview/db"
7
"tangled.org/core/appview/models"
8
"tangled.org/core/crypto"
···
34
return ""
35
}
36
37
+
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.Commit) (VerifiedCommits, error) {
38
vcs := VerifiedCommits{}
39
40
didPubkeyCache := make(map[string][]models.PublicKey)
41
42
for _, commit := range ndCommits {
43
+
committerEmail := commit.Committer.Email
44
if did, exists := emailToDid[committerEmail]; exists {
45
// check if we've already fetched public keys for this did
46
pubKeys, ok := didPubkeyCache[did]
···
56
}
57
58
// try to verify with any associated pubkeys
59
+
payload := commit.Payload()
60
+
signature := commit.PGPSignature
61
for _, pk := range pubKeys {
62
+
if _, ok := crypto.VerifySignature([]byte(pk.Key), []byte(signature), []byte(payload)); ok {
63
64
fp, err := crypto.SSHFingerprint(pk.Key)
65
if err != nil {
66
log.Println("error computing ssh fingerprint:", err)
67
}
68
69
+
vc := verifiedCommit{fingerprint: fp, hash: commit.This}
70
vcs[vc] = struct{}{}
71
break
72
}
···
77
78
return vcs, nil
79
}
+3
-2
appview/db/artifact.go
+3
-2
appview/db/artifact.go
···
8
"github.com/go-git/go-git/v5/plumbing"
9
"github.com/ipfs/go-cid"
10
"tangled.org/core/appview/models"
11
)
12
13
func AddArtifact(e Execer, artifact models.Artifact) error {
···
37
return err
38
}
39
40
-
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
var artifacts []models.Artifact
42
43
var conditions []string
···
109
return artifacts, nil
110
}
111
112
-
func DeleteArtifact(e Execer, filters ...filter) error {
113
var conditions []string
114
var args []any
115
for _, filter := range filters {
···
8
"github.com/go-git/go-git/v5/plumbing"
9
"github.com/ipfs/go-cid"
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
12
)
13
14
func AddArtifact(e Execer, artifact models.Artifact) error {
···
38
return err
39
}
40
41
+
func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) {
42
var artifacts []models.Artifact
43
44
var conditions []string
···
110
return artifacts, nil
111
}
112
113
+
func DeleteArtifact(e Execer, filters ...orm.Filter) error {
114
var conditions []string
115
var args []any
116
for _, filter := range filters {
+4
-3
appview/db/collaborators.go
+4
-3
appview/db/collaborators.go
···
6
"time"
7
8
"tangled.org/core/appview/models"
9
)
10
11
func AddCollaborator(e Execer, c models.Collaborator) error {
···
16
return err
17
}
18
19
-
func DeleteCollaborator(e Execer, filters ...filter) error {
20
var conditions []string
21
var args []any
22
for _, filter := range filters {
···
58
return nil, nil
59
}
60
61
-
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
62
}
63
64
-
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
var collaborators []models.Collaborator
66
var conditions []string
67
var args []any
···
6
"time"
7
8
"tangled.org/core/appview/models"
9
+
"tangled.org/core/orm"
10
)
11
12
func AddCollaborator(e Execer, c models.Collaborator) error {
···
17
return err
18
}
19
20
+
func DeleteCollaborator(e Execer, filters ...orm.Filter) error {
21
var conditions []string
22
var args []any
23
for _, filter := range filters {
···
59
return nil, nil
60
}
61
62
+
return GetRepos(e, 0, orm.FilterIn("at_uri", repoAts))
63
}
64
65
+
func GetCollaborators(e Execer, filters ...orm.Filter) ([]models.Collaborator, error) {
66
var collaborators []models.Collaborator
67
var conditions []string
68
var args []any
+69
-136
appview/db/db.go
+69
-136
appview/db/db.go
···
3
import (
4
"context"
5
"database/sql"
6
-
"fmt"
7
"log/slog"
8
-
"reflect"
9
"strings"
10
11
_ "github.com/mattn/go-sqlite3"
12
"tangled.org/core/log"
13
)
14
15
type DB struct {
···
561
email_notifications integer not null default 0
562
);
563
564
create table if not exists migrations (
565
id integer primary key autoincrement,
566
name text unique
···
569
-- indexes for better performance
570
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
571
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
572
-
create index if not exists idx_stars_created on stars(created);
573
-
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
574
`)
575
if err != nil {
576
return nil, err
577
}
578
579
// run migrations
580
-
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
581
tx.Exec(`
582
alter table repos add column description text check (length(description) <= 200);
583
`)
584
return nil
585
})
586
587
-
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
588
// add unconstrained column
589
_, err := tx.Exec(`
590
alter table public_keys
···
607
return nil
608
})
609
610
-
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
611
_, err := tx.Exec(`
612
alter table comments drop column comment_at;
613
alter table comments add column rkey text;
···
615
return err
616
})
617
618
-
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
619
_, err := tx.Exec(`
620
alter table comments add column deleted text; -- timestamp
621
alter table comments add column edited text; -- timestamp
···
623
return err
624
})
625
626
-
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
627
_, err := tx.Exec(`
628
alter table pulls add column source_branch text;
629
alter table pulls add column source_repo_at text;
···
632
return err
633
})
634
635
-
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
636
_, err := tx.Exec(`
637
alter table repos add column source text;
638
`)
···
644
//
645
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
646
conn.ExecContext(ctx, "pragma foreign_keys = off;")
647
-
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
648
_, err := tx.Exec(`
649
create table pulls_new (
650
-- identifiers
···
701
})
702
conn.ExecContext(ctx, "pragma foreign_keys = on;")
703
704
-
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
705
tx.Exec(`
706
alter table repos add column spindle text;
707
`)
···
711
// drop all knot secrets, add unique constraint to knots
712
//
713
// knots will henceforth use service auth for signed requests
714
-
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
715
_, err := tx.Exec(`
716
create table registrations_new (
717
id integer primary key autoincrement,
···
734
})
735
736
// recreate and add rkey + created columns with default constraint
737
-
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
738
// create new table
739
// - repo_at instead of repo integer
740
// - rkey field
···
788
return err
789
})
790
791
-
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
792
_, err := tx.Exec(`
793
alter table issues add column rkey text not null default '';
794
···
800
})
801
802
// repurpose the read-only column to "needs-upgrade"
803
-
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
804
_, err := tx.Exec(`
805
alter table registrations rename column read_only to needs_upgrade;
806
`)
···
808
})
809
810
// require all knots to upgrade after the release of total xrpc
811
-
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
812
_, err := tx.Exec(`
813
update registrations set needs_upgrade = 1;
814
`)
···
816
})
817
818
// require all knots to upgrade after the release of total xrpc
819
-
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
820
_, err := tx.Exec(`
821
alter table spindles add column needs_upgrade integer not null default 0;
822
`)
···
834
//
835
// disable foreign-keys for the next migration
836
conn.ExecContext(ctx, "pragma foreign_keys = off;")
837
-
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
838
_, err := tx.Exec(`
839
create table if not exists issues_new (
840
-- identifiers
···
904
// - new columns
905
// * column "reply_to" which can be any other comment
906
// * column "at-uri" which is a generated column
907
-
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
908
_, err := tx.Exec(`
909
create table if not exists issue_comments (
910
-- identifiers
···
964
//
965
// disable foreign-keys for the next migration
966
conn.ExecContext(ctx, "pragma foreign_keys = off;")
967
-
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
968
_, err := tx.Exec(`
969
create table if not exists pulls_new (
970
-- identifiers
···
1045
//
1046
// disable foreign-keys for the next migration
1047
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1048
-
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1049
_, err := tx.Exec(`
1050
create table if not exists pull_submissions_new (
1051
-- identifiers
···
1099
1100
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
// (but not on the pds record), because calculating the combined patch requires a git index
1102
-
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
_, err := tx.Exec(`
1104
alter table pull_submissions add column combined text;
1105
`)
1106
return err
1107
})
1108
1109
-
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1110
_, err := tx.Exec(`
1111
alter table profile add column pronouns text;
1112
`)
1113
return err
1114
})
1115
1116
-
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1117
_, err := tx.Exec(`
1118
alter table repos add column website text;
1119
alter table repos add column topics text;
···
1121
return err
1122
})
1123
1124
-
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1125
_, err := tx.Exec(`
1126
alter table notification_preferences add column user_mentioned integer not null default 1;
1127
`)
1128
return err
1129
})
1130
1131
-
return &DB{
1132
-
db,
1133
-
logger,
1134
-
}, nil
1135
-
}
1136
1137
-
type migrationFn = func(*sql.Tx) error
1138
1139
-
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1140
-
logger = logger.With("migration", name)
1141
1142
-
tx, err := c.BeginTx(context.Background(), nil)
1143
-
if err != nil {
1144
-
return err
1145
-
}
1146
-
defer tx.Rollback()
1147
1148
-
var exists bool
1149
-
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
1150
-
if err != nil {
1151
return err
1152
-
}
1153
-
1154
-
if !exists {
1155
-
// run migration
1156
-
err = migrationFn(tx)
1157
-
if err != nil {
1158
-
logger.Error("failed to run migration", "err", err)
1159
-
return err
1160
-
}
1161
-
1162
-
// mark migration as complete
1163
-
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1164
-
if err != nil {
1165
-
logger.Error("failed to mark migration as complete", "err", err)
1166
-
return err
1167
-
}
1168
1169
-
// commit the transaction
1170
-
if err := tx.Commit(); err != nil {
1171
-
return err
1172
-
}
1173
-
1174
-
logger.Info("migration applied successfully")
1175
-
} else {
1176
-
logger.Warn("skipped migration, already applied")
1177
-
}
1178
-
1179
-
return nil
1180
}
1181
1182
func (d *DB) Close() error {
1183
return d.DB.Close()
1184
}
1185
-
1186
-
type filter struct {
1187
-
key string
1188
-
arg any
1189
-
cmp string
1190
-
}
1191
-
1192
-
func newFilter(key, cmp string, arg any) filter {
1193
-
return filter{
1194
-
key: key,
1195
-
arg: arg,
1196
-
cmp: cmp,
1197
-
}
1198
-
}
1199
-
1200
-
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
1201
-
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
1202
-
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
1203
-
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
1204
-
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
1205
-
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
1206
-
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1207
-
func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) }
1208
-
func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) }
1209
-
func FilterContains(key string, arg any) filter {
1210
-
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
1211
-
}
1212
-
1213
-
func (f filter) Condition() string {
1214
-
rv := reflect.ValueOf(f.arg)
1215
-
kind := rv.Kind()
1216
-
1217
-
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
1218
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1219
-
if rv.Len() == 0 {
1220
-
// always false
1221
-
return "1 = 0"
1222
-
}
1223
-
1224
-
placeholders := make([]string, rv.Len())
1225
-
for i := range placeholders {
1226
-
placeholders[i] = "?"
1227
-
}
1228
-
1229
-
return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
1230
-
}
1231
-
1232
-
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
1233
-
}
1234
-
1235
-
func (f filter) Arg() []any {
1236
-
rv := reflect.ValueOf(f.arg)
1237
-
kind := rv.Kind()
1238
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1239
-
if rv.Len() == 0 {
1240
-
return nil
1241
-
}
1242
-
1243
-
out := make([]any, rv.Len())
1244
-
for i := range rv.Len() {
1245
-
out[i] = rv.Index(i).Interface()
1246
-
}
1247
-
return out
1248
-
}
1249
-
1250
-
return []any{f.arg}
1251
-
}
···
3
import (
4
"context"
5
"database/sql"
6
"log/slog"
7
"strings"
8
9
_ "github.com/mattn/go-sqlite3"
10
"tangled.org/core/log"
11
+
"tangled.org/core/orm"
12
)
13
14
type DB struct {
···
560
email_notifications integer not null default 0
561
);
562
563
+
create table if not exists reference_links (
564
+
id integer primary key autoincrement,
565
+
from_at text not null,
566
+
to_at text not null,
567
+
unique (from_at, to_at)
568
+
);
569
+
570
create table if not exists migrations (
571
id integer primary key autoincrement,
572
name text unique
···
575
-- indexes for better performance
576
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
577
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
578
+
create index if not exists idx_references_from_at on reference_links(from_at);
579
+
create index if not exists idx_references_to_at on reference_links(to_at);
580
`)
581
if err != nil {
582
return nil, err
583
}
584
585
// run migrations
586
+
orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
587
tx.Exec(`
588
alter table repos add column description text check (length(description) <= 200);
589
`)
590
return nil
591
})
592
593
+
orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
594
// add unconstrained column
595
_, err := tx.Exec(`
596
alter table public_keys
···
613
return nil
614
})
615
616
+
orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
617
_, err := tx.Exec(`
618
alter table comments drop column comment_at;
619
alter table comments add column rkey text;
···
621
return err
622
})
623
624
+
orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
625
_, err := tx.Exec(`
626
alter table comments add column deleted text; -- timestamp
627
alter table comments add column edited text; -- timestamp
···
629
return err
630
})
631
632
+
orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
633
_, err := tx.Exec(`
634
alter table pulls add column source_branch text;
635
alter table pulls add column source_repo_at text;
···
638
return err
639
})
640
641
+
orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
642
_, err := tx.Exec(`
643
alter table repos add column source text;
644
`)
···
650
//
651
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
652
conn.ExecContext(ctx, "pragma foreign_keys = off;")
653
+
orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
654
_, err := tx.Exec(`
655
create table pulls_new (
656
-- identifiers
···
707
})
708
conn.ExecContext(ctx, "pragma foreign_keys = on;")
709
710
+
orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
711
tx.Exec(`
712
alter table repos add column spindle text;
713
`)
···
717
// drop all knot secrets, add unique constraint to knots
718
//
719
// knots will henceforth use service auth for signed requests
720
+
orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
721
_, err := tx.Exec(`
722
create table registrations_new (
723
id integer primary key autoincrement,
···
740
})
741
742
// recreate and add rkey + created columns with default constraint
743
+
orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
744
// create new table
745
// - repo_at instead of repo integer
746
// - rkey field
···
794
return err
795
})
796
797
+
orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
798
_, err := tx.Exec(`
799
alter table issues add column rkey text not null default '';
800
···
806
})
807
808
// repurpose the read-only column to "needs-upgrade"
809
+
orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
810
_, err := tx.Exec(`
811
alter table registrations rename column read_only to needs_upgrade;
812
`)
···
814
})
815
816
// require all knots to upgrade after the release of total xrpc
817
+
orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
818
_, err := tx.Exec(`
819
update registrations set needs_upgrade = 1;
820
`)
···
822
})
823
824
// require all knots to upgrade after the release of total xrpc
825
+
orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
826
_, err := tx.Exec(`
827
alter table spindles add column needs_upgrade integer not null default 0;
828
`)
···
840
//
841
// disable foreign-keys for the next migration
842
conn.ExecContext(ctx, "pragma foreign_keys = off;")
843
+
orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
844
_, err := tx.Exec(`
845
create table if not exists issues_new (
846
-- identifiers
···
910
// - new columns
911
// * column "reply_to" which can be any other comment
912
// * column "at-uri" which is a generated column
913
+
orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
914
_, err := tx.Exec(`
915
create table if not exists issue_comments (
916
-- identifiers
···
970
//
971
// disable foreign-keys for the next migration
972
conn.ExecContext(ctx, "pragma foreign_keys = off;")
973
+
orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
974
_, err := tx.Exec(`
975
create table if not exists pulls_new (
976
-- identifiers
···
1051
//
1052
// disable foreign-keys for the next migration
1053
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1054
+
orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1055
_, err := tx.Exec(`
1056
create table if not exists pull_submissions_new (
1057
-- identifiers
···
1105
1106
// knots may report the combined patch for a comparison, we can store that on the appview side
1107
// (but not on the pds record), because calculating the combined patch requires a git index
1108
+
orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1109
_, err := tx.Exec(`
1110
alter table pull_submissions add column combined text;
1111
`)
1112
return err
1113
})
1114
1115
+
orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1116
_, err := tx.Exec(`
1117
alter table profile add column pronouns text;
1118
`)
1119
return err
1120
})
1121
1122
+
orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1123
_, err := tx.Exec(`
1124
alter table repos add column website text;
1125
alter table repos add column topics text;
···
1127
return err
1128
})
1129
1130
+
orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1131
_, err := tx.Exec(`
1132
alter table notification_preferences add column user_mentioned integer not null default 1;
1133
`)
1134
return err
1135
})
1136
1137
+
// remove the foreign key constraints from stars.
1138
+
orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1139
+
_, err := tx.Exec(`
1140
+
create table stars_new (
1141
+
id integer primary key autoincrement,
1142
+
did text not null,
1143
+
rkey text not null,
1144
+
1145
+
subject_at text not null,
1146
1147
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1148
+
unique(did, rkey),
1149
+
unique(did, subject_at)
1150
+
);
1151
1152
+
insert into stars_new (
1153
+
id,
1154
+
did,
1155
+
rkey,
1156
+
subject_at,
1157
+
created
1158
+
)
1159
+
select
1160
+
id,
1161
+
starred_by_did,
1162
+
rkey,
1163
+
repo_at,
1164
+
created
1165
+
from stars;
1166
1167
+
drop table stars;
1168
+
alter table stars_new rename to stars;
1169
1170
+
create index if not exists idx_stars_created on stars(created);
1171
+
create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
1172
+
`)
1173
return err
1174
+
})
1175
1176
+
return &DB{
1177
+
db,
1178
+
logger,
1179
+
}, nil
1180
}
1181
1182
func (d *DB) Close() error {
1183
return d.DB.Close()
1184
}
+6
-3
appview/db/follow.go
+6
-3
appview/db/follow.go
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
)
11
12
func AddFollow(e Execer, follow *models.Follow) error {
···
134
return result, nil
135
}
136
137
-
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
var follows []models.Follow
139
140
var conditions []string
···
166
if err != nil {
167
return nil, err
168
}
169
for rows.Next() {
170
var follow models.Follow
171
var followedAt string
···
191
}
192
193
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
194
-
return GetFollows(e, 0, FilterEq("subject_did", did))
195
}
196
197
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
198
-
return GetFollows(e, 0, FilterEq("user_did", did))
199
}
200
201
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
11
)
12
13
func AddFollow(e Execer, follow *models.Follow) error {
···
135
return result, nil
136
}
137
138
+
func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, error) {
139
var follows []models.Follow
140
141
var conditions []string
···
167
if err != nil {
168
return nil, err
169
}
170
+
defer rows.Close()
171
+
172
for rows.Next() {
173
var follow models.Follow
174
var followedAt string
···
194
}
195
196
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
197
+
return GetFollows(e, 0, orm.FilterEq("subject_did", did))
198
}
199
200
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
201
+
return GetFollows(e, 0, orm.FilterEq("user_did", did))
202
}
203
204
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
+93
-36
appview/db/issues.go
+93
-36
appview/db/issues.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
"tangled.org/core/appview/pagination"
15
)
16
17
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
···
26
27
issues, err := GetIssues(
28
tx,
29
-
FilterEq("did", issue.Did),
30
-
FilterEq("rkey", issue.Rkey),
31
)
32
switch {
33
case err != nil:
···
69
returning rowid, issue_id
70
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
71
72
-
return row.Scan(&issue.Id, &issue.IssueId)
73
}
74
75
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
···
79
set title = ?, body = ?, edited = ?
80
where did = ? and rkey = ?
81
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
82
-
return err
83
}
84
85
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86
issueMap := make(map[string]*models.Issue) // at-uri -> issue
87
88
var conditions []string
···
98
whereClause = " where " + strings.Join(conditions, " and ")
99
}
100
101
-
pLower := FilterGte("row_num", page.Offset+1)
102
-
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
104
pageClause := ""
105
if page.Limit > 0 {
···
189
repoAts = append(repoAts, string(issue.RepoAt))
190
}
191
192
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
193
if err != nil {
194
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
195
}
···
212
// collect comments
213
issueAts := slices.Collect(maps.Keys(issueMap))
214
215
-
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
216
if err != nil {
217
return nil, fmt.Errorf("failed to query comments: %w", err)
218
}
···
224
}
225
226
// collect allLabels for each issue
227
-
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
228
if err != nil {
229
return nil, fmt.Errorf("failed to query labels: %w", err)
230
}
···
234
}
235
}
236
237
var issues []models.Issue
238
for _, i := range issueMap {
239
issues = append(issues, *i)
···
250
issues, err := GetIssuesPaginated(
251
e,
252
pagination.Page{},
253
-
FilterEq("repo_at", repoAt),
254
-
FilterEq("issue_id", issueId),
255
)
256
if err != nil {
257
return nil, err
···
263
return &issues[0], nil
264
}
265
266
-
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
267
return GetIssuesPaginated(e, pagination.Page{}, filters...)
268
}
269
···
271
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
272
var ids []int64
273
274
-
var filters []filter
275
openValue := 0
276
if opts.IsOpen {
277
openValue = 1
278
}
279
-
filters = append(filters, FilterEq("open", openValue))
280
if opts.RepoAt != "" {
281
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
282
}
283
284
var conditions []string
···
323
return ids, nil
324
}
325
326
-
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
327
-
result, err := e.Exec(
328
`insert into issue_comments (
329
did,
330
rkey,
···
363
return 0, err
364
}
365
366
return id, nil
367
}
368
369
-
func DeleteIssueComments(e Execer, filters ...filter) error {
370
var conditions []string
371
var args []any
372
for _, filter := range filters {
···
385
return err
386
}
387
388
-
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
389
-
var comments []models.IssueComment
390
391
var conditions []string
392
var args []any
···
420
if err != nil {
421
return nil, err
422
}
423
424
for rows.Next() {
425
var comment models.IssueComment
···
465
comment.ReplyTo = &replyTo.V
466
}
467
468
-
comments = append(comments, comment)
469
}
470
471
if err = rows.Err(); err != nil {
472
return nil, err
473
}
474
475
return comments, nil
476
}
477
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()...)
484
}
485
486
-
whereClause := ""
487
-
if conditions != nil {
488
-
whereClause = " where " + strings.Join(conditions, " and ")
489
}
490
491
-
query := fmt.Sprintf(`delete from issues %s`, whereClause)
492
-
_, err := e.Exec(query, args...)
493
-
return err
494
}
495
496
-
func CloseIssues(e Execer, filters ...filter) error {
497
var conditions []string
498
var args []any
499
for _, filter := range filters {
···
511
return err
512
}
513
514
-
func ReopenIssues(e Execer, filters ...filter) error {
515
var conditions []string
516
var args []any
517
for _, filter := range filters {
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/api/tangled"
14
"tangled.org/core/appview/models"
15
"tangled.org/core/appview/pagination"
16
+
"tangled.org/core/orm"
17
)
18
19
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
···
28
29
issues, err := GetIssues(
30
tx,
31
+
orm.FilterEq("did", issue.Did),
32
+
orm.FilterEq("rkey", issue.Rkey),
33
)
34
switch {
35
case err != nil:
···
71
returning rowid, issue_id
72
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
73
74
+
err = row.Scan(&issue.Id, &issue.IssueId)
75
+
if err != nil {
76
+
return fmt.Errorf("scan row: %w", err)
77
+
}
78
+
79
+
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
80
+
return fmt.Errorf("put reference_links: %w", err)
81
+
}
82
+
return nil
83
}
84
85
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
···
89
set title = ?, body = ?, edited = ?
90
where did = ? and rkey = ?
91
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
92
+
if err != nil {
93
+
return err
94
+
}
95
+
96
+
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
97
+
return fmt.Errorf("put reference_links: %w", err)
98
+
}
99
+
return nil
100
}
101
102
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) {
103
issueMap := make(map[string]*models.Issue) // at-uri -> issue
104
105
var conditions []string
···
115
whereClause = " where " + strings.Join(conditions, " and ")
116
}
117
118
+
pLower := orm.FilterGte("row_num", page.Offset+1)
119
+
pUpper := orm.FilterLte("row_num", page.Offset+page.Limit)
120
121
pageClause := ""
122
if page.Limit > 0 {
···
206
repoAts = append(repoAts, string(issue.RepoAt))
207
}
208
209
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts))
210
if err != nil {
211
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
212
}
···
229
// collect comments
230
issueAts := slices.Collect(maps.Keys(issueMap))
231
232
+
comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts))
233
if err != nil {
234
return nil, fmt.Errorf("failed to query comments: %w", err)
235
}
···
241
}
242
243
// collect allLabels for each issue
244
+
allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts))
245
if err != nil {
246
return nil, fmt.Errorf("failed to query labels: %w", err)
247
}
···
251
}
252
}
253
254
+
// collect references for each issue
255
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", issueAts))
256
+
if err != nil {
257
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
258
+
}
259
+
for issueAt, references := range allReferencs {
260
+
if issue, ok := issueMap[issueAt.String()]; ok {
261
+
issue.References = references
262
+
}
263
+
}
264
+
265
var issues []models.Issue
266
for _, i := range issueMap {
267
issues = append(issues, *i)
···
278
issues, err := GetIssuesPaginated(
279
e,
280
pagination.Page{},
281
+
orm.FilterEq("repo_at", repoAt),
282
+
orm.FilterEq("issue_id", issueId),
283
)
284
if err != nil {
285
return nil, err
···
291
return &issues[0], nil
292
}
293
294
+
func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) {
295
return GetIssuesPaginated(e, pagination.Page{}, filters...)
296
}
297
···
299
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
300
var ids []int64
301
302
+
var filters []orm.Filter
303
openValue := 0
304
if opts.IsOpen {
305
openValue = 1
306
}
307
+
filters = append(filters, orm.FilterEq("open", openValue))
308
if opts.RepoAt != "" {
309
+
filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt))
310
}
311
312
var conditions []string
···
351
return ids, nil
352
}
353
354
+
func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
355
+
result, err := tx.Exec(
356
`insert into issue_comments (
357
did,
358
rkey,
···
391
return 0, err
392
}
393
394
+
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
395
+
return 0, fmt.Errorf("put reference_links: %w", err)
396
+
}
397
+
398
return id, nil
399
}
400
401
+
func DeleteIssueComments(e Execer, filters ...orm.Filter) error {
402
var conditions []string
403
var args []any
404
for _, filter := range filters {
···
417
return err
418
}
419
420
+
func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) {
421
+
commentMap := make(map[string]*models.IssueComment)
422
423
var conditions []string
424
var args []any
···
452
if err != nil {
453
return nil, err
454
}
455
+
defer rows.Close()
456
457
for rows.Next() {
458
var comment models.IssueComment
···
498
comment.ReplyTo = &replyTo.V
499
}
500
501
+
atUri := comment.AtUri().String()
502
+
commentMap[atUri] = &comment
503
}
504
505
if err = rows.Err(); err != nil {
506
return nil, err
507
}
508
509
+
// collect references for each comments
510
+
commentAts := slices.Collect(maps.Keys(commentMap))
511
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
512
+
if err != nil {
513
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
514
+
}
515
+
for commentAt, references := range allReferencs {
516
+
if comment, ok := commentMap[commentAt.String()]; ok {
517
+
comment.References = references
518
+
}
519
+
}
520
+
521
+
var comments []models.IssueComment
522
+
for _, c := range commentMap {
523
+
comments = append(comments, *c)
524
+
}
525
+
526
+
sort.Slice(comments, func(i, j int) bool {
527
+
return comments[i].Created.After(comments[j].Created)
528
+
})
529
+
530
return comments, nil
531
}
532
533
+
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
534
+
_, err := tx.Exec(
535
+
`delete from issues
536
+
where did = ? and rkey = ?`,
537
+
did,
538
+
rkey,
539
+
)
540
+
if err != nil {
541
+
return fmt.Errorf("delete issue: %w", err)
542
}
543
544
+
uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey))
545
+
err = deleteReferences(tx, uri)
546
+
if err != nil {
547
+
return fmt.Errorf("delete reference_links: %w", err)
548
}
549
550
+
return nil
551
}
552
553
+
func CloseIssues(e Execer, filters ...orm.Filter) error {
554
var conditions []string
555
var args []any
556
for _, filter := range filters {
···
568
return err
569
}
570
571
+
func ReopenIssues(e Execer, filters ...orm.Filter) error {
572
var conditions []string
573
var args []any
574
for _, filter := range filters {
+8
-7
appview/db/label.go
+8
-7
appview/db/label.go
···
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"tangled.org/core/appview/models"
13
)
14
15
// no updating type for now
···
59
return id, nil
60
}
61
62
-
func DeleteLabelDefinition(e Execer, filters ...filter) error {
63
var conditions []string
64
var args []any
65
for _, filter := range filters {
···
75
return err
76
}
77
78
-
func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) {
79
var labelDefinitions []models.LabelDefinition
80
var conditions []string
81
var args []any
···
167
}
168
169
// helper to get exactly one label def
170
-
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
171
labels, err := GetLabelDefinitions(e, filters...)
172
if err != nil {
173
return nil, err
···
227
return id, nil
228
}
229
230
-
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
var labelOps []models.LabelOp
232
var conditions []string
233
var args []any
···
302
}
303
304
// get labels for a given list of subject URIs
305
-
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
306
ops, err := GetLabelOps(e, filters...)
307
if err != nil {
308
return nil, err
···
322
}
323
labelAts := slices.Collect(maps.Keys(labelAtSet))
324
325
-
actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts))
326
if err != nil {
327
return nil, err
328
}
···
338
return results, nil
339
}
340
341
-
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
342
labels, err := GetLabelDefinitions(e, filters...)
343
if err != nil {
344
return nil, err
···
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"tangled.org/core/appview/models"
13
+
"tangled.org/core/orm"
14
)
15
16
// no updating type for now
···
60
return id, nil
61
}
62
63
+
func DeleteLabelDefinition(e Execer, filters ...orm.Filter) error {
64
var conditions []string
65
var args []any
66
for _, filter := range filters {
···
76
return err
77
}
78
79
+
func GetLabelDefinitions(e Execer, filters ...orm.Filter) ([]models.LabelDefinition, error) {
80
var labelDefinitions []models.LabelDefinition
81
var conditions []string
82
var args []any
···
168
}
169
170
// helper to get exactly one label def
171
+
func GetLabelDefinition(e Execer, filters ...orm.Filter) (*models.LabelDefinition, error) {
172
labels, err := GetLabelDefinitions(e, filters...)
173
if err != nil {
174
return nil, err
···
228
return id, nil
229
}
230
231
+
func GetLabelOps(e Execer, filters ...orm.Filter) ([]models.LabelOp, error) {
232
var labelOps []models.LabelOp
233
var conditions []string
234
var args []any
···
303
}
304
305
// get labels for a given list of subject URIs
306
+
func GetLabels(e Execer, filters ...orm.Filter) (map[syntax.ATURI]models.LabelState, error) {
307
ops, err := GetLabelOps(e, filters...)
308
if err != nil {
309
return nil, err
···
323
}
324
labelAts := slices.Collect(maps.Keys(labelAtSet))
325
326
+
actx, err := NewLabelApplicationCtx(e, orm.FilterIn("at_uri", labelAts))
327
if err != nil {
328
return nil, err
329
}
···
339
return results, nil
340
}
341
342
+
func NewLabelApplicationCtx(e Execer, filters ...orm.Filter) (*models.LabelApplicationCtx, error) {
343
labels, err := GetLabelDefinitions(e, filters...)
344
if err != nil {
345
return nil, err
+6
-5
appview/db/language.go
+6
-5
appview/db/language.go
···
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
"tangled.org/core/appview/models"
10
)
11
12
-
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
13
var conditions []string
14
var args []any
15
for _, filter := range filters {
···
27
whereClause,
28
)
29
rows, err := e.Query(query, args...)
30
-
31
if err != nil {
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
33
}
34
35
var langs []models.RepoLanguage
36
for rows.Next() {
···
85
return nil
86
}
87
88
-
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
var conditions []string
90
var args []any
91
for _, filter := range filters {
···
107
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
err := DeleteRepoLanguages(
109
tx,
110
-
FilterEq("repo_at", repoAt),
111
-
FilterEq("ref", ref),
112
)
113
if err != nil {
114
return fmt.Errorf("failed to delete existing languages: %w", err)
···
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
11
)
12
13
+
func GetRepoLanguages(e Execer, filters ...orm.Filter) ([]models.RepoLanguage, error) {
14
var conditions []string
15
var args []any
16
for _, filter := range filters {
···
28
whereClause,
29
)
30
rows, err := e.Query(query, args...)
31
if err != nil {
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
33
}
34
+
defer rows.Close()
35
36
var langs []models.RepoLanguage
37
for rows.Next() {
···
86
return nil
87
}
88
89
+
func DeleteRepoLanguages(e Execer, filters ...orm.Filter) error {
90
var conditions []string
91
var args []any
92
for _, filter := range filters {
···
108
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
109
err := DeleteRepoLanguages(
110
tx,
111
+
orm.FilterEq("repo_at", repoAt),
112
+
orm.FilterEq("ref", ref),
113
)
114
if err != nil {
115
return fmt.Errorf("failed to delete existing languages: %w", err)
+14
-13
appview/db/notifications.go
+14
-13
appview/db/notifications.go
···
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"tangled.org/core/appview/models"
13
"tangled.org/core/appview/pagination"
14
)
15
16
func CreateNotification(e Execer, notification *models.Notification) error {
···
44
}
45
46
// GetNotificationsPaginated retrieves notifications with filters and pagination
47
-
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
48
var conditions []string
49
var args []any
50
···
113
}
114
115
// GetNotificationsWithEntities retrieves notifications with their related entities
116
-
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
117
var conditions []string
118
var args []any
119
···
256
}
257
258
// GetNotifications retrieves notifications with filters
259
-
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
260
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
261
}
262
263
-
func CountNotifications(e Execer, filters ...filter) (int64, error) {
264
var conditions []string
265
var args []any
266
for _, filter := range filters {
···
285
}
286
287
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
288
-
idFilter := FilterEq("id", notificationID)
289
-
recipientFilter := FilterEq("recipient_did", userDID)
290
291
query := fmt.Sprintf(`
292
UPDATE notifications
···
314
}
315
316
func MarkAllNotificationsRead(e Execer, userDID string) error {
317
-
recipientFilter := FilterEq("recipient_did", userDID)
318
-
readFilter := FilterEq("read", 0)
319
320
query := fmt.Sprintf(`
321
UPDATE notifications
···
334
}
335
336
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
337
-
idFilter := FilterEq("id", notificationID)
338
-
recipientFilter := FilterEq("recipient_did", userDID)
339
340
query := fmt.Sprintf(`
341
DELETE FROM notifications
···
362
}
363
364
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
365
-
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
366
if err != nil {
367
return nil, err
368
}
···
375
return p, nil
376
}
377
378
-
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
380
381
var conditions []string
···
483
484
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
485
cutoff := time.Now().Add(-olderThan)
486
-
createdFilter := FilterLte("created", cutoff)
487
488
query := fmt.Sprintf(`
489
DELETE FROM notifications
···
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"tangled.org/core/appview/models"
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
15
)
16
17
func CreateNotification(e Execer, notification *models.Notification) error {
···
45
}
46
47
// GetNotificationsPaginated retrieves notifications with filters and pagination
48
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) {
49
var conditions []string
50
var args []any
51
···
114
}
115
116
// GetNotificationsWithEntities retrieves notifications with their related entities
117
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) {
118
var conditions []string
119
var args []any
120
···
257
}
258
259
// GetNotifications retrieves notifications with filters
260
+
func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) {
261
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
262
}
263
264
+
func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) {
265
var conditions []string
266
var args []any
267
for _, filter := range filters {
···
286
}
287
288
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
289
+
idFilter := orm.FilterEq("id", notificationID)
290
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
291
292
query := fmt.Sprintf(`
293
UPDATE notifications
···
315
}
316
317
func MarkAllNotificationsRead(e Execer, userDID string) error {
318
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
319
+
readFilter := orm.FilterEq("read", 0)
320
321
query := fmt.Sprintf(`
322
UPDATE notifications
···
335
}
336
337
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
338
+
idFilter := orm.FilterEq("id", notificationID)
339
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
340
341
query := fmt.Sprintf(`
342
DELETE FROM notifications
···
363
}
364
365
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
366
+
prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid))
367
if err != nil {
368
return nil, err
369
}
···
376
return p, nil
377
}
378
379
+
func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) {
380
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
381
382
var conditions []string
···
484
485
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
486
cutoff := time.Now().Add(-olderThan)
487
+
createdFilter := orm.FilterLte("created", cutoff)
488
489
query := fmt.Sprintf(`
490
DELETE FROM notifications
+6
-5
appview/db/pipeline.go
+6
-5
appview/db/pipeline.go
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
)
11
12
-
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
var pipelines []models.Pipeline
14
15
var conditions []string
···
168
169
// this is a mega query, but the most useful one:
170
// get N pipelines, for each one get the latest status of its N workflows
171
-
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
172
var conditions []string
173
var args []any
174
for _, filter := range filters {
175
-
filter.key = "p." + filter.key // the table is aliased in the query to `p`
176
conditions = append(conditions, filter.Condition())
177
args = append(args, filter.Arg()...)
178
}
···
264
conditions = nil
265
args = nil
266
for _, p := range pipelines {
267
-
knotFilter := FilterEq("pipeline_knot", p.Knot)
268
-
rkeyFilter := FilterEq("pipeline_rkey", p.Rkey)
269
conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition()))
270
args = append(args, p.Knot)
271
args = append(args, p.Rkey)
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
11
)
12
13
+
func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) {
14
var pipelines []models.Pipeline
15
16
var conditions []string
···
169
170
// this is a mega query, but the most useful one:
171
// get N pipelines, for each one get the latest status of its N workflows
172
+
func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) {
173
var conditions []string
174
var args []any
175
for _, filter := range filters {
176
+
filter.Key = "p." + filter.Key // the table is aliased in the query to `p`
177
conditions = append(conditions, filter.Condition())
178
args = append(args, filter.Arg()...)
179
}
···
265
conditions = nil
266
args = nil
267
for _, p := range pipelines {
268
+
knotFilter := orm.FilterEq("pipeline_knot", p.Knot)
269
+
rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey)
270
conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition()))
271
args = append(args, p.Knot)
272
args = append(args, p.Rkey)
+29
-16
appview/db/profile.go
+29
-16
appview/db/profile.go
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
)
15
16
const TimeframeMonths = 7
···
19
timeline := models.ProfileTimeline{
20
ByMonth: make([]models.ByMonth, TimeframeMonths),
21
}
22
-
currentMonth := time.Now().Month()
23
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
24
25
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
29
30
// group pulls by month
31
for _, pull := range pulls {
32
-
pullMonth := pull.Created.Month()
33
34
-
if currentMonth-pullMonth >= TimeframeMonths {
35
// shouldn't happen; but times are weird
36
continue
37
}
38
39
-
idx := currentMonth - pullMonth
40
items := &timeline.ByMonth[idx].PullEvents.Items
41
42
*items = append(*items, &pull)
···
44
45
issues, err := GetIssues(
46
e,
47
-
FilterEq("did", forDid),
48
-
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
49
)
50
if err != nil {
51
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
52
}
53
54
for _, issue := range issues {
55
-
issueMonth := issue.Created.Month()
56
57
-
if currentMonth-issueMonth >= TimeframeMonths {
58
// shouldn't happen; but times are weird
59
continue
60
}
61
62
-
idx := currentMonth - issueMonth
63
items := &timeline.ByMonth[idx].IssueEvents.Items
64
65
*items = append(*items, &issue)
66
}
67
68
-
repos, err := GetRepos(e, 0, FilterEq("did", forDid))
69
if err != nil {
70
return nil, fmt.Errorf("error getting all repos by did: %w", err)
71
}
···
76
if repo.Source != "" {
77
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
78
if err != nil {
79
-
return nil, err
80
}
81
}
82
83
-
repoMonth := repo.Created.Month()
84
85
-
if currentMonth-repoMonth >= TimeframeMonths {
86
// shouldn't happen; but times are weird
87
continue
88
}
89
90
-
idx := currentMonth - repoMonth
91
92
items := &timeline.ByMonth[idx].RepoEvents
93
*items = append(*items, models.RepoEvent{
···
99
return &timeline, nil
100
}
101
102
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
103
defer tx.Rollback()
104
···
199
return tx.Commit()
200
}
201
202
-
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
203
var conditions []string
204
var args []any
205
for _, filter := range filters {
···
229
if err != nil {
230
return nil, err
231
}
232
233
profileMap := make(map[string]*models.Profile)
234
for rows.Next() {
···
269
if err != nil {
270
return nil, err
271
}
272
idxs := make(map[string]int)
273
for did := range profileMap {
274
idxs[did] = 0
···
289
if err != nil {
290
return nil, err
291
}
292
idxs = make(map[string]int)
293
for did := range profileMap {
294
idxs[did] = 0
···
441
}
442
443
// ensure all pinned repos are either own repos or collaborating repos
444
-
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
445
if err != nil {
446
log.Printf("getting repos for %s: %s", profile.Did, err)
447
}
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
15
)
16
17
const TimeframeMonths = 7
···
20
timeline := models.ProfileTimeline{
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
}
23
+
now := time.Now()
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
31
// group pulls by month
32
for _, pull := range pulls {
33
+
monthsAgo := monthsBetween(pull.Created, now)
34
35
+
if monthsAgo >= TimeframeMonths {
36
// shouldn't happen; but times are weird
37
continue
38
}
39
40
+
idx := monthsAgo
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
43
*items = append(*items, &pull)
···
45
46
issues, err := GetIssues(
47
e,
48
+
orm.FilterEq("did", forDid),
49
+
orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
50
)
51
if err != nil {
52
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
53
}
54
55
for _, issue := range issues {
56
+
monthsAgo := monthsBetween(issue.Created, now)
57
58
+
if monthsAgo >= TimeframeMonths {
59
// shouldn't happen; but times are weird
60
continue
61
}
62
63
+
idx := monthsAgo
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
66
*items = append(*items, &issue)
67
}
68
69
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid))
70
if err != nil {
71
return nil, fmt.Errorf("error getting all repos by did: %w", err)
72
}
···
77
if repo.Source != "" {
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
if err != nil {
80
+
// the source repo was not found, skip this bit
81
+
log.Println("profile", "err", err)
82
}
83
}
84
85
+
monthsAgo := monthsBetween(repo.Created, now)
86
87
+
if monthsAgo >= TimeframeMonths {
88
// shouldn't happen; but times are weird
89
continue
90
}
91
92
+
idx := monthsAgo
93
94
items := &timeline.ByMonth[idx].RepoEvents
95
*items = append(*items, models.RepoEvent{
···
101
return &timeline, nil
102
}
103
104
+
func monthsBetween(from, to time.Time) int {
105
+
years := to.Year() - from.Year()
106
+
months := int(to.Month() - from.Month())
107
+
return years*12 + months
108
+
}
109
+
110
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
111
defer tx.Rollback()
112
···
207
return tx.Commit()
208
}
209
210
+
func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
211
var conditions []string
212
var args []any
213
for _, filter := range filters {
···
237
if err != nil {
238
return nil, err
239
}
240
+
defer rows.Close()
241
242
profileMap := make(map[string]*models.Profile)
243
for rows.Next() {
···
278
if err != nil {
279
return nil, err
280
}
281
+
defer rows.Close()
282
+
283
idxs := make(map[string]int)
284
for did := range profileMap {
285
idxs[did] = 0
···
300
if err != nil {
301
return nil, err
302
}
303
+
defer rows.Close()
304
+
305
idxs = make(map[string]int)
306
for did := range profileMap {
307
idxs[did] = 0
···
454
}
455
456
// ensure all pinned repos are either own repos or collaborating repos
457
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did))
458
if err != nil {
459
log.Printf("getting repos for %s: %s", profile.Did, err)
460
}
+69
-24
appview/db/pulls.go
+69
-24
appview/db/pulls.go
···
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
"tangled.org/core/appview/models"
16
)
17
18
func NewPull(tx *sql.Tx, pull *models.Pull) error {
···
93
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
values (?, ?, ?, ?, ?)
95
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
-
return err
97
}
98
99
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
···
110
return pullId - 1, err
111
}
112
113
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
pulls := make(map[syntax.ATURI]*models.Pull)
115
116
var conditions []string
···
221
for _, p := range pulls {
222
pullAts = append(pullAts, p.AtUri())
223
}
224
-
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
226
return nil, fmt.Errorf("failed to get submissions: %w", err)
227
}
···
233
}
234
235
// collect allLabels for each issue
236
-
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
if err != nil {
238
return nil, fmt.Errorf("failed to query labels: %w", err)
239
}
···
250
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
}
252
}
253
-
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
return nil, fmt.Errorf("failed to get source repos: %w", err)
256
}
···
266
}
267
}
268
269
orderedByPullId := []*models.Pull{}
270
for _, p := range pulls {
271
orderedByPullId = append(orderedByPullId, p)
···
277
return orderedByPullId, nil
278
}
279
280
-
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
281
return GetPullsWithLimit(e, 0, filters...)
282
}
283
284
func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) {
285
var ids []int64
286
287
-
var filters []filter
288
-
filters = append(filters, FilterEq("state", opts.State))
289
if opts.RepoAt != "" {
290
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
291
}
292
293
var conditions []string
···
343
}
344
345
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
346
-
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
347
if err != nil {
348
return nil, err
349
}
···
355
}
356
357
// mapping from pull -> pull submissions
358
-
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
359
var conditions []string
360
var args []any
361
for _, filter := range filters {
···
430
431
// Get comments for all submissions using GetPullComments
432
submissionIds := slices.Collect(maps.Keys(submissionMap))
433
-
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
434
if err != nil {
435
-
return nil, err
436
}
437
for _, comment := range comments {
438
if submission, ok := submissionMap[comment.SubmissionId]; ok {
···
456
return m, nil
457
}
458
459
-
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
460
var conditions []string
461
var args []any
462
for _, filter := range filters {
···
492
}
493
defer rows.Close()
494
495
-
var comments []models.PullComment
496
for rows.Next() {
497
var comment models.PullComment
498
var createdAt string
···
514
comment.Created = t
515
}
516
517
-
comments = append(comments, comment)
518
}
519
520
if err := rows.Err(); err != nil {
521
return nil, err
522
}
523
524
return comments, nil
525
}
526
···
600
return pulls, nil
601
}
602
603
-
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
604
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
605
-
res, err := e.Exec(
606
query,
607
comment.OwnerDid,
608
comment.RepoAt,
···
618
i, err := res.LastInsertId()
619
if err != nil {
620
return 0, err
621
}
622
623
return i, nil
···
664
return err
665
}
666
667
-
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error {
668
var conditions []string
669
var args []any
670
···
688
689
// Only used when stacking to update contents in the event of a rebase (the interdiff should be empty).
690
// otherwise submissions are immutable
691
-
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error {
692
var conditions []string
693
var args []any
694
···
746
func GetStack(e Execer, stackId string) (models.Stack, error) {
747
unorderedPulls, err := GetPulls(
748
e,
749
-
FilterEq("stack_id", stackId),
750
-
FilterNotEq("state", models.PullDeleted),
751
)
752
if err != nil {
753
return nil, err
···
791
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
792
pulls, err := GetPulls(
793
e,
794
-
FilterEq("stack_id", stackId),
795
-
FilterEq("state", models.PullDeleted),
796
)
797
if err != nil {
798
return nil, err
···
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
"tangled.org/core/appview/models"
16
+
"tangled.org/core/orm"
17
)
18
19
func NewPull(tx *sql.Tx, pull *models.Pull) error {
···
94
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
95
values (?, ?, ?, ?, ?)
96
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
97
+
if err != nil {
98
+
return err
99
+
}
100
+
101
+
if err := putReferences(tx, pull.AtUri(), pull.References); err != nil {
102
+
return fmt.Errorf("put reference_links: %w", err)
103
+
}
104
+
105
+
return nil
106
}
107
108
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
···
119
return pullId - 1, err
120
}
121
122
+
func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) {
123
pulls := make(map[syntax.ATURI]*models.Pull)
124
125
var conditions []string
···
230
for _, p := range pulls {
231
pullAts = append(pullAts, p.AtUri())
232
}
233
+
submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts))
234
if err != nil {
235
return nil, fmt.Errorf("failed to get submissions: %w", err)
236
}
···
242
}
243
244
// collect allLabels for each issue
245
+
allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts))
246
if err != nil {
247
return nil, fmt.Errorf("failed to query labels: %w", err)
248
}
···
259
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
260
}
261
}
262
+
sourceRepos, err := GetRepos(e, 0, orm.FilterIn("at_uri", sourceAts))
263
if err != nil && !errors.Is(err, sql.ErrNoRows) {
264
return nil, fmt.Errorf("failed to get source repos: %w", err)
265
}
···
275
}
276
}
277
278
+
allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts))
279
+
if err != nil {
280
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
281
+
}
282
+
for pullAt, references := range allReferences {
283
+
if pull, ok := pulls[pullAt]; ok {
284
+
pull.References = references
285
+
}
286
+
}
287
+
288
orderedByPullId := []*models.Pull{}
289
for _, p := range pulls {
290
orderedByPullId = append(orderedByPullId, p)
···
296
return orderedByPullId, nil
297
}
298
299
+
func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) {
300
return GetPullsWithLimit(e, 0, filters...)
301
}
302
303
func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) {
304
var ids []int64
305
306
+
var filters []orm.Filter
307
+
filters = append(filters, orm.FilterEq("state", opts.State))
308
if opts.RepoAt != "" {
309
+
filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt))
310
}
311
312
var conditions []string
···
362
}
363
364
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
365
+
pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId))
366
if err != nil {
367
return nil, err
368
}
···
374
}
375
376
// mapping from pull -> pull submissions
377
+
func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
378
var conditions []string
379
var args []any
380
for _, filter := range filters {
···
449
450
// Get comments for all submissions using GetPullComments
451
submissionIds := slices.Collect(maps.Keys(submissionMap))
452
+
comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds))
453
if err != nil {
454
+
return nil, fmt.Errorf("failed to get pull comments: %w", err)
455
}
456
for _, comment := range comments {
457
if submission, ok := submissionMap[comment.SubmissionId]; ok {
···
475
return m, nil
476
}
477
478
+
func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) {
479
var conditions []string
480
var args []any
481
for _, filter := range filters {
···
511
}
512
defer rows.Close()
513
514
+
commentMap := make(map[string]*models.PullComment)
515
for rows.Next() {
516
var comment models.PullComment
517
var createdAt string
···
533
comment.Created = t
534
}
535
536
+
atUri := comment.AtUri().String()
537
+
commentMap[atUri] = &comment
538
}
539
540
if err := rows.Err(); err != nil {
541
return nil, err
542
}
543
544
+
// collect references for each comments
545
+
commentAts := slices.Collect(maps.Keys(commentMap))
546
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
547
+
if err != nil {
548
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
549
+
}
550
+
for commentAt, references := range allReferencs {
551
+
if comment, ok := commentMap[commentAt.String()]; ok {
552
+
comment.References = references
553
+
}
554
+
}
555
+
556
+
var comments []models.PullComment
557
+
for _, c := range commentMap {
558
+
comments = append(comments, *c)
559
+
}
560
+
561
+
sort.Slice(comments, func(i, j int) bool {
562
+
return comments[i].Created.Before(comments[j].Created)
563
+
})
564
+
565
return comments, nil
566
}
567
···
641
return pulls, nil
642
}
643
644
+
func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) {
645
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
646
+
res, err := tx.Exec(
647
query,
648
comment.OwnerDid,
649
comment.RepoAt,
···
659
i, err := res.LastInsertId()
660
if err != nil {
661
return 0, err
662
+
}
663
+
664
+
if err := putReferences(tx, comment.AtUri(), comment.References); err != nil {
665
+
return 0, fmt.Errorf("put reference_links: %w", err)
666
}
667
668
return i, nil
···
709
return err
710
}
711
712
+
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error {
713
var conditions []string
714
var args []any
715
···
733
734
// Only used when stacking to update contents in the event of a rebase (the interdiff should be empty).
735
// otherwise submissions are immutable
736
+
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error {
737
var conditions []string
738
var args []any
739
···
791
func GetStack(e Execer, stackId string) (models.Stack, error) {
792
unorderedPulls, err := GetPulls(
793
e,
794
+
orm.FilterEq("stack_id", stackId),
795
+
orm.FilterNotEq("state", models.PullDeleted),
796
)
797
if err != nil {
798
return nil, err
···
836
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
837
pulls, err := GetPulls(
838
e,
839
+
orm.FilterEq("stack_id", stackId),
840
+
orm.FilterEq("state", models.PullDeleted),
841
)
842
if err != nil {
843
return nil, err
+3
-2
appview/db/punchcard.go
+3
-2
appview/db/punchcard.go
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
)
11
12
// this adds to the existing count
···
20
return err
21
}
22
23
-
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
punchcard := &models.Punchcard{}
25
now := time.Now()
26
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
···
77
punch.Count = int(count.Int64)
78
}
79
80
-
punchcard.Punches[punch.Date.YearDay()] = punch
81
punchcard.Total += punch.Count
82
}
83
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
11
)
12
13
// this adds to the existing count
···
21
return err
22
}
23
24
+
func MakePunchcard(e Execer, filters ...orm.Filter) (*models.Punchcard, error) {
25
punchcard := &models.Punchcard{}
26
now := time.Now()
27
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
···
78
punch.Count = int(count.Int64)
79
}
80
81
+
punchcard.Punches[punch.Date.YearDay()-1] = punch
82
punchcard.Total += punch.Count
83
}
84
+463
appview/db/reference.go
+463
appview/db/reference.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"strings"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
12
+
)
13
+
14
+
// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs.
15
+
// It will ignore missing refLinks.
16
+
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
17
+
var (
18
+
issueRefs []models.ReferenceLink
19
+
pullRefs []models.ReferenceLink
20
+
)
21
+
for _, ref := range refLinks {
22
+
switch ref.Kind {
23
+
case models.RefKindIssue:
24
+
issueRefs = append(issueRefs, ref)
25
+
case models.RefKindPull:
26
+
pullRefs = append(pullRefs, ref)
27
+
}
28
+
}
29
+
issueUris, err := findIssueReferences(e, issueRefs)
30
+
if err != nil {
31
+
return nil, fmt.Errorf("find issue references: %w", err)
32
+
}
33
+
pullUris, err := findPullReferences(e, pullRefs)
34
+
if err != nil {
35
+
return nil, fmt.Errorf("find pull references: %w", err)
36
+
}
37
+
38
+
return append(issueUris, pullUris...), nil
39
+
}
40
+
41
+
func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
42
+
if len(refLinks) == 0 {
43
+
return nil, nil
44
+
}
45
+
vals := make([]string, len(refLinks))
46
+
args := make([]any, 0, len(refLinks)*4)
47
+
for i, ref := range refLinks {
48
+
vals[i] = "(?, ?, ?, ?)"
49
+
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
50
+
}
51
+
query := fmt.Sprintf(
52
+
`with input(owner_did, name, issue_id, comment_id) as (
53
+
values %s
54
+
)
55
+
select
56
+
i.did, i.rkey,
57
+
c.did, c.rkey
58
+
from input inp
59
+
join repos r
60
+
on r.did = inp.owner_did
61
+
and r.name = inp.name
62
+
join issues i
63
+
on i.repo_at = r.at_uri
64
+
and i.issue_id = inp.issue_id
65
+
left join issue_comments c
66
+
on inp.comment_id is not null
67
+
and c.issue_at = i.at_uri
68
+
and c.id = inp.comment_id
69
+
`,
70
+
strings.Join(vals, ","),
71
+
)
72
+
rows, err := e.Query(query, args...)
73
+
if err != nil {
74
+
return nil, err
75
+
}
76
+
defer rows.Close()
77
+
78
+
var uris []syntax.ATURI
79
+
80
+
for rows.Next() {
81
+
// Scan rows
82
+
var issueOwner, issueRkey string
83
+
var commentOwner, commentRkey sql.NullString
84
+
var uri syntax.ATURI
85
+
if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil {
86
+
return nil, err
87
+
}
88
+
if commentOwner.Valid && commentRkey.Valid {
89
+
uri = syntax.ATURI(fmt.Sprintf(
90
+
"at://%s/%s/%s",
91
+
commentOwner.String,
92
+
tangled.RepoIssueCommentNSID,
93
+
commentRkey.String,
94
+
))
95
+
} else {
96
+
uri = syntax.ATURI(fmt.Sprintf(
97
+
"at://%s/%s/%s",
98
+
issueOwner,
99
+
tangled.RepoIssueNSID,
100
+
issueRkey,
101
+
))
102
+
}
103
+
uris = append(uris, uri)
104
+
}
105
+
if err := rows.Err(); err != nil {
106
+
return nil, fmt.Errorf("iterate rows: %w", err)
107
+
}
108
+
109
+
return uris, nil
110
+
}
111
+
112
+
func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
113
+
if len(refLinks) == 0 {
114
+
return nil, nil
115
+
}
116
+
vals := make([]string, len(refLinks))
117
+
args := make([]any, 0, len(refLinks)*4)
118
+
for i, ref := range refLinks {
119
+
vals[i] = "(?, ?, ?, ?)"
120
+
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
121
+
}
122
+
query := fmt.Sprintf(
123
+
`with input(owner_did, name, pull_id, comment_id) as (
124
+
values %s
125
+
)
126
+
select
127
+
p.owner_did, p.rkey,
128
+
c.comment_at
129
+
from input inp
130
+
join repos r
131
+
on r.did = inp.owner_did
132
+
and r.name = inp.name
133
+
join pulls p
134
+
on p.repo_at = r.at_uri
135
+
and p.pull_id = inp.pull_id
136
+
left join pull_comments c
137
+
on inp.comment_id is not null
138
+
and c.repo_at = r.at_uri and c.pull_id = p.pull_id
139
+
and c.id = inp.comment_id
140
+
`,
141
+
strings.Join(vals, ","),
142
+
)
143
+
rows, err := e.Query(query, args...)
144
+
if err != nil {
145
+
return nil, err
146
+
}
147
+
defer rows.Close()
148
+
149
+
var uris []syntax.ATURI
150
+
151
+
for rows.Next() {
152
+
// Scan rows
153
+
var pullOwner, pullRkey string
154
+
var commentUri sql.NullString
155
+
var uri syntax.ATURI
156
+
if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil {
157
+
return nil, err
158
+
}
159
+
if commentUri.Valid {
160
+
// no-op
161
+
uri = syntax.ATURI(commentUri.String)
162
+
} else {
163
+
uri = syntax.ATURI(fmt.Sprintf(
164
+
"at://%s/%s/%s",
165
+
pullOwner,
166
+
tangled.RepoPullNSID,
167
+
pullRkey,
168
+
))
169
+
}
170
+
uris = append(uris, uri)
171
+
}
172
+
return uris, nil
173
+
}
174
+
175
+
func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error {
176
+
err := deleteReferences(tx, fromAt)
177
+
if err != nil {
178
+
return fmt.Errorf("delete old reference_links: %w", err)
179
+
}
180
+
if len(references) == 0 {
181
+
return nil
182
+
}
183
+
184
+
values := make([]string, 0, len(references))
185
+
args := make([]any, 0, len(references)*2)
186
+
for _, ref := range references {
187
+
values = append(values, "(?, ?)")
188
+
args = append(args, fromAt, ref)
189
+
}
190
+
_, err = tx.Exec(
191
+
fmt.Sprintf(
192
+
`insert into reference_links (from_at, to_at)
193
+
values %s`,
194
+
strings.Join(values, ","),
195
+
),
196
+
args...,
197
+
)
198
+
if err != nil {
199
+
return fmt.Errorf("insert new reference_links: %w", err)
200
+
}
201
+
return nil
202
+
}
203
+
204
+
func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error {
205
+
_, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt)
206
+
return err
207
+
}
208
+
209
+
func GetReferencesAll(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]syntax.ATURI, error) {
210
+
var (
211
+
conditions []string
212
+
args []any
213
+
)
214
+
for _, filter := range filters {
215
+
conditions = append(conditions, filter.Condition())
216
+
args = append(args, filter.Arg()...)
217
+
}
218
+
219
+
whereClause := ""
220
+
if conditions != nil {
221
+
whereClause = " where " + strings.Join(conditions, " and ")
222
+
}
223
+
224
+
rows, err := e.Query(
225
+
fmt.Sprintf(
226
+
`select from_at, to_at from reference_links %s`,
227
+
whereClause,
228
+
),
229
+
args...,
230
+
)
231
+
if err != nil {
232
+
return nil, fmt.Errorf("query reference_links: %w", err)
233
+
}
234
+
defer rows.Close()
235
+
236
+
result := make(map[syntax.ATURI][]syntax.ATURI)
237
+
238
+
for rows.Next() {
239
+
var from, to syntax.ATURI
240
+
if err := rows.Scan(&from, &to); err != nil {
241
+
return nil, fmt.Errorf("scan row: %w", err)
242
+
}
243
+
244
+
result[from] = append(result[from], to)
245
+
}
246
+
if err := rows.Err(); err != nil {
247
+
return nil, fmt.Errorf("iterate rows: %w", err)
248
+
}
249
+
250
+
return result, nil
251
+
}
252
+
253
+
func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) {
254
+
rows, err := e.Query(
255
+
`select from_at from reference_links
256
+
where to_at = ?`,
257
+
target,
258
+
)
259
+
if err != nil {
260
+
return nil, fmt.Errorf("query backlinks: %w", err)
261
+
}
262
+
defer rows.Close()
263
+
264
+
var (
265
+
backlinks []models.RichReferenceLink
266
+
backlinksMap = make(map[string][]syntax.ATURI)
267
+
)
268
+
for rows.Next() {
269
+
var from syntax.ATURI
270
+
if err := rows.Scan(&from); err != nil {
271
+
return nil, fmt.Errorf("scan row: %w", err)
272
+
}
273
+
nsid := from.Collection().String()
274
+
backlinksMap[nsid] = append(backlinksMap[nsid], from)
275
+
}
276
+
if err := rows.Err(); err != nil {
277
+
return nil, fmt.Errorf("iterate rows: %w", err)
278
+
}
279
+
280
+
var ls []models.RichReferenceLink
281
+
ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID])
282
+
if err != nil {
283
+
return nil, fmt.Errorf("get issue backlinks: %w", err)
284
+
}
285
+
backlinks = append(backlinks, ls...)
286
+
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID])
287
+
if err != nil {
288
+
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
289
+
}
290
+
backlinks = append(backlinks, ls...)
291
+
ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID])
292
+
if err != nil {
293
+
return nil, fmt.Errorf("get pull backlinks: %w", err)
294
+
}
295
+
backlinks = append(backlinks, ls...)
296
+
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID])
297
+
if err != nil {
298
+
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
299
+
}
300
+
backlinks = append(backlinks, ls...)
301
+
302
+
return backlinks, nil
303
+
}
304
+
305
+
func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
306
+
if len(aturis) == 0 {
307
+
return nil, nil
308
+
}
309
+
vals := make([]string, len(aturis))
310
+
args := make([]any, 0, len(aturis)*2)
311
+
for i, aturi := range aturis {
312
+
vals[i] = "(?, ?)"
313
+
did := aturi.Authority().String()
314
+
rkey := aturi.RecordKey().String()
315
+
args = append(args, did, rkey)
316
+
}
317
+
rows, err := e.Query(
318
+
fmt.Sprintf(
319
+
`select r.did, r.name, i.issue_id, i.title, i.open
320
+
from issues i
321
+
join repos r
322
+
on r.at_uri = i.repo_at
323
+
where (i.did, i.rkey) in (%s)`,
324
+
strings.Join(vals, ","),
325
+
),
326
+
args...,
327
+
)
328
+
if err != nil {
329
+
return nil, err
330
+
}
331
+
defer rows.Close()
332
+
var refLinks []models.RichReferenceLink
333
+
for rows.Next() {
334
+
var l models.RichReferenceLink
335
+
l.Kind = models.RefKindIssue
336
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
337
+
return nil, err
338
+
}
339
+
refLinks = append(refLinks, l)
340
+
}
341
+
if err := rows.Err(); err != nil {
342
+
return nil, fmt.Errorf("iterate rows: %w", err)
343
+
}
344
+
return refLinks, nil
345
+
}
346
+
347
+
func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
348
+
if len(aturis) == 0 {
349
+
return nil, nil
350
+
}
351
+
filter := orm.FilterIn("c.at_uri", aturis)
352
+
rows, err := e.Query(
353
+
fmt.Sprintf(
354
+
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
355
+
from issue_comments c
356
+
join issues i
357
+
on i.at_uri = c.issue_at
358
+
join repos r
359
+
on r.at_uri = i.repo_at
360
+
where %s`,
361
+
filter.Condition(),
362
+
),
363
+
filter.Arg()...,
364
+
)
365
+
if err != nil {
366
+
return nil, err
367
+
}
368
+
defer rows.Close()
369
+
var refLinks []models.RichReferenceLink
370
+
for rows.Next() {
371
+
var l models.RichReferenceLink
372
+
l.Kind = models.RefKindIssue
373
+
l.CommentId = new(int)
374
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
375
+
return nil, err
376
+
}
377
+
refLinks = append(refLinks, l)
378
+
}
379
+
if err := rows.Err(); err != nil {
380
+
return nil, fmt.Errorf("iterate rows: %w", err)
381
+
}
382
+
return refLinks, nil
383
+
}
384
+
385
+
func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
386
+
if len(aturis) == 0 {
387
+
return nil, nil
388
+
}
389
+
vals := make([]string, len(aturis))
390
+
args := make([]any, 0, len(aturis)*2)
391
+
for i, aturi := range aturis {
392
+
vals[i] = "(?, ?)"
393
+
did := aturi.Authority().String()
394
+
rkey := aturi.RecordKey().String()
395
+
args = append(args, did, rkey)
396
+
}
397
+
rows, err := e.Query(
398
+
fmt.Sprintf(
399
+
`select r.did, r.name, p.pull_id, p.title, p.state
400
+
from pulls p
401
+
join repos r
402
+
on r.at_uri = p.repo_at
403
+
where (p.owner_did, p.rkey) in (%s)`,
404
+
strings.Join(vals, ","),
405
+
),
406
+
args...,
407
+
)
408
+
if err != nil {
409
+
return nil, err
410
+
}
411
+
defer rows.Close()
412
+
var refLinks []models.RichReferenceLink
413
+
for rows.Next() {
414
+
var l models.RichReferenceLink
415
+
l.Kind = models.RefKindPull
416
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
417
+
return nil, err
418
+
}
419
+
refLinks = append(refLinks, l)
420
+
}
421
+
if err := rows.Err(); err != nil {
422
+
return nil, fmt.Errorf("iterate rows: %w", err)
423
+
}
424
+
return refLinks, nil
425
+
}
426
+
427
+
func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
428
+
if len(aturis) == 0 {
429
+
return nil, nil
430
+
}
431
+
filter := orm.FilterIn("c.comment_at", aturis)
432
+
rows, err := e.Query(
433
+
fmt.Sprintf(
434
+
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
435
+
from repos r
436
+
join pulls p
437
+
on r.at_uri = p.repo_at
438
+
join pull_comments c
439
+
on r.at_uri = c.repo_at and p.pull_id = c.pull_id
440
+
where %s`,
441
+
filter.Condition(),
442
+
),
443
+
filter.Arg()...,
444
+
)
445
+
if err != nil {
446
+
return nil, err
447
+
}
448
+
defer rows.Close()
449
+
var refLinks []models.RichReferenceLink
450
+
for rows.Next() {
451
+
var l models.RichReferenceLink
452
+
l.Kind = models.RefKindPull
453
+
l.CommentId = new(int)
454
+
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
455
+
return nil, err
456
+
}
457
+
refLinks = append(refLinks, l)
458
+
}
459
+
if err := rows.Err(); err != nil {
460
+
return nil, fmt.Errorf("iterate rows: %w", err)
461
+
}
462
+
return refLinks, nil
463
+
}
+5
-3
appview/db/registration.go
+5
-3
appview/db/registration.go
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
)
11
12
-
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
var registrations []models.Registration
14
15
var conditions []string
···
37
if err != nil {
38
return nil, err
39
}
40
41
for rows.Next() {
42
var createdAt string
···
69
return registrations, nil
70
}
71
72
-
func MarkRegistered(e Execer, filters ...filter) error {
73
var conditions []string
74
var args []any
75
for _, filter := range filters {
···
94
return err
95
}
96
97
-
func DeleteKnot(e Execer, filters ...filter) error {
98
var conditions []string
99
var args []any
100
for _, filter := range filters {
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
11
)
12
13
+
func GetRegistrations(e Execer, filters ...orm.Filter) ([]models.Registration, error) {
14
var registrations []models.Registration
15
16
var conditions []string
···
38
if err != nil {
39
return nil, err
40
}
41
+
defer rows.Close()
42
43
for rows.Next() {
44
var createdAt string
···
71
return registrations, nil
72
}
73
74
+
func MarkRegistered(e Execer, filters ...orm.Filter) error {
75
var conditions []string
76
var args []any
77
for _, filter := range filters {
···
96
return err
97
}
98
99
+
func DeleteKnot(e Execer, filters ...orm.Filter) error {
100
var conditions []string
101
var args []any
102
for _, filter := range filters {
+32
-37
appview/db/repos.go
+32
-37
appview/db/repos.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.org/core/api/tangled"
15
"tangled.org/core/appview/models"
16
)
17
18
-
type Repo struct {
19
-
Id int64
20
-
Did string
21
-
Name string
22
-
Knot string
23
-
Rkey string
24
-
Created time.Time
25
-
Description string
26
-
Spindle string
27
-
28
-
// optionally, populate this when querying for reverse mappings
29
-
RepoStats *models.RepoStats
30
-
31
-
// optional
32
-
Source string
33
-
}
34
-
35
-
func (r Repo) RepoAt() syntax.ATURI {
36
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
37
-
}
38
-
39
-
func (r Repo) DidSlashRepo() string {
40
-
p, _ := securejoin.SecureJoin(r.Did, r.Name)
41
-
return p
42
-
}
43
-
44
-
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
repoMap := make(map[syntax.ATURI]*models.Repo)
46
47
var conditions []string
···
83
limitClause,
84
)
85
rows, err := e.Query(repoQuery, args...)
86
-
87
if err != nil {
88
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
89
}
90
91
for rows.Next() {
92
var repo models.Repo
···
155
if err != nil {
156
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
157
}
158
for rows.Next() {
159
var repoat, labelat string
160
if err := rows.Scan(&repoat, &labelat); err != nil {
···
183
from repo_languages
184
where repo_at in (%s)
185
and is_default_ref = 1
186
)
187
where rn = 1
188
`,
···
192
if err != nil {
193
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
194
}
195
for rows.Next() {
196
var repoat, lang string
197
if err := rows.Scan(&repoat, &lang); err != nil {
···
208
209
starCountQuery := fmt.Sprintf(
210
`select
211
-
repo_at, count(1)
212
from stars
213
-
where repo_at in (%s)
214
-
group by repo_at`,
215
inClause,
216
)
217
rows, err = e.Query(starCountQuery, args...)
218
if err != nil {
219
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
220
}
221
for rows.Next() {
222
var repoat string
223
var count int
···
247
if err != nil {
248
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
249
}
250
for rows.Next() {
251
var repoat string
252
var open, closed int
···
288
if err != nil {
289
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
290
}
291
for rows.Next() {
292
var repoat string
293
var open, merged, closed, deleted int
···
322
}
323
324
// helper to get exactly one repo
325
-
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
326
repos, err := GetRepos(e, 0, filters...)
327
if err != nil {
328
return nil, err
···
339
return &repos[0], nil
340
}
341
342
-
func CountRepos(e Execer, filters ...filter) (int64, error) {
343
var conditions []string
344
var args []any
345
for _, filter := range filters {
···
439
return nullableSource.String, nil
440
}
441
442
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
443
var repos []models.Repo
444
···
559
return err
560
}
561
562
-
func UnsubscribeLabel(e Execer, filters ...filter) error {
563
var conditions []string
564
var args []any
565
for _, filter := range filters {
···
577
return err
578
}
579
580
-
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
581
var conditions []string
582
var args []any
583
for _, filter := range filters {
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
15
)
16
17
+
func GetRepos(e Execer, limit int, filters ...orm.Filter) ([]models.Repo, error) {
18
repoMap := make(map[syntax.ATURI]*models.Repo)
19
20
var conditions []string
···
56
limitClause,
57
)
58
rows, err := e.Query(repoQuery, args...)
59
if err != nil {
60
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
61
}
62
+
defer rows.Close()
63
64
for rows.Next() {
65
var repo models.Repo
···
128
if err != nil {
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
130
}
131
+
defer rows.Close()
132
+
133
for rows.Next() {
134
var repoat, labelat string
135
if err := rows.Scan(&repoat, &labelat); err != nil {
···
158
from repo_languages
159
where repo_at in (%s)
160
and is_default_ref = 1
161
+
and language <> ''
162
)
163
where rn = 1
164
`,
···
168
if err != nil {
169
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
170
}
171
+
defer rows.Close()
172
+
173
for rows.Next() {
174
var repoat, lang string
175
if err := rows.Scan(&repoat, &lang); err != nil {
···
186
187
starCountQuery := fmt.Sprintf(
188
`select
189
+
subject_at, count(1)
190
from stars
191
+
where subject_at in (%s)
192
+
group by subject_at`,
193
inClause,
194
)
195
rows, err = e.Query(starCountQuery, args...)
196
if err != nil {
197
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
198
}
199
+
defer rows.Close()
200
+
201
for rows.Next() {
202
var repoat string
203
var count int
···
227
if err != nil {
228
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
229
}
230
+
defer rows.Close()
231
+
232
for rows.Next() {
233
var repoat string
234
var open, closed int
···
270
if err != nil {
271
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
272
}
273
+
defer rows.Close()
274
+
275
for rows.Next() {
276
var repoat string
277
var open, merged, closed, deleted int
···
306
}
307
308
// helper to get exactly one repo
309
+
func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) {
310
repos, err := GetRepos(e, 0, filters...)
311
if err != nil {
312
return nil, err
···
323
return &repos[0], nil
324
}
325
326
+
func CountRepos(e Execer, filters ...orm.Filter) (int64, error) {
327
var conditions []string
328
var args []any
329
for _, filter := range filters {
···
423
return nullableSource.String, nil
424
}
425
426
+
func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) {
427
+
source, err := GetRepoSource(e, repoAt)
428
+
if source == "" || errors.Is(err, sql.ErrNoRows) {
429
+
return nil, nil
430
+
}
431
+
if err != nil {
432
+
return nil, err
433
+
}
434
+
return GetRepoByAtUri(e, source)
435
+
}
436
+
437
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
438
var repos []models.Repo
439
···
554
return err
555
}
556
557
+
func UnsubscribeLabel(e Execer, filters ...orm.Filter) error {
558
var conditions []string
559
var args []any
560
for _, filter := range filters {
···
572
return err
573
}
574
575
+
func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) {
576
var conditions []string
577
var args []any
578
for _, filter := range filters {
+6
-5
appview/db/spindle.go
+6
-5
appview/db/spindle.go
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
)
11
12
-
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
var spindles []models.Spindle
14
15
var conditions []string
···
91
return err
92
}
93
94
-
func VerifySpindle(e Execer, filters ...filter) (int64, error) {
95
var conditions []string
96
var args []any
97
for _, filter := range filters {
···
114
return res.RowsAffected()
115
}
116
117
-
func DeleteSpindle(e Execer, filters ...filter) error {
118
var conditions []string
119
var args []any
120
for _, filter := range filters {
···
144
return err
145
}
146
147
-
func RemoveSpindleMember(e Execer, filters ...filter) error {
148
var conditions []string
149
var args []any
150
for _, filter := range filters {
···
163
return err
164
}
165
166
-
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
var members []models.SpindleMember
168
169
var conditions []string
···
7
"time"
8
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
11
)
12
13
+
func GetSpindles(e Execer, filters ...orm.Filter) ([]models.Spindle, error) {
14
var spindles []models.Spindle
15
16
var conditions []string
···
92
return err
93
}
94
95
+
func VerifySpindle(e Execer, filters ...orm.Filter) (int64, error) {
96
var conditions []string
97
var args []any
98
for _, filter := range filters {
···
115
return res.RowsAffected()
116
}
117
118
+
func DeleteSpindle(e Execer, filters ...orm.Filter) error {
119
var conditions []string
120
var args []any
121
for _, filter := range filters {
···
145
return err
146
}
147
148
+
func RemoveSpindleMember(e Execer, filters ...orm.Filter) error {
149
var conditions []string
150
var args []any
151
for _, filter := range filters {
···
164
return err
165
}
166
167
+
func GetSpindleMembers(e Execer, filters ...orm.Filter) ([]models.SpindleMember, error) {
168
var members []models.SpindleMember
169
170
var conditions []string
+44
-102
appview/db/star.go
+44
-102
appview/db/star.go
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
)
15
16
func AddStar(e Execer, star *models.Star) error {
17
-
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
18
_, err := e.Exec(
19
query,
20
-
star.StarredByDid,
21
star.RepoAt.String(),
22
star.Rkey,
23
)
···
25
}
26
27
// Get a star record
28
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
29
query := `
30
-
select starred_by_did, repo_at, created, rkey
31
from stars
32
-
where starred_by_did = ? and repo_at = ?`
33
-
row := e.QueryRow(query, starredByDid, repoAt)
34
35
var star models.Star
36
var created string
37
-
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
38
if err != nil {
39
return nil, err
40
}
···
51
}
52
53
// Remove a star
54
-
func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error {
55
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt)
56
return err
57
}
58
59
// Remove a star
60
-
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
61
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
62
return err
63
}
64
65
-
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
66
stars := 0
67
err := e.QueryRow(
68
-
`select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
69
if err != nil {
70
return 0, err
71
}
···
89
}
90
91
query := fmt.Sprintf(`
92
-
SELECT repo_at
93
FROM stars
94
-
WHERE starred_by_did = ? AND repo_at IN (%s)
95
`, strings.Join(placeholders, ","))
96
97
rows, err := e.Query(query, args...)
···
118
return result, nil
119
}
120
121
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
122
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
123
if err != nil {
124
return false
125
}
126
-
return statuses[repoAt.String()]
127
}
128
129
// GetStarStatuses returns a map of repo URIs to star status for a given user
130
-
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
131
-
return getStarStatuses(e, userDid, repoAts)
132
}
133
-
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
134
var conditions []string
135
var args []any
136
for _, filter := range filters {
···
149
}
150
151
repoQuery := fmt.Sprintf(
152
-
`select starred_by_did, repo_at, created, rkey
153
from stars
154
%s
155
order by created desc
···
161
if err != nil {
162
return nil, err
163
}
164
165
starMap := make(map[string][]models.Star)
166
for rows.Next() {
167
var star models.Star
168
var created string
169
-
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
170
if err != nil {
171
return nil, err
172
}
···
192
return nil, nil
193
}
194
195
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
196
if err != nil {
197
return nil, err
198
}
199
200
for _, r := range repos {
201
if stars, ok := starMap[string(r.RepoAt())]; ok {
202
-
for i := range stars {
203
-
stars[i].Repo = &r
204
}
205
}
206
}
207
208
-
var stars []models.Star
209
-
for _, s := range starMap {
210
-
stars = append(stars, s...)
211
-
}
212
-
213
-
slices.SortFunc(stars, func(a, b models.Star) int {
214
if a.Created.After(b.Created) {
215
return -1
216
}
···
220
return 0
221
})
222
223
-
return stars, nil
224
}
225
226
-
func CountStars(e Execer, filters ...filter) (int64, error) {
227
var conditions []string
228
var args []any
229
for _, filter := range filters {
···
247
return count, nil
248
}
249
250
-
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
251
-
var stars []models.Star
252
-
253
-
rows, err := e.Query(`
254
-
select
255
-
s.starred_by_did,
256
-
s.repo_at,
257
-
s.rkey,
258
-
s.created,
259
-
r.did,
260
-
r.name,
261
-
r.knot,
262
-
r.rkey,
263
-
r.created
264
-
from stars s
265
-
join repos r on s.repo_at = r.at_uri
266
-
`)
267
-
268
-
if err != nil {
269
-
return nil, err
270
-
}
271
-
defer rows.Close()
272
-
273
-
for rows.Next() {
274
-
var star models.Star
275
-
var repo models.Repo
276
-
var starCreatedAt, repoCreatedAt string
277
-
278
-
if err := rows.Scan(
279
-
&star.StarredByDid,
280
-
&star.RepoAt,
281
-
&star.Rkey,
282
-
&starCreatedAt,
283
-
&repo.Did,
284
-
&repo.Name,
285
-
&repo.Knot,
286
-
&repo.Rkey,
287
-
&repoCreatedAt,
288
-
); err != nil {
289
-
return nil, err
290
-
}
291
-
292
-
star.Created, err = time.Parse(time.RFC3339, starCreatedAt)
293
-
if err != nil {
294
-
star.Created = time.Now()
295
-
}
296
-
repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt)
297
-
if err != nil {
298
-
repo.Created = time.Now()
299
-
}
300
-
star.Repo = &repo
301
-
302
-
stars = append(stars, star)
303
-
}
304
-
305
-
if err := rows.Err(); err != nil {
306
-
return nil, err
307
-
}
308
-
309
-
return stars, nil
310
-
}
311
-
312
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
313
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
314
// first, get the top repo URIs by star count from the last week
315
query := `
316
with recent_starred_repos as (
317
-
select distinct repo_at
318
from stars
319
where created >= datetime('now', '-7 days')
320
),
321
repo_star_counts as (
322
select
323
-
s.repo_at,
324
count(*) as stars_gained_last_week
325
from stars s
326
-
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
327
where s.created >= datetime('now', '-7 days')
328
-
group by s.repo_at
329
)
330
-
select rsc.repo_at
331
from repo_star_counts rsc
332
order by rsc.stars_gained_last_week desc
333
limit 8
···
358
}
359
360
// get full repo data
361
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
362
if err != nil {
363
return nil, err
364
}
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
15
)
16
17
func AddStar(e Execer, star *models.Star) error {
18
+
query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)`
19
_, err := e.Exec(
20
query,
21
+
star.Did,
22
star.RepoAt.String(),
23
star.Rkey,
24
)
···
26
}
27
28
// Get a star record
29
+
func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) {
30
query := `
31
+
select did, subject_at, created, rkey
32
from stars
33
+
where did = ? and subject_at = ?`
34
+
row := e.QueryRow(query, did, subjectAt)
35
36
var star models.Star
37
var created string
38
+
err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
39
if err != nil {
40
return nil, err
41
}
···
52
}
53
54
// Remove a star
55
+
func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error {
56
+
_, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt)
57
return err
58
}
59
60
// Remove a star
61
+
func DeleteStarByRkey(e Execer, did string, rkey string) error {
62
+
_, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey)
63
return err
64
}
65
66
+
func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) {
67
stars := 0
68
err := e.QueryRow(
69
+
`select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars)
70
if err != nil {
71
return 0, err
72
}
···
90
}
91
92
query := fmt.Sprintf(`
93
+
SELECT subject_at
94
FROM stars
95
+
WHERE did = ? AND subject_at IN (%s)
96
`, strings.Join(placeholders, ","))
97
98
rows, err := e.Query(query, args...)
···
119
return result, nil
120
}
121
122
+
func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool {
123
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt})
124
if err != nil {
125
return false
126
}
127
+
return statuses[subjectAt.String()]
128
}
129
130
// GetStarStatuses returns a map of repo URIs to star status for a given user
131
+
func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) {
132
+
return getStarStatuses(e, userDid, subjectAts)
133
}
134
+
135
+
// GetRepoStars return a list of stars each holding target repository.
136
+
// If there isn't known repo with starred at-uri, those stars will be ignored.
137
+
func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) {
138
var conditions []string
139
var args []any
140
for _, filter := range filters {
···
153
}
154
155
repoQuery := fmt.Sprintf(
156
+
`select did, subject_at, created, rkey
157
from stars
158
%s
159
order by created desc
···
165
if err != nil {
166
return nil, err
167
}
168
+
defer rows.Close()
169
170
starMap := make(map[string][]models.Star)
171
for rows.Next() {
172
var star models.Star
173
var created string
174
+
err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
175
if err != nil {
176
return nil, err
177
}
···
197
return nil, nil
198
}
199
200
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args))
201
if err != nil {
202
return nil, err
203
}
204
205
+
var repoStars []models.RepoStar
206
for _, r := range repos {
207
if stars, ok := starMap[string(r.RepoAt())]; ok {
208
+
for _, star := range stars {
209
+
repoStars = append(repoStars, models.RepoStar{
210
+
Star: star,
211
+
Repo: &r,
212
+
})
213
}
214
}
215
}
216
217
+
slices.SortFunc(repoStars, func(a, b models.RepoStar) int {
218
if a.Created.After(b.Created) {
219
return -1
220
}
···
224
return 0
225
})
226
227
+
return repoStars, nil
228
}
229
230
+
func CountStars(e Execer, filters ...orm.Filter) (int64, error) {
231
var conditions []string
232
var args []any
233
for _, filter := range filters {
···
251
return count, nil
252
}
253
254
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
255
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
256
// first, get the top repo URIs by star count from the last week
257
query := `
258
with recent_starred_repos as (
259
+
select distinct subject_at
260
from stars
261
where created >= datetime('now', '-7 days')
262
),
263
repo_star_counts as (
264
select
265
+
s.subject_at,
266
count(*) as stars_gained_last_week
267
from stars s
268
+
join recent_starred_repos rsr on s.subject_at = rsr.subject_at
269
where s.created >= datetime('now', '-7 days')
270
+
group by s.subject_at
271
)
272
+
select rsc.subject_at
273
from repo_star_counts rsc
274
order by rsc.stars_gained_last_week desc
275
limit 8
···
300
}
301
302
// get full repo data
303
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris))
304
if err != nil {
305
return nil, err
306
}
+4
-3
appview/db/strings.go
+4
-3
appview/db/strings.go
···
8
"time"
9
10
"tangled.org/core/appview/models"
11
)
12
13
func AddString(e Execer, s models.String) error {
···
44
return err
45
}
46
47
-
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
var all []models.String
49
50
var conditions []string
···
127
return all, nil
128
}
129
130
-
func CountStrings(e Execer, filters ...filter) (int64, error) {
131
var conditions []string
132
var args []any
133
for _, filter := range filters {
···
151
return count, nil
152
}
153
154
-
func DeleteString(e Execer, filters ...filter) error {
155
var conditions []string
156
var args []any
157
for _, filter := range filters {
···
8
"time"
9
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
12
)
13
14
func AddString(e Execer, s models.String) error {
···
45
return err
46
}
47
48
+
func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) {
49
var all []models.String
50
51
var conditions []string
···
128
return all, nil
129
}
130
131
+
func CountStrings(e Execer, filters ...orm.Filter) (int64, error) {
132
var conditions []string
133
var args []any
134
for _, filter := range filters {
···
152
return count, nil
153
}
154
155
+
func DeleteString(e Execer, filters ...orm.Filter) error {
156
var conditions []string
157
var args []any
158
for _, filter := range filters {
+11
-20
appview/db/timeline.go
+11
-20
appview/db/timeline.go
···
5
6
"github.com/bluesky-social/indigo/atproto/syntax"
7
"tangled.org/core/appview/models"
8
)
9
10
// TODO: this gathers heterogenous events from different sources and aggregates
···
84
}
85
86
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
-
filters := make([]filter, 0)
88
if userIsFollowing != nil {
89
-
filters = append(filters, FilterIn("did", userIsFollowing))
90
}
91
92
repos, err := GetRepos(e, limit, filters...)
···
104
105
var origRepos []models.Repo
106
if args != nil {
107
-
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
108
}
109
if err != nil {
110
return nil, err
···
144
}
145
146
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
-
filters := make([]filter, 0)
148
if userIsFollowing != nil {
149
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
}
151
152
-
stars, err := GetStars(e, limit, filters...)
153
if err != nil {
154
return nil, err
155
}
156
157
-
// filter star records without a repo
158
-
n := 0
159
-
for _, s := range stars {
160
-
if s.Repo != nil {
161
-
stars[n] = s
162
-
n++
163
-
}
164
-
}
165
-
stars = stars[:n]
166
-
167
var repos []models.Repo
168
for _, s := range stars {
169
repos = append(repos, *s.Repo)
···
179
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
180
181
events = append(events, models.TimelineEvent{
182
-
Star: &s,
183
EventAt: s.Created,
184
IsStarred: isStarred,
185
StarCount: starCount,
···
190
}
191
192
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
193
-
filters := make([]filter, 0)
194
if userIsFollowing != nil {
195
-
filters = append(filters, FilterIn("user_did", userIsFollowing))
196
}
197
198
follows, err := GetFollows(e, limit, filters...)
···
209
return nil, nil
210
}
211
212
-
profiles, err := GetProfiles(e, FilterIn("did", subjects))
213
if err != nil {
214
return nil, err
215
}
···
5
6
"github.com/bluesky-social/indigo/atproto/syntax"
7
"tangled.org/core/appview/models"
8
+
"tangled.org/core/orm"
9
)
10
11
// TODO: this gathers heterogenous events from different sources and aggregates
···
85
}
86
87
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
88
+
filters := make([]orm.Filter, 0)
89
if userIsFollowing != nil {
90
+
filters = append(filters, orm.FilterIn("did", userIsFollowing))
91
}
92
93
repos, err := GetRepos(e, limit, filters...)
···
105
106
var origRepos []models.Repo
107
if args != nil {
108
+
origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args))
109
}
110
if err != nil {
111
return nil, err
···
145
}
146
147
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
148
+
filters := make([]orm.Filter, 0)
149
if userIsFollowing != nil {
150
+
filters = append(filters, orm.FilterIn("did", userIsFollowing))
151
}
152
153
+
stars, err := GetRepoStars(e, limit, filters...)
154
if err != nil {
155
return nil, err
156
}
157
158
var repos []models.Repo
159
for _, s := range stars {
160
repos = append(repos, *s.Repo)
···
170
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
171
172
events = append(events, models.TimelineEvent{
173
+
RepoStar: &s,
174
EventAt: s.Created,
175
IsStarred: isStarred,
176
StarCount: starCount,
···
181
}
182
183
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
184
+
filters := make([]orm.Filter, 0)
185
if userIsFollowing != nil {
186
+
filters = append(filters, orm.FilterIn("user_did", userIsFollowing))
187
}
188
189
follows, err := GetFollows(e, limit, filters...)
···
200
return nil, nil
201
}
202
203
+
profiles, err := GetProfiles(e, orm.FilterIn("did", subjects))
204
if err != nil {
205
return nil, err
206
}
+7
-12
appview/email/email.go
+7
-12
appview/email/email.go
···
3
import (
4
"fmt"
5
"net"
6
-
"regexp"
7
"strings"
8
9
"github.com/resend/resend-go/v2"
···
34
}
35
36
func IsValidEmail(email string) bool {
37
-
// Basic length check
38
-
if len(email) < 3 || len(email) > 254 {
39
return false
40
}
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) {
50
return false
51
}
52
53
// Split email into local and domain parts
54
-
parts := strings.Split(email, "@")
55
domain := parts[1]
56
57
mx, err := net.LookupMX(domain)
···
3
import (
4
"fmt"
5
"net"
6
+
"net/mail"
7
"strings"
8
9
"github.com/resend/resend-go/v2"
···
34
}
35
36
func IsValidEmail(email string) bool {
37
+
// Reject whitespace (ParseAddress normalizes it away)
38
+
if strings.ContainsAny(email, " \t\n\r") {
39
return false
40
}
41
42
+
// Use stdlib RFC 5322 parser
43
+
addr, err := mail.ParseAddress(email)
44
+
if err != nil {
45
return false
46
}
47
48
// Split email into local and domain parts
49
+
parts := strings.Split(addr.Address, "@")
50
domain := parts[1]
51
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
+
}
+50
-32
appview/ingester.go
+50
-32
appview/ingester.go
···
21
"tangled.org/core/appview/serververify"
22
"tangled.org/core/appview/validator"
23
"tangled.org/core/idresolver"
24
"tangled.org/core/rbac"
25
)
26
···
121
return err
122
}
123
err = db.AddStar(i.Db, &models.Star{
124
-
StarredByDid: did,
125
-
RepoAt: subjectUri,
126
-
Rkey: e.Commit.RKey,
127
})
128
case jmodels.CommitOperationDelete:
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
···
253
254
err = db.AddArtifact(i.Db, artifact)
255
case jmodels.CommitOperationDelete:
256
-
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
257
}
258
259
if err != nil {
···
350
351
err = db.UpsertProfile(tx, &profile)
352
case jmodels.CommitOperationDelete:
353
-
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
354
}
355
356
if err != nil {
···
424
// get record from db first
425
members, err := db.GetSpindleMembers(
426
ddb,
427
-
db.FilterEq("did", did),
428
-
db.FilterEq("rkey", rkey),
429
)
430
if err != nil || len(members) != 1 {
431
return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members))
···
440
// remove record by rkey && update enforcer
441
if err = db.RemoveSpindleMember(
442
tx,
443
-
db.FilterEq("did", did),
444
-
db.FilterEq("rkey", rkey),
445
); err != nil {
446
return fmt.Errorf("failed to remove from db: %w", err)
447
}
···
523
// get record from db first
524
spindles, err := db.GetSpindles(
525
ddb,
526
-
db.FilterEq("owner", did),
527
-
db.FilterEq("instance", instance),
528
)
529
if err != nil || len(spindles) != 1 {
530
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
···
543
// remove spindle members first
544
err = db.RemoveSpindleMember(
545
tx,
546
-
db.FilterEq("owner", did),
547
-
db.FilterEq("instance", instance),
548
)
549
if err != nil {
550
return err
···
552
553
err = db.DeleteSpindle(
554
tx,
555
-
db.FilterEq("owner", did),
556
-
db.FilterEq("instance", instance),
557
)
558
if err != nil {
559
return err
···
621
case jmodels.CommitOperationDelete:
622
if err := db.DeleteString(
623
ddb,
624
-
db.FilterEq("did", did),
625
-
db.FilterEq("rkey", rkey),
626
); err != nil {
627
l.Error("failed to delete", "err", err)
628
return fmt.Errorf("failed to delete string record: %w", err)
···
740
// get record from db first
741
registrations, err := db.GetRegistrations(
742
ddb,
743
-
db.FilterEq("domain", domain),
744
-
db.FilterEq("did", did),
745
)
746
if err != nil {
747
return fmt.Errorf("failed to get registration: %w", err)
···
762
763
err = db.DeleteKnot(
764
tx,
765
-
db.FilterEq("did", did),
766
-
db.FilterEq("domain", domain),
767
)
768
if err != nil {
769
return err
···
841
return nil
842
843
case jmodels.CommitOperationDelete:
844
if err := db.DeleteIssues(
845
-
ddb,
846
-
db.FilterEq("did", did),
847
-
db.FilterEq("rkey", rkey),
848
); err != nil {
849
l.Error("failed to delete", "err", err)
850
return fmt.Errorf("failed to delete issue record: %w", err)
851
}
852
853
return nil
···
888
return fmt.Errorf("failed to validate comment: %w", err)
889
}
890
891
-
_, err = db.AddIssueComment(ddb, *comment)
892
if err != nil {
893
return fmt.Errorf("failed to create issue comment: %w", err)
894
}
895
896
-
return nil
897
898
case jmodels.CommitOperationDelete:
899
if err := db.DeleteIssueComments(
900
ddb,
901
-
db.FilterEq("did", did),
902
-
db.FilterEq("rkey", rkey),
903
); err != nil {
904
return fmt.Errorf("failed to delete issue comment record: %w", err)
905
}
···
952
case jmodels.CommitOperationDelete:
953
if err := db.DeleteLabelDefinition(
954
ddb,
955
-
db.FilterEq("did", did),
956
-
db.FilterEq("rkey", rkey),
957
); err != nil {
958
return fmt.Errorf("failed to delete labeldef record: %w", err)
959
}
···
993
var repo *models.Repo
994
switch collection {
995
case tangled.RepoIssueNSID:
996
-
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
997
if err != nil || len(i) != 1 {
998
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
999
}
···
1002
return fmt.Errorf("unsupport label subject: %s", collection)
1003
}
1004
1005
-
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1006
if err != nil {
1007
return fmt.Errorf("failed to build label application ctx: %w", err)
1008
}
···
21
"tangled.org/core/appview/serververify"
22
"tangled.org/core/appview/validator"
23
"tangled.org/core/idresolver"
24
+
"tangled.org/core/orm"
25
"tangled.org/core/rbac"
26
)
27
···
122
return err
123
}
124
err = db.AddStar(i.Db, &models.Star{
125
+
Did: did,
126
+
RepoAt: subjectUri,
127
+
Rkey: e.Commit.RKey,
128
})
129
case jmodels.CommitOperationDelete:
130
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
···
254
255
err = db.AddArtifact(i.Db, artifact)
256
case jmodels.CommitOperationDelete:
257
+
err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey))
258
}
259
260
if err != nil {
···
351
352
err = db.UpsertProfile(tx, &profile)
353
case jmodels.CommitOperationDelete:
354
+
err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey))
355
}
356
357
if err != nil {
···
425
// get record from db first
426
members, err := db.GetSpindleMembers(
427
ddb,
428
+
orm.FilterEq("did", did),
429
+
orm.FilterEq("rkey", rkey),
430
)
431
if err != nil || len(members) != 1 {
432
return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members))
···
441
// remove record by rkey && update enforcer
442
if err = db.RemoveSpindleMember(
443
tx,
444
+
orm.FilterEq("did", did),
445
+
orm.FilterEq("rkey", rkey),
446
); err != nil {
447
return fmt.Errorf("failed to remove from db: %w", err)
448
}
···
524
// get record from db first
525
spindles, err := db.GetSpindles(
526
ddb,
527
+
orm.FilterEq("owner", did),
528
+
orm.FilterEq("instance", instance),
529
)
530
if err != nil || len(spindles) != 1 {
531
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
···
544
// remove spindle members first
545
err = db.RemoveSpindleMember(
546
tx,
547
+
orm.FilterEq("owner", did),
548
+
orm.FilterEq("instance", instance),
549
)
550
if err != nil {
551
return err
···
553
554
err = db.DeleteSpindle(
555
tx,
556
+
orm.FilterEq("owner", did),
557
+
orm.FilterEq("instance", instance),
558
)
559
if err != nil {
560
return err
···
622
case jmodels.CommitOperationDelete:
623
if err := db.DeleteString(
624
ddb,
625
+
orm.FilterEq("did", did),
626
+
orm.FilterEq("rkey", rkey),
627
); err != nil {
628
l.Error("failed to delete", "err", err)
629
return fmt.Errorf("failed to delete string record: %w", err)
···
741
// get record from db first
742
registrations, err := db.GetRegistrations(
743
ddb,
744
+
orm.FilterEq("domain", domain),
745
+
orm.FilterEq("did", did),
746
)
747
if err != nil {
748
return fmt.Errorf("failed to get registration: %w", err)
···
763
764
err = db.DeleteKnot(
765
tx,
766
+
orm.FilterEq("did", did),
767
+
orm.FilterEq("domain", domain),
768
)
769
if err != nil {
770
return err
···
842
return nil
843
844
case jmodels.CommitOperationDelete:
845
+
tx, err := ddb.BeginTx(ctx, nil)
846
+
if err != nil {
847
+
l.Error("failed to begin transaction", "err", err)
848
+
return err
849
+
}
850
+
defer tx.Rollback()
851
+
852
if err := db.DeleteIssues(
853
+
tx,
854
+
did,
855
+
rkey,
856
); err != nil {
857
l.Error("failed to delete", "err", err)
858
return fmt.Errorf("failed to delete issue record: %w", err)
859
+
}
860
+
if err := tx.Commit(); err != nil {
861
+
l.Error("failed to commit txn", "err", err)
862
+
return err
863
}
864
865
return nil
···
900
return fmt.Errorf("failed to validate comment: %w", err)
901
}
902
903
+
tx, err := ddb.Begin()
904
+
if err != nil {
905
+
return fmt.Errorf("failed to start transaction: %w", err)
906
+
}
907
+
defer tx.Rollback()
908
+
909
+
_, err = db.AddIssueComment(tx, *comment)
910
if err != nil {
911
return fmt.Errorf("failed to create issue comment: %w", err)
912
}
913
914
+
return tx.Commit()
915
916
case jmodels.CommitOperationDelete:
917
if err := db.DeleteIssueComments(
918
ddb,
919
+
orm.FilterEq("did", did),
920
+
orm.FilterEq("rkey", rkey),
921
); err != nil {
922
return fmt.Errorf("failed to delete issue comment record: %w", err)
923
}
···
970
case jmodels.CommitOperationDelete:
971
if err := db.DeleteLabelDefinition(
972
ddb,
973
+
orm.FilterEq("did", did),
974
+
orm.FilterEq("rkey", rkey),
975
); err != nil {
976
return fmt.Errorf("failed to delete labeldef record: %w", err)
977
}
···
1011
var repo *models.Repo
1012
switch collection {
1013
case tangled.RepoIssueNSID:
1014
+
i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject))
1015
if err != nil || len(i) != 1 {
1016
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
1017
}
···
1020
return fmt.Errorf("unsupport label subject: %s", collection)
1021
}
1022
1023
+
actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels))
1024
if err != nil {
1025
return fmt.Errorf("failed to build label application ctx: %w", err)
1026
}
+152
-135
appview/issues/issues.go
+152
-135
appview/issues/issues.go
···
7
"fmt"
8
"log/slog"
9
"net/http"
10
-
"slices"
11
"time"
12
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
20
"tangled.org/core/appview/config"
21
"tangled.org/core/appview/db"
22
issues_indexer "tangled.org/core/appview/indexer/issues"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
"tangled.org/core/appview/pagination"
29
"tangled.org/core/appview/reporesolver"
30
"tangled.org/core/appview/validator"
31
"tangled.org/core/idresolver"
32
"tangled.org/core/tid"
33
)
34
35
type Issues struct {
36
-
oauth *oauth.OAuth
37
-
repoResolver *reporesolver.RepoResolver
38
-
pages *pages.Pages
39
-
idResolver *idresolver.Resolver
40
-
db *db.DB
41
-
config *config.Config
42
-
notifier notify.Notifier
43
-
logger *slog.Logger
44
-
validator *validator.Validator
45
-
indexer *issues_indexer.Indexer
46
}
47
48
func New(
49
oauth *oauth.OAuth,
50
repoResolver *reporesolver.RepoResolver,
51
pages *pages.Pages,
52
idResolver *idresolver.Resolver,
53
db *db.DB,
54
config *config.Config,
55
notifier notify.Notifier,
···
58
logger *slog.Logger,
59
) *Issues {
60
return &Issues{
61
-
oauth: oauth,
62
-
repoResolver: repoResolver,
63
-
pages: pages,
64
-
idResolver: idResolver,
65
-
db: db,
66
-
config: config,
67
-
notifier: notifier,
68
-
logger: logger,
69
-
validator: validator,
70
-
indexer: indexer,
71
}
72
}
73
···
97
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
98
}
99
100
labelDefs, err := db.GetLabelDefinitions(
101
rp.db,
102
-
db.FilterIn("at_uri", f.Repo.Labels),
103
-
db.FilterContains("scope", tangled.RepoIssueNSID),
104
)
105
if err != nil {
106
l.Error("failed to fetch labels", "err", err)
···
115
116
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
117
LoggedInUser: user,
118
-
RepoInfo: f.RepoInfo(user),
119
Issue: issue,
120
CommentList: issue.CommentList(),
121
OrderedReactionKinds: models.OrderedReactionKinds,
122
Reactions: reactionMap,
123
UserReacted: userReactions,
···
128
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
129
l := rp.logger.With("handler", "EditIssue")
130
user := rp.oauth.GetUser(r)
131
-
f, err := rp.repoResolver.Resolve(r)
132
-
if err != nil {
133
-
l.Error("failed to get repo and knot", "err", err)
134
-
return
135
-
}
136
137
issue, ok := r.Context().Value("issue").(*models.Issue)
138
if !ok {
···
145
case http.MethodGet:
146
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
147
LoggedInUser: user,
148
-
RepoInfo: f.RepoInfo(user),
149
Issue: issue,
150
})
151
case http.MethodPost:
···
153
newIssue := issue
154
newIssue.Title = r.FormValue("title")
155
newIssue.Body = r.FormValue("body")
156
157
if err := rp.validator.ValidateIssue(newIssue); err != nil {
158
l.Error("validation error", "err", err)
···
222
l := rp.logger.With("handler", "DeleteIssue")
223
noticeId := "issue-actions-error"
224
225
-
user := rp.oauth.GetUser(r)
226
-
227
f, err := rp.repoResolver.Resolve(r)
228
if err != nil {
229
l.Error("failed to get repo and knot", "err", err)
···
238
}
239
l = l.With("did", issue.Did, "rkey", issue.Rkey)
240
241
// delete from PDS
242
client, err := rp.oauth.AuthorizedClient(r)
243
if err != nil {
···
258
}
259
260
// delete from db
261
-
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
262
l.Error("failed to delete issue", "err", err)
263
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
264
return
265
}
266
267
rp.notifier.DeleteIssue(r.Context(), issue)
268
269
// return to all issues page
270
-
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
271
}
272
273
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
286
return
287
}
288
289
-
collaborators, err := f.Collaborators(r.Context())
290
-
if err != nil {
291
-
l.Error("failed to fetch repo collaborators", "err", err)
292
-
}
293
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
294
-
return user.Did == collab.Did
295
-
})
296
isIssueOwner := user.Did == issue.Did
297
298
// TODO: make this more granular
299
-
if isIssueOwner || isCollaborator {
300
err = db.CloseIssues(
301
rp.db,
302
-
db.FilterEq("id", issue.Id),
303
)
304
if err != nil {
305
l.Error("failed to close issue", "err", err)
···
312
// notify about the issue closure
313
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
314
315
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
316
return
317
} else {
318
l.Error("user is not permitted to close issue")
···
337
return
338
}
339
340
-
collaborators, err := f.Collaborators(r.Context())
341
-
if err != nil {
342
-
l.Error("failed to fetch repo collaborators", "err", err)
343
-
}
344
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
345
-
return user.Did == collab.Did
346
-
})
347
isIssueOwner := user.Did == issue.Did
348
349
-
if isCollaborator || isIssueOwner {
350
err := db.ReopenIssues(
351
rp.db,
352
-
db.FilterEq("id", issue.Id),
353
)
354
if err != nil {
355
l.Error("failed to reopen issue", "err", err)
···
362
// notify about the issue reopen
363
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
364
365
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
366
return
367
} else {
368
l.Error("user is not the owner of the repo")
···
398
if replyToUri != "" {
399
replyTo = &replyToUri
400
}
401
402
comment := models.IssueComment{
403
-
Did: user.Did,
404
-
Rkey: tid.TID(),
405
-
IssueAt: issue.AtUri().String(),
406
-
ReplyTo: replyTo,
407
-
Body: body,
408
-
Created: time.Now(),
409
}
410
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
411
l.Error("failed to validate comment", "err", err)
···
442
}
443
}()
444
445
-
commentId, err := db.AddIssueComment(rp.db, comment)
446
if err != nil {
447
l.Error("failed to create comment", "err", err)
448
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
449
return
450
}
451
452
// reset atUri to make rollback a no-op
453
atUri = ""
···
455
// notify about the new comment
456
comment.Id = commentId
457
458
-
rawMentions := markup.FindUserMentions(comment.Body)
459
-
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
460
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
461
-
var mentions []syntax.DID
462
-
for _, ident := range idents {
463
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
464
-
mentions = append(mentions, ident.DID)
465
-
}
466
-
}
467
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
468
469
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
470
}
471
472
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
473
l := rp.logger.With("handler", "IssueComment")
474
user := rp.oauth.GetUser(r)
475
-
f, err := rp.repoResolver.Resolve(r)
476
-
if err != nil {
477
-
l.Error("failed to get repo and knot", "err", err)
478
-
return
479
-
}
480
481
issue, ok := r.Context().Value("issue").(*models.Issue)
482
if !ok {
···
488
commentId := chi.URLParam(r, "commentId")
489
comments, err := db.GetIssueComments(
490
rp.db,
491
-
db.FilterEq("id", commentId),
492
)
493
if err != nil {
494
l.Error("failed to fetch comment", "id", commentId)
···
504
505
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
506
LoggedInUser: user,
507
-
RepoInfo: f.RepoInfo(user),
508
Issue: issue,
509
Comment: &comment,
510
})
···
513
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
514
l := rp.logger.With("handler", "EditIssueComment")
515
user := rp.oauth.GetUser(r)
516
-
f, err := rp.repoResolver.Resolve(r)
517
-
if err != nil {
518
-
l.Error("failed to get repo and knot", "err", err)
519
-
return
520
-
}
521
522
issue, ok := r.Context().Value("issue").(*models.Issue)
523
if !ok {
···
529
commentId := chi.URLParam(r, "commentId")
530
comments, err := db.GetIssueComments(
531
rp.db,
532
-
db.FilterEq("id", commentId),
533
)
534
if err != nil {
535
l.Error("failed to fetch comment", "id", commentId)
···
553
case http.MethodGet:
554
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
555
LoggedInUser: user,
556
-
RepoInfo: f.RepoInfo(user),
557
Issue: issue,
558
Comment: &comment,
559
})
···
571
newComment := comment
572
newComment.Body = newBody
573
newComment.Edited = &now
574
record := newComment.AsRecord()
575
576
-
_, err = db.AddIssueComment(rp.db, newComment)
577
if err != nil {
578
l.Error("failed to perferom update-description query", "err", err)
579
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
580
return
581
}
582
583
// rkey is optional, it was introduced later
584
if newComment.Rkey != "" {
···
607
// return new comment body with htmx
608
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
609
LoggedInUser: user,
610
-
RepoInfo: f.RepoInfo(user),
611
Issue: issue,
612
Comment: &newComment,
613
})
···
617
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
618
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
619
user := rp.oauth.GetUser(r)
620
-
f, err := rp.repoResolver.Resolve(r)
621
-
if err != nil {
622
-
l.Error("failed to get repo and knot", "err", err)
623
-
return
624
-
}
625
626
issue, ok := r.Context().Value("issue").(*models.Issue)
627
if !ok {
···
633
commentId := chi.URLParam(r, "commentId")
634
comments, err := db.GetIssueComments(
635
rp.db,
636
-
db.FilterEq("id", commentId),
637
)
638
if err != nil {
639
l.Error("failed to fetch comment", "id", commentId)
···
649
650
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
651
LoggedInUser: user,
652
-
RepoInfo: f.RepoInfo(user),
653
Issue: issue,
654
Comment: &comment,
655
})
···
658
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
659
l := rp.logger.With("handler", "ReplyIssueComment")
660
user := rp.oauth.GetUser(r)
661
-
f, err := rp.repoResolver.Resolve(r)
662
-
if err != nil {
663
-
l.Error("failed to get repo and knot", "err", err)
664
-
return
665
-
}
666
667
issue, ok := r.Context().Value("issue").(*models.Issue)
668
if !ok {
···
674
commentId := chi.URLParam(r, "commentId")
675
comments, err := db.GetIssueComments(
676
rp.db,
677
-
db.FilterEq("id", commentId),
678
)
679
if err != nil {
680
l.Error("failed to fetch comment", "id", commentId)
···
690
691
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
692
LoggedInUser: user,
693
-
RepoInfo: f.RepoInfo(user),
694
Issue: issue,
695
Comment: &comment,
696
})
···
699
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
700
l := rp.logger.With("handler", "DeleteIssueComment")
701
user := rp.oauth.GetUser(r)
702
-
f, err := rp.repoResolver.Resolve(r)
703
-
if err != nil {
704
-
l.Error("failed to get repo and knot", "err", err)
705
-
return
706
-
}
707
708
issue, ok := r.Context().Value("issue").(*models.Issue)
709
if !ok {
···
715
commentId := chi.URLParam(r, "commentId")
716
comments, err := db.GetIssueComments(
717
rp.db,
718
-
db.FilterEq("id", commentId),
719
)
720
if err != nil {
721
l.Error("failed to fetch comment", "id", commentId)
···
742
743
// optimistic deletion
744
deleted := time.Now()
745
-
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
746
if err != nil {
747
l.Error("failed to delete comment", "err", err)
748
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
774
// htmx fragment of comment after deletion
775
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
776
LoggedInUser: user,
777
-
RepoInfo: f.RepoInfo(user),
778
Issue: issue,
779
Comment: &comment,
780
})
···
804
return
805
}
806
807
keyword := params.Get("q")
808
809
var issues []models.Issue
···
820
return
821
}
822
l.Debug("searched issues with indexer", "count", len(res.Hits))
823
824
issues, err = db.GetIssues(
825
rp.db,
826
-
db.FilterIn("id", res.Hits),
827
)
828
if err != nil {
829
l.Error("failed to get issues", "err", err)
···
839
issues, err = db.GetIssuesPaginated(
840
rp.db,
841
page,
842
-
db.FilterEq("repo_at", f.RepoAt()),
843
-
db.FilterEq("open", openInt),
844
)
845
if err != nil {
846
l.Error("failed to get issues", "err", err)
···
851
852
labelDefs, err := db.GetLabelDefinitions(
853
rp.db,
854
-
db.FilterIn("at_uri", f.Repo.Labels),
855
-
db.FilterContains("scope", tangled.RepoIssueNSID),
856
)
857
if err != nil {
858
l.Error("failed to fetch labels", "err", err)
···
867
868
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
869
LoggedInUser: rp.oauth.GetUser(r),
870
-
RepoInfo: f.RepoInfo(user),
871
Issues: issues,
872
LabelDefs: defs,
873
FilteringByOpen: isOpen,
874
FilterQuery: keyword,
···
890
case http.MethodGet:
891
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
892
LoggedInUser: user,
893
-
RepoInfo: f.RepoInfo(user),
894
})
895
case http.MethodPost:
896
issue := &models.Issue{
897
-
RepoAt: f.RepoAt(),
898
-
Rkey: tid.TID(),
899
-
Title: r.FormValue("title"),
900
-
Body: r.FormValue("body"),
901
-
Open: true,
902
-
Did: user.Did,
903
-
Created: time.Now(),
904
-
Repo: &f.Repo,
905
}
906
907
if err := rp.validator.ValidateIssue(issue); err != nil {
···
969
// everything is successful, do not rollback the atproto record
970
atUri = ""
971
972
-
rawMentions := markup.FindUserMentions(issue.Body)
973
-
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
974
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
975
-
var mentions []syntax.DID
976
-
for _, ident := range idents {
977
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
978
-
mentions = append(mentions, ident.DID)
979
-
}
980
-
}
981
rp.notifier.NewIssue(r.Context(), issue, mentions)
982
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
983
return
984
}
985
}
···
7
"fmt"
8
"log/slog"
9
"net/http"
10
"time"
11
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
19
"tangled.org/core/appview/config"
20
"tangled.org/core/appview/db"
21
issues_indexer "tangled.org/core/appview/indexer/issues"
22
+
"tangled.org/core/appview/mentions"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/repoinfo"
28
"tangled.org/core/appview/pagination"
29
"tangled.org/core/appview/reporesolver"
30
"tangled.org/core/appview/validator"
31
"tangled.org/core/idresolver"
32
+
"tangled.org/core/orm"
33
+
"tangled.org/core/rbac"
34
"tangled.org/core/tid"
35
)
36
37
type Issues struct {
38
+
oauth *oauth.OAuth
39
+
repoResolver *reporesolver.RepoResolver
40
+
enforcer *rbac.Enforcer
41
+
pages *pages.Pages
42
+
idResolver *idresolver.Resolver
43
+
mentionsResolver *mentions.Resolver
44
+
db *db.DB
45
+
config *config.Config
46
+
notifier notify.Notifier
47
+
logger *slog.Logger
48
+
validator *validator.Validator
49
+
indexer *issues_indexer.Indexer
50
}
51
52
func New(
53
oauth *oauth.OAuth,
54
repoResolver *reporesolver.RepoResolver,
55
+
enforcer *rbac.Enforcer,
56
pages *pages.Pages,
57
idResolver *idresolver.Resolver,
58
+
mentionsResolver *mentions.Resolver,
59
db *db.DB,
60
config *config.Config,
61
notifier notify.Notifier,
···
64
logger *slog.Logger,
65
) *Issues {
66
return &Issues{
67
+
oauth: oauth,
68
+
repoResolver: repoResolver,
69
+
enforcer: enforcer,
70
+
pages: pages,
71
+
idResolver: idResolver,
72
+
mentionsResolver: mentionsResolver,
73
+
db: db,
74
+
config: config,
75
+
notifier: notifier,
76
+
logger: logger,
77
+
validator: validator,
78
+
indexer: indexer,
79
}
80
}
81
···
105
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
106
}
107
108
+
backlinks, err := db.GetBacklinks(rp.db, issue.AtUri())
109
+
if err != nil {
110
+
l.Error("failed to fetch backlinks", "err", err)
111
+
rp.pages.Error503(w)
112
+
return
113
+
}
114
+
115
labelDefs, err := db.GetLabelDefinitions(
116
rp.db,
117
+
orm.FilterIn("at_uri", f.Labels),
118
+
orm.FilterContains("scope", tangled.RepoIssueNSID),
119
)
120
if err != nil {
121
l.Error("failed to fetch labels", "err", err)
···
130
131
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
132
LoggedInUser: user,
133
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
134
Issue: issue,
135
CommentList: issue.CommentList(),
136
+
Backlinks: backlinks,
137
OrderedReactionKinds: models.OrderedReactionKinds,
138
Reactions: reactionMap,
139
UserReacted: userReactions,
···
144
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
145
l := rp.logger.With("handler", "EditIssue")
146
user := rp.oauth.GetUser(r)
147
148
issue, ok := r.Context().Value("issue").(*models.Issue)
149
if !ok {
···
156
case http.MethodGet:
157
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
158
LoggedInUser: user,
159
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
160
Issue: issue,
161
})
162
case http.MethodPost:
···
164
newIssue := issue
165
newIssue.Title = r.FormValue("title")
166
newIssue.Body = r.FormValue("body")
167
+
newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body)
168
169
if err := rp.validator.ValidateIssue(newIssue); err != nil {
170
l.Error("validation error", "err", err)
···
234
l := rp.logger.With("handler", "DeleteIssue")
235
noticeId := "issue-actions-error"
236
237
f, err := rp.repoResolver.Resolve(r)
238
if err != nil {
239
l.Error("failed to get repo and knot", "err", err)
···
248
}
249
l = l.With("did", issue.Did, "rkey", issue.Rkey)
250
251
+
tx, err := rp.db.Begin()
252
+
if err != nil {
253
+
l.Error("failed to start transaction", "err", err)
254
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
255
+
return
256
+
}
257
+
defer tx.Rollback()
258
+
259
// delete from PDS
260
client, err := rp.oauth.AuthorizedClient(r)
261
if err != nil {
···
276
}
277
278
// delete from db
279
+
if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
280
l.Error("failed to delete issue", "err", err)
281
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
282
return
283
}
284
+
tx.Commit()
285
286
rp.notifier.DeleteIssue(r.Context(), issue)
287
288
// return to all issues page
289
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
290
+
rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")
291
}
292
293
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
306
return
307
}
308
309
+
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
310
+
isRepoOwner := roles.IsOwner()
311
+
isCollaborator := roles.IsCollaborator()
312
isIssueOwner := user.Did == issue.Did
313
314
// TODO: make this more granular
315
+
if isIssueOwner || isRepoOwner || isCollaborator {
316
err = db.CloseIssues(
317
rp.db,
318
+
orm.FilterEq("id", issue.Id),
319
)
320
if err != nil {
321
l.Error("failed to close issue", "err", err)
···
328
// notify about the issue closure
329
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
330
331
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
332
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
333
return
334
} else {
335
l.Error("user is not permitted to close issue")
···
354
return
355
}
356
357
+
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
358
+
isRepoOwner := roles.IsOwner()
359
+
isCollaborator := roles.IsCollaborator()
360
isIssueOwner := user.Did == issue.Did
361
362
+
if isCollaborator || isRepoOwner || isIssueOwner {
363
err := db.ReopenIssues(
364
rp.db,
365
+
orm.FilterEq("id", issue.Id),
366
)
367
if err != nil {
368
l.Error("failed to reopen issue", "err", err)
···
375
// notify about the issue reopen
376
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
377
378
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
379
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
380
return
381
} else {
382
l.Error("user is not the owner of the repo")
···
412
if replyToUri != "" {
413
replyTo = &replyToUri
414
}
415
+
416
+
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
417
418
comment := models.IssueComment{
419
+
Did: user.Did,
420
+
Rkey: tid.TID(),
421
+
IssueAt: issue.AtUri().String(),
422
+
ReplyTo: replyTo,
423
+
Body: body,
424
+
Created: time.Now(),
425
+
Mentions: mentions,
426
+
References: references,
427
}
428
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
429
l.Error("failed to validate comment", "err", err)
···
460
}
461
}()
462
463
+
tx, err := rp.db.Begin()
464
+
if err != nil {
465
+
l.Error("failed to start transaction", "err", err)
466
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
467
+
return
468
+
}
469
+
defer tx.Rollback()
470
+
471
+
commentId, err := db.AddIssueComment(tx, comment)
472
if err != nil {
473
l.Error("failed to create comment", "err", err)
474
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
475
return
476
}
477
+
err = tx.Commit()
478
+
if err != nil {
479
+
l.Error("failed to commit transaction", "err", err)
480
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
481
+
return
482
+
}
483
484
// reset atUri to make rollback a no-op
485
atUri = ""
···
487
// notify about the new comment
488
comment.Id = commentId
489
490
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
491
492
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
493
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
494
}
495
496
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
497
l := rp.logger.With("handler", "IssueComment")
498
user := rp.oauth.GetUser(r)
499
500
issue, ok := r.Context().Value("issue").(*models.Issue)
501
if !ok {
···
507
commentId := chi.URLParam(r, "commentId")
508
comments, err := db.GetIssueComments(
509
rp.db,
510
+
orm.FilterEq("id", commentId),
511
)
512
if err != nil {
513
l.Error("failed to fetch comment", "id", commentId)
···
523
524
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
525
LoggedInUser: user,
526
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
527
Issue: issue,
528
Comment: &comment,
529
})
···
532
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
533
l := rp.logger.With("handler", "EditIssueComment")
534
user := rp.oauth.GetUser(r)
535
536
issue, ok := r.Context().Value("issue").(*models.Issue)
537
if !ok {
···
543
commentId := chi.URLParam(r, "commentId")
544
comments, err := db.GetIssueComments(
545
rp.db,
546
+
orm.FilterEq("id", commentId),
547
)
548
if err != nil {
549
l.Error("failed to fetch comment", "id", commentId)
···
567
case http.MethodGet:
568
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
569
LoggedInUser: user,
570
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
571
Issue: issue,
572
Comment: &comment,
573
})
···
585
newComment := comment
586
newComment.Body = newBody
587
newComment.Edited = &now
588
+
newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
589
+
590
record := newComment.AsRecord()
591
592
+
tx, err := rp.db.Begin()
593
+
if err != nil {
594
+
l.Error("failed to start transaction", "err", err)
595
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
596
+
return
597
+
}
598
+
defer tx.Rollback()
599
+
600
+
_, err = db.AddIssueComment(tx, newComment)
601
if err != nil {
602
l.Error("failed to perferom update-description query", "err", err)
603
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
604
return
605
}
606
+
tx.Commit()
607
608
// rkey is optional, it was introduced later
609
if newComment.Rkey != "" {
···
632
// return new comment body with htmx
633
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
634
LoggedInUser: user,
635
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
636
Issue: issue,
637
Comment: &newComment,
638
})
···
642
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
643
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
644
user := rp.oauth.GetUser(r)
645
646
issue, ok := r.Context().Value("issue").(*models.Issue)
647
if !ok {
···
653
commentId := chi.URLParam(r, "commentId")
654
comments, err := db.GetIssueComments(
655
rp.db,
656
+
orm.FilterEq("id", commentId),
657
)
658
if err != nil {
659
l.Error("failed to fetch comment", "id", commentId)
···
669
670
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
671
LoggedInUser: user,
672
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
673
Issue: issue,
674
Comment: &comment,
675
})
···
678
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
679
l := rp.logger.With("handler", "ReplyIssueComment")
680
user := rp.oauth.GetUser(r)
681
682
issue, ok := r.Context().Value("issue").(*models.Issue)
683
if !ok {
···
689
commentId := chi.URLParam(r, "commentId")
690
comments, err := db.GetIssueComments(
691
rp.db,
692
+
orm.FilterEq("id", commentId),
693
)
694
if err != nil {
695
l.Error("failed to fetch comment", "id", commentId)
···
705
706
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
707
LoggedInUser: user,
708
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
709
Issue: issue,
710
Comment: &comment,
711
})
···
714
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
715
l := rp.logger.With("handler", "DeleteIssueComment")
716
user := rp.oauth.GetUser(r)
717
718
issue, ok := r.Context().Value("issue").(*models.Issue)
719
if !ok {
···
725
commentId := chi.URLParam(r, "commentId")
726
comments, err := db.GetIssueComments(
727
rp.db,
728
+
orm.FilterEq("id", commentId),
729
)
730
if err != nil {
731
l.Error("failed to fetch comment", "id", commentId)
···
752
753
// optimistic deletion
754
deleted := time.Now()
755
+
err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id))
756
if err != nil {
757
l.Error("failed to delete comment", "err", err)
758
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
784
// htmx fragment of comment after deletion
785
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
786
LoggedInUser: user,
787
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
788
Issue: issue,
789
Comment: &comment,
790
})
···
814
return
815
}
816
817
+
totalIssues := 0
818
+
if isOpen {
819
+
totalIssues = f.RepoStats.IssueCount.Open
820
+
} else {
821
+
totalIssues = f.RepoStats.IssueCount.Closed
822
+
}
823
+
824
keyword := params.Get("q")
825
826
var issues []models.Issue
···
837
return
838
}
839
l.Debug("searched issues with indexer", "count", len(res.Hits))
840
+
totalIssues = int(res.Total)
841
842
issues, err = db.GetIssues(
843
rp.db,
844
+
orm.FilterIn("id", res.Hits),
845
)
846
if err != nil {
847
l.Error("failed to get issues", "err", err)
···
857
issues, err = db.GetIssuesPaginated(
858
rp.db,
859
page,
860
+
orm.FilterEq("repo_at", f.RepoAt()),
861
+
orm.FilterEq("open", openInt),
862
)
863
if err != nil {
864
l.Error("failed to get issues", "err", err)
···
869
870
labelDefs, err := db.GetLabelDefinitions(
871
rp.db,
872
+
orm.FilterIn("at_uri", f.Labels),
873
+
orm.FilterContains("scope", tangled.RepoIssueNSID),
874
)
875
if err != nil {
876
l.Error("failed to fetch labels", "err", err)
···
885
886
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
887
LoggedInUser: rp.oauth.GetUser(r),
888
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
889
Issues: issues,
890
+
IssueCount: totalIssues,
891
LabelDefs: defs,
892
FilteringByOpen: isOpen,
893
FilterQuery: keyword,
···
909
case http.MethodGet:
910
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
911
LoggedInUser: user,
912
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
913
})
914
case http.MethodPost:
915
+
body := r.FormValue("body")
916
+
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
917
+
918
issue := &models.Issue{
919
+
RepoAt: f.RepoAt(),
920
+
Rkey: tid.TID(),
921
+
Title: r.FormValue("title"),
922
+
Body: body,
923
+
Open: true,
924
+
Did: user.Did,
925
+
Created: time.Now(),
926
+
Mentions: mentions,
927
+
References: references,
928
+
Repo: f,
929
}
930
931
if err := rp.validator.ValidateIssue(issue); err != nil {
···
993
// everything is successful, do not rollback the atproto record
994
atUri = ""
995
996
rp.notifier.NewIssue(r.Context(), issue, mentions)
997
+
998
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
999
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
1000
return
1001
}
1002
}
+5
-5
appview/issues/opengraph.go
+5
-5
appview/issues/opengraph.go
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
}
200
201
// Draw "opened by @author" and date at the bottom with more spacing
···
232
233
// Get owner handle for avatar
234
var ownerHandle string
235
-
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
236
if err != nil {
237
-
ownerHandle = f.Repo.Did
238
} else {
239
ownerHandle = "@" + owner.Handle.String()
240
}
241
242
-
card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
243
if err != nil {
244
log.Println("failed to draw issue summary card", err)
245
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
+
log.Printf("dolly not available (this is ok): %v", err)
199
}
200
201
// Draw "opened by @author" and date at the bottom with more spacing
···
232
233
// Get owner handle for avatar
234
var ownerHandle string
235
+
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did)
236
if err != nil {
237
+
ownerHandle = f.Did
238
} else {
239
ownerHandle = "@" + owner.Handle.String()
240
}
241
242
+
card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle)
243
if err != nil {
244
log.Println("failed to draw issue summary card", err)
245
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+37
-24
appview/knots/knots.go
+37
-24
appview/knots/knots.go
···
21
"tangled.org/core/appview/xrpcclient"
22
"tangled.org/core/eventconsumer"
23
"tangled.org/core/idresolver"
24
"tangled.org/core/rbac"
25
"tangled.org/core/tid"
26
···
39
Knotstream *eventconsumer.Consumer
40
}
41
42
func (k *Knots) Router() http.Handler {
43
r := chi.NewRouter()
44
···
59
user := k.OAuth.GetUser(r)
60
registrations, err := db.GetRegistrations(
61
k.Db,
62
-
db.FilterEq("did", user.Did),
63
)
64
if err != nil {
65
k.Logger.Error("failed to fetch knot registrations", "err", err)
···
70
k.Pages.Knots(w, pages.KnotsParams{
71
LoggedInUser: user,
72
Registrations: registrations,
73
})
74
}
75
···
87
88
registrations, err := db.GetRegistrations(
89
k.Db,
90
-
db.FilterEq("did", user.Did),
91
-
db.FilterEq("domain", domain),
92
)
93
if err != nil {
94
l.Error("failed to get registrations", "err", err)
···
112
repos, err := db.GetRepos(
113
k.Db,
114
0,
115
-
db.FilterEq("knot", domain),
116
)
117
if err != nil {
118
l.Error("failed to get knot repos", "err", err)
···
132
Members: members,
133
Repos: repoMap,
134
IsOwner: true,
135
})
136
}
137
···
276
// get record from db first
277
registrations, err := db.GetRegistrations(
278
k.Db,
279
-
db.FilterEq("did", user.Did),
280
-
db.FilterEq("domain", domain),
281
)
282
if err != nil {
283
l.Error("failed to get registration", "err", err)
···
304
305
err = db.DeleteKnot(
306
tx,
307
-
db.FilterEq("did", user.Did),
308
-
db.FilterEq("domain", domain),
309
)
310
if err != nil {
311
l.Error("failed to delete registration", "err", err)
···
385
// get record from db first
386
registrations, err := db.GetRegistrations(
387
k.Db,
388
-
db.FilterEq("did", user.Did),
389
-
db.FilterEq("domain", domain),
390
)
391
if err != nil {
392
l.Error("failed to get registration", "err", err)
···
476
// Get updated registration to show
477
registrations, err = db.GetRegistrations(
478
k.Db,
479
-
db.FilterEq("did", user.Did),
480
-
db.FilterEq("domain", domain),
481
)
482
if err != nil {
483
l.Error("failed to get registration", "err", err)
···
512
513
registrations, err := db.GetRegistrations(
514
k.Db,
515
-
db.FilterEq("did", user.Did),
516
-
db.FilterEq("domain", domain),
517
-
db.FilterIsNot("registered", "null"),
518
)
519
if err != nil {
520
l.Error("failed to get registration", "err", err)
···
596
}
597
598
// success
599
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
600
}
601
602
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
···
620
621
registrations, err := db.GetRegistrations(
622
k.Db,
623
-
db.FilterEq("did", user.Did),
624
-
db.FilterEq("domain", domain),
625
-
db.FilterIsNot("registered", "null"),
626
)
627
if err != nil {
628
l.Error("failed to get registration", "err", err)
···
645
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
646
if err != nil {
647
l.Error("failed to resolve member identity to handle", "err", err)
648
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
649
-
return
650
-
}
651
-
if memberId.Handle.IsInvalidHandle() {
652
-
l.Error("failed to resolve member identity to handle")
653
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
654
return
655
}
···
21
"tangled.org/core/appview/xrpcclient"
22
"tangled.org/core/eventconsumer"
23
"tangled.org/core/idresolver"
24
+
"tangled.org/core/orm"
25
"tangled.org/core/rbac"
26
"tangled.org/core/tid"
27
···
40
Knotstream *eventconsumer.Consumer
41
}
42
43
+
type tab = map[string]any
44
+
45
+
var (
46
+
knotsTabs []tab = []tab{
47
+
{"Name": "profile", "Icon": "user"},
48
+
{"Name": "keys", "Icon": "key"},
49
+
{"Name": "emails", "Icon": "mail"},
50
+
{"Name": "notifications", "Icon": "bell"},
51
+
{"Name": "knots", "Icon": "volleyball"},
52
+
{"Name": "spindles", "Icon": "spool"},
53
+
}
54
+
)
55
+
56
func (k *Knots) Router() http.Handler {
57
r := chi.NewRouter()
58
···
73
user := k.OAuth.GetUser(r)
74
registrations, err := db.GetRegistrations(
75
k.Db,
76
+
orm.FilterEq("did", user.Did),
77
)
78
if err != nil {
79
k.Logger.Error("failed to fetch knot registrations", "err", err)
···
84
k.Pages.Knots(w, pages.KnotsParams{
85
LoggedInUser: user,
86
Registrations: registrations,
87
+
Tabs: knotsTabs,
88
+
Tab: "knots",
89
})
90
}
91
···
103
104
registrations, err := db.GetRegistrations(
105
k.Db,
106
+
orm.FilterEq("did", user.Did),
107
+
orm.FilterEq("domain", domain),
108
)
109
if err != nil {
110
l.Error("failed to get registrations", "err", err)
···
128
repos, err := db.GetRepos(
129
k.Db,
130
0,
131
+
orm.FilterEq("knot", domain),
132
)
133
if err != nil {
134
l.Error("failed to get knot repos", "err", err)
···
148
Members: members,
149
Repos: repoMap,
150
IsOwner: true,
151
+
Tabs: knotsTabs,
152
+
Tab: "knots",
153
})
154
}
155
···
294
// get record from db first
295
registrations, err := db.GetRegistrations(
296
k.Db,
297
+
orm.FilterEq("did", user.Did),
298
+
orm.FilterEq("domain", domain),
299
)
300
if err != nil {
301
l.Error("failed to get registration", "err", err)
···
322
323
err = db.DeleteKnot(
324
tx,
325
+
orm.FilterEq("did", user.Did),
326
+
orm.FilterEq("domain", domain),
327
)
328
if err != nil {
329
l.Error("failed to delete registration", "err", err)
···
403
// get record from db first
404
registrations, err := db.GetRegistrations(
405
k.Db,
406
+
orm.FilterEq("did", user.Did),
407
+
orm.FilterEq("domain", domain),
408
)
409
if err != nil {
410
l.Error("failed to get registration", "err", err)
···
494
// Get updated registration to show
495
registrations, err = db.GetRegistrations(
496
k.Db,
497
+
orm.FilterEq("did", user.Did),
498
+
orm.FilterEq("domain", domain),
499
)
500
if err != nil {
501
l.Error("failed to get registration", "err", err)
···
530
531
registrations, err := db.GetRegistrations(
532
k.Db,
533
+
orm.FilterEq("did", user.Did),
534
+
orm.FilterEq("domain", domain),
535
+
orm.FilterIsNot("registered", "null"),
536
)
537
if err != nil {
538
l.Error("failed to get registration", "err", err)
···
614
}
615
616
// success
617
+
k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain))
618
}
619
620
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
···
638
639
registrations, err := db.GetRegistrations(
640
k.Db,
641
+
orm.FilterEq("did", user.Did),
642
+
orm.FilterEq("domain", domain),
643
+
orm.FilterIsNot("registered", "null"),
644
)
645
if err != nil {
646
l.Error("failed to get registration", "err", err)
···
663
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
664
if err != nil {
665
l.Error("failed to resolve member identity to handle", "err", err)
666
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
667
return
668
}
+5
-4
appview/labels/labels.go
+5
-4
appview/labels/labels.go
···
16
"tangled.org/core/appview/oauth"
17
"tangled.org/core/appview/pages"
18
"tangled.org/core/appview/validator"
19
"tangled.org/core/rbac"
20
"tangled.org/core/tid"
21
···
88
repoAt := r.Form.Get("repo")
89
subjectUri := r.Form.Get("subject")
90
91
-
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
92
if err != nil {
93
fail("Failed to get repository.", err)
94
return
95
}
96
97
// find all the labels that this repo subscribes to
98
-
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
99
if err != nil {
100
fail("Failed to get labels for this repository.", err)
101
return
···
106
labelAts = append(labelAts, rl.LabelAt.String())
107
}
108
109
-
actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts))
110
if err != nil {
111
fail("Invalid form data.", err)
112
return
113
}
114
115
// calculate the start state by applying already known labels
116
-
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
117
if err != nil {
118
fail("Invalid form data.", err)
119
return
···
16
"tangled.org/core/appview/oauth"
17
"tangled.org/core/appview/pages"
18
"tangled.org/core/appview/validator"
19
+
"tangled.org/core/orm"
20
"tangled.org/core/rbac"
21
"tangled.org/core/tid"
22
···
89
repoAt := r.Form.Get("repo")
90
subjectUri := r.Form.Get("subject")
91
92
+
repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt))
93
if err != nil {
94
fail("Failed to get repository.", err)
95
return
96
}
97
98
// find all the labels that this repo subscribes to
99
+
repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt))
100
if err != nil {
101
fail("Failed to get labels for this repository.", err)
102
return
···
107
labelAts = append(labelAts, rl.LabelAt.String())
108
}
109
110
+
actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts))
111
if err != nil {
112
fail("Invalid form data.", err)
113
return
114
}
115
116
// calculate the start state by applying already known labels
117
+
existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri))
118
if err != nil {
119
fail("Invalid form data.", err)
120
return
+67
appview/mentions/resolver.go
+67
appview/mentions/resolver.go
···
···
1
+
package mentions
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.org/core/appview/config"
9
+
"tangled.org/core/appview/db"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/pages/markup"
12
+
"tangled.org/core/idresolver"
13
+
)
14
+
15
+
type Resolver struct {
16
+
config *config.Config
17
+
idResolver *idresolver.Resolver
18
+
execer db.Execer
19
+
logger *slog.Logger
20
+
}
21
+
22
+
func New(
23
+
config *config.Config,
24
+
idResolver *idresolver.Resolver,
25
+
execer db.Execer,
26
+
logger *slog.Logger,
27
+
) *Resolver {
28
+
return &Resolver{
29
+
config,
30
+
idResolver,
31
+
execer,
32
+
logger,
33
+
}
34
+
}
35
+
36
+
func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) {
37
+
l := r.logger.With("method", "Resolve")
38
+
39
+
rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source)
40
+
l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs)
41
+
42
+
idents := r.idResolver.ResolveIdents(ctx, rawMentions)
43
+
var mentions []syntax.DID
44
+
for _, ident := range idents {
45
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
46
+
mentions = append(mentions, ident.DID)
47
+
}
48
+
}
49
+
l.Debug("found mentions", "mentions", mentions)
50
+
51
+
var resolvedRefs []models.ReferenceLink
52
+
for _, rawRef := range rawRefs {
53
+
ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle)
54
+
if err != nil || ident == nil || ident.Handle.IsInvalidHandle() {
55
+
continue
56
+
}
57
+
rawRef.Handle = string(ident.DID)
58
+
resolvedRefs = append(resolvedRefs, rawRef)
59
+
}
60
+
aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs)
61
+
if err != nil {
62
+
l.Error("failed running query", "err", err)
63
+
}
64
+
l.Debug("found references", "refs", aturiRefs)
65
+
66
+
return mentions, aturiRefs
67
+
}
+9
-4
appview/middleware/middleware.go
+9
-4
appview/middleware/middleware.go
···
18
"tangled.org/core/appview/pagination"
19
"tangled.org/core/appview/reporesolver"
20
"tangled.org/core/idresolver"
21
"tangled.org/core/rbac"
22
)
23
···
164
ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
165
if err != nil || !ok {
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())
168
http.Error(w, "Forbiden", http.StatusUnauthorized)
169
return
170
}
···
217
218
repo, err := db.GetRepo(
219
mw.db,
220
-
db.FilterEq("did", id.DID.String()),
221
-
db.FilterEq("name", repoName),
222
)
223
if err != nil {
224
log.Println("failed to resolve repo", "err", err)
225
mw.pages.ErrorKnot404(w)
226
return
227
}
···
239
f, err := mw.repoResolver.Resolve(r)
240
if err != nil {
241
log.Println("failed to fully resolve repo", err)
242
mw.pages.ErrorKnot404(w)
243
return
244
}
···
287
f, err := mw.repoResolver.Resolve(r)
288
if err != nil {
289
log.Println("failed to fully resolve repo", err)
290
mw.pages.ErrorKnot404(w)
291
return
292
}
···
323
f, err := mw.repoResolver.Resolve(r)
324
if err != nil {
325
log.Println("failed to fully resolve repo", err)
326
mw.pages.ErrorKnot404(w)
327
return
328
}
329
330
-
fullName := f.OwnerHandle() + "/" + f.Name
331
332
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
333
if r.URL.Query().Get("go-get") == "1" {
···
18
"tangled.org/core/appview/pagination"
19
"tangled.org/core/appview/reporesolver"
20
"tangled.org/core/idresolver"
21
+
"tangled.org/core/orm"
22
"tangled.org/core/rbac"
23
)
24
···
165
ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
166
if err != nil || !ok {
167
// we need a logged in user
168
+
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo())
169
http.Error(w, "Forbiden", http.StatusUnauthorized)
170
return
171
}
···
218
219
repo, err := db.GetRepo(
220
mw.db,
221
+
orm.FilterEq("did", id.DID.String()),
222
+
orm.FilterEq("name", repoName),
223
)
224
if err != nil {
225
log.Println("failed to resolve repo", "err", err)
226
+
w.WriteHeader(http.StatusNotFound)
227
mw.pages.ErrorKnot404(w)
228
return
229
}
···
241
f, err := mw.repoResolver.Resolve(r)
242
if err != nil {
243
log.Println("failed to fully resolve repo", err)
244
+
w.WriteHeader(http.StatusNotFound)
245
mw.pages.ErrorKnot404(w)
246
return
247
}
···
290
f, err := mw.repoResolver.Resolve(r)
291
if err != nil {
292
log.Println("failed to fully resolve repo", err)
293
+
w.WriteHeader(http.StatusNotFound)
294
mw.pages.ErrorKnot404(w)
295
return
296
}
···
327
f, err := mw.repoResolver.Resolve(r)
328
if err != nil {
329
log.Println("failed to fully resolve repo", err)
330
+
w.WriteHeader(http.StatusNotFound)
331
mw.pages.ErrorKnot404(w)
332
return
333
}
334
335
+
fullName := reporesolver.GetBaseRepoPath(r, f)
336
337
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
338
if r.URL.Query().Get("go-get") == "1" {
+70
-34
appview/models/issue.go
+70
-34
appview/models/issue.go
···
10
)
11
12
type Issue struct {
13
-
Id int64
14
-
Did string
15
-
Rkey string
16
-
RepoAt syntax.ATURI
17
-
IssueId int
18
-
Created time.Time
19
-
Edited *time.Time
20
-
Deleted *time.Time
21
-
Title string
22
-
Body string
23
-
Open bool
24
25
// optionally, populate this when querying for reverse mappings
26
// like comment counts, parent repo etc.
···
34
}
35
36
func (i *Issue) AsRecord() tangled.RepoIssue {
37
return tangled.RepoIssue{
38
-
Repo: i.RepoAt.String(),
39
-
Title: i.Title,
40
-
Body: &i.Body,
41
-
CreatedAt: i.Created.Format(time.RFC3339),
42
}
43
}
44
···
161
}
162
163
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
173
}
174
175
func (i *IssueComment) AtUri() syntax.ATURI {
···
177
}
178
179
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
180
return tangled.RepoIssueComment{
181
-
Body: i.Body,
182
-
Issue: i.IssueAt,
183
-
CreatedAt: i.Created.Format(time.RFC3339),
184
-
ReplyTo: i.ReplyTo,
185
}
186
}
187
···
205
return nil, err
206
}
207
208
comment := IssueComment{
209
-
Did: ownerDid,
210
-
Rkey: rkey,
211
-
Body: record.Body,
212
-
IssueAt: record.Issue,
213
-
ReplyTo: record.ReplyTo,
214
-
Created: created,
215
}
216
217
return &comment, nil
···
10
)
11
12
type Issue struct {
13
+
Id int64
14
+
Did string
15
+
Rkey string
16
+
RepoAt syntax.ATURI
17
+
IssueId int
18
+
Created time.Time
19
+
Edited *time.Time
20
+
Deleted *time.Time
21
+
Title string
22
+
Body string
23
+
Open bool
24
+
Mentions []syntax.DID
25
+
References []syntax.ATURI
26
27
// optionally, populate this when querying for reverse mappings
28
// like comment counts, parent repo etc.
···
36
}
37
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
+
}
47
return tangled.RepoIssue{
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),
54
}
55
}
56
···
173
}
174
175
type IssueComment struct {
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
187
}
188
189
func (i *IssueComment) AtUri() syntax.ATURI {
···
191
}
192
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
+
}
202
return tangled.RepoIssueComment{
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,
209
}
210
}
211
···
229
return nil, err
230
}
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
+
242
comment := IssueComment{
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,
251
}
252
253
return &comment, nil
+3
-1
appview/models/profile.go
+3
-1
appview/models/profile.go
···
111
}
112
113
type ByMonth struct {
114
RepoEvents []RepoEvent
115
IssueEvents IssueEvents
116
PullEvents PullEvents
···
119
func (b ByMonth) IsEmpty() bool {
120
return len(b.RepoEvents) == 0 &&
121
len(b.IssueEvents.Items) == 0 &&
122
-
len(b.PullEvents.Items) == 0
123
}
124
125
type IssueEvents struct {
···
111
}
112
113
type ByMonth struct {
114
+
Commits int
115
RepoEvents []RepoEvent
116
IssueEvents IssueEvents
117
PullEvents PullEvents
···
120
func (b ByMonth) IsEmpty() bool {
121
return len(b.RepoEvents) == 0 &&
122
len(b.IssueEvents.Items) == 0 &&
123
+
len(b.PullEvents.Items) == 0 &&
124
+
b.Commits == 0
125
}
126
127
type IssueEvents struct {
+42
-4
appview/models/pull.go
+42
-4
appview/models/pull.go
···
66
TargetBranch string
67
State PullState
68
Submissions []*PullSubmission
69
70
// stacking
71
StackId string // nullable string
···
81
Repo *Repo
82
}
83
84
func (p Pull) AsRecord() tangled.RepoPull {
85
var source *tangled.RepoPull_Source
86
if p.PullSource != nil {
···
92
source.Repo = &s
93
}
94
}
95
96
record := tangled.RepoPull{
97
-
Title: p.Title,
98
-
Body: &p.Body,
99
-
CreatedAt: p.Created.Format(time.RFC3339),
100
Target: &tangled.RepoPull_Target{
101
Repo: p.RepoAt.String(),
102
Branch: p.TargetBranch,
103
},
104
-
Patch: p.LatestPatch(),
105
Source: source,
106
}
107
return record
···
148
Body string
149
150
// meta
151
Created time.Time
152
}
153
154
func (p *Pull) LastRoundNumber() int {
155
return len(p.Submissions) - 1
···
66
TargetBranch string
67
State PullState
68
Submissions []*PullSubmission
69
+
Mentions []syntax.DID
70
+
References []syntax.ATURI
71
72
// stacking
73
StackId string // nullable string
···
83
Repo *Repo
84
}
85
86
+
// NOTE: This method does not include patch blob in returned atproto record
87
func (p Pull) AsRecord() tangled.RepoPull {
88
var source *tangled.RepoPull_Source
89
if p.PullSource != nil {
···
95
source.Repo = &s
96
}
97
}
98
+
mentions := make([]string, len(p.Mentions))
99
+
for i, did := range p.Mentions {
100
+
mentions[i] = string(did)
101
+
}
102
+
references := make([]string, len(p.References))
103
+
for i, uri := range p.References {
104
+
references[i] = string(uri)
105
+
}
106
107
record := tangled.RepoPull{
108
+
Title: p.Title,
109
+
Body: &p.Body,
110
+
Mentions: mentions,
111
+
References: references,
112
+
CreatedAt: p.Created.Format(time.RFC3339),
113
Target: &tangled.RepoPull_Target{
114
Repo: p.RepoAt.String(),
115
Branch: p.TargetBranch,
116
},
117
Source: source,
118
}
119
return record
···
160
Body string
161
162
// meta
163
+
Mentions []syntax.DID
164
+
References []syntax.ATURI
165
+
166
+
// meta
167
Created time.Time
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
+
// }
191
192
func (p *Pull) LastRoundNumber() int {
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
+
}
+14
-5
appview/models/star.go
+14
-5
appview/models/star.go
···
7
)
8
9
type Star struct {
10
+
Did string
11
+
RepoAt syntax.ATURI
12
+
Created time.Time
13
+
Rkey string
14
+
}
15
16
+
// RepoStar is used for reverse mapping to repos
17
+
type RepoStar struct {
18
+
Star
19
Repo *Repo
20
}
21
+
22
+
// StringStar is used for reverse mapping to strings
23
+
type StringStar struct {
24
+
Star
25
+
String *String
26
+
}
+1
-1
appview/models/string.go
+1
-1
appview/models/string.go
+1
-1
appview/models/timeline.go
+1
-1
appview/models/timeline.go
+5
-4
appview/notifications/notifications.go
+5
-4
appview/notifications/notifications.go
···
11
"tangled.org/core/appview/oauth"
12
"tangled.org/core/appview/pages"
13
"tangled.org/core/appview/pagination"
14
)
15
16
type Notifications struct {
···
53
54
total, err := db.CountNotifications(
55
n.db,
56
-
db.FilterEq("recipient_did", user.Did),
57
)
58
if err != nil {
59
l.Error("failed to get total notifications", "err", err)
···
64
notifications, err := db.GetNotificationsWithEntities(
65
n.db,
66
page,
67
-
db.FilterEq("recipient_did", user.Did),
68
)
69
if err != nil {
70
l.Error("failed to get notifications", "err", err)
···
96
97
count, err := db.CountNotifications(
98
n.db,
99
-
db.FilterEq("recipient_did", user.Did),
100
-
db.FilterEq("read", 0),
101
)
102
if err != nil {
103
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
···
11
"tangled.org/core/appview/oauth"
12
"tangled.org/core/appview/pages"
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
15
)
16
17
type Notifications struct {
···
54
55
total, err := db.CountNotifications(
56
n.db,
57
+
orm.FilterEq("recipient_did", user.Did),
58
)
59
if err != nil {
60
l.Error("failed to get total notifications", "err", err)
···
65
notifications, err := db.GetNotificationsWithEntities(
66
n.db,
67
page,
68
+
orm.FilterEq("recipient_did", user.Did),
69
)
70
if err != nil {
71
l.Error("failed to get notifications", "err", err)
···
97
98
count, err := db.CountNotifications(
99
n.db,
100
+
orm.FilterEq("recipient_did", user.Did),
101
+
orm.FilterEq("read", 0),
102
)
103
if err != nil {
104
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+83
-67
appview/notify/db/db.go
+83
-67
appview/notify/db/db.go
···
3
import (
4
"context"
5
"log"
6
-
"maps"
7
"slices"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/db"
11
"tangled.org/core/appview/models"
12
"tangled.org/core/appview/notify"
13
"tangled.org/core/idresolver"
14
)
15
16
const (
17
-
maxMentions = 5
18
)
19
20
type databaseNotifier struct {
···
36
}
37
38
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
39
var err error
40
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
41
if err != nil {
42
log.Printf("NewStar: failed to get repos: %v", err)
43
return
44
}
45
46
-
actorDid := syntax.DID(star.StarredByDid)
47
-
recipients := []syntax.DID{syntax.DID(repo.Did)}
48
eventType := models.NotificationTypeRepoStarred
49
entityType := "repo"
50
entityId := star.RepoAt.String()
···
69
}
70
71
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
72
-
73
-
// build the recipients list
74
-
// - owner of the repo
75
-
// - collaborators in the repo
76
-
var recipients []syntax.DID
77
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
78
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
79
if err != nil {
80
log.Printf("failed to fetch collaborators: %v", err)
81
return
82
}
83
for _, c := range collaborators {
84
-
recipients = append(recipients, c.SubjectDid)
85
}
86
87
actorDid := syntax.DID(issue.Did)
···
103
)
104
n.notifyEvent(
105
actorDid,
106
-
mentions,
107
models.NotificationTypeUserMentioned,
108
entityType,
109
entityId,
···
114
}
115
116
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
117
-
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
118
if err != nil {
119
log.Printf("NewIssueComment: failed to get issues: %v", err)
120
return
···
125
}
126
issue := issues[0]
127
128
-
var recipients []syntax.DID
129
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
130
131
if comment.IsReply() {
132
// if this comment is a reply, then notify everybody in that thread
133
parentAtUri := *comment.ReplyTo
134
-
allThreads := issue.CommentList()
135
136
// find the parent thread, and add all DIDs from here to the recipient list
137
-
for _, t := range allThreads {
138
if t.Self.AtUri().String() == parentAtUri {
139
-
recipients = append(recipients, t.Participants()...)
140
}
141
}
142
} else {
143
// not a reply, notify just the issue author
144
-
recipients = append(recipients, syntax.DID(issue.Did))
145
}
146
147
actorDid := syntax.DID(comment.Did)
···
163
)
164
n.notifyEvent(
165
actorDid,
166
-
mentions,
167
models.NotificationTypeUserMentioned,
168
entityType,
169
entityId,
···
179
180
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
181
actorDid := syntax.DID(follow.UserDid)
182
-
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
183
eventType := models.NotificationTypeFollowed
184
entityType := "follow"
185
entityId := follow.UserDid
···
202
}
203
204
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
205
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
206
if err != nil {
207
log.Printf("NewPull: failed to get repos: %v", err)
208
return
209
}
210
-
211
-
// build the recipients list
212
-
// - owner of the repo
213
-
// - collaborators in the repo
214
-
var recipients []syntax.DID
215
-
recipients = append(recipients, syntax.DID(repo.Did))
216
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
217
if err != nil {
218
log.Printf("failed to fetch collaborators: %v", err)
219
return
220
}
221
for _, c := range collaborators {
222
-
recipients = append(recipients, c.SubjectDid)
223
}
224
225
actorDid := syntax.DID(pull.OwnerDid)
···
253
return
254
}
255
256
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
257
if err != nil {
258
log.Printf("NewPullComment: failed to get repos: %v", err)
259
return
···
262
// build up the recipients list:
263
// - repo owner
264
// - all pull participants
265
-
var recipients []syntax.DID
266
-
recipients = append(recipients, syntax.DID(repo.Did))
267
for _, p := range pull.Participants() {
268
-
recipients = append(recipients, syntax.DID(p))
269
}
270
271
actorDid := syntax.DID(comment.OwnerDid)
···
289
)
290
n.notifyEvent(
291
actorDid,
292
-
mentions,
293
models.NotificationTypeUserMentioned,
294
entityType,
295
entityId,
···
316
}
317
318
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
319
-
// build up the recipients list:
320
-
// - repo owner
321
-
// - repo collaborators
322
-
// - all issue participants
323
-
var recipients []syntax.DID
324
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
325
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
326
if err != nil {
327
log.Printf("failed to fetch collaborators: %v", err)
328
return
329
}
330
for _, c := range collaborators {
331
-
recipients = append(recipients, c.SubjectDid)
332
}
333
for _, p := range issue.Participants() {
334
-
recipients = append(recipients, syntax.DID(p))
335
}
336
337
entityType := "pull"
···
361
362
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
363
// Get repo details
364
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
365
if err != nil {
366
log.Printf("NewPullState: failed to get repos: %v", err)
367
return
368
}
369
370
-
// build up the recipients list:
371
-
// - repo owner
372
-
// - all pull participants
373
-
var recipients []syntax.DID
374
-
recipients = append(recipients, syntax.DID(repo.Did))
375
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
376
if err != nil {
377
log.Printf("failed to fetch collaborators: %v", err)
378
return
379
}
380
for _, c := range collaborators {
381
-
recipients = append(recipients, c.SubjectDid)
382
}
383
for _, p := range pull.Participants() {
384
-
recipients = append(recipients, syntax.DID(p))
385
}
386
387
entityType := "pull"
···
417
418
func (n *databaseNotifier) notifyEvent(
419
actorDid syntax.DID,
420
-
recipients []syntax.DID,
421
eventType models.NotificationType,
422
entityType string,
423
entityId string,
···
425
issueId *int64,
426
pullId *int64,
427
) {
428
-
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
429
-
recipients = recipients[:maxMentions]
430
-
}
431
-
recipientSet := make(map[syntax.DID]struct{})
432
-
for _, did := range recipients {
433
-
// everybody except actor themselves
434
-
if did != actorDid {
435
-
recipientSet[did] = struct{}{}
436
-
}
437
}
438
439
prefMap, err := db.GetNotificationPreferences(
440
n.db,
441
-
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
442
)
443
if err != nil {
444
// failed to get prefs for users
···
454
defer tx.Rollback()
455
456
// filter based on preferences
457
-
for recipientDid := range recipientSet {
458
prefs, ok := prefMap[recipientDid]
459
if !ok {
460
prefs = models.DefaultNotificationPreferences(recipientDid)
···
3
import (
4
"context"
5
"log"
6
"slices"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
"tangled.org/core/appview/db"
11
"tangled.org/core/appview/models"
12
"tangled.org/core/appview/notify"
13
"tangled.org/core/idresolver"
14
+
"tangled.org/core/orm"
15
+
"tangled.org/core/sets"
16
)
17
18
const (
19
+
maxMentions = 8
20
)
21
22
type databaseNotifier struct {
···
38
}
39
40
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
41
+
if star.RepoAt.Collection().String() != tangled.RepoNSID {
42
+
// skip string stars for now
43
+
return
44
+
}
45
var err error
46
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt)))
47
if err != nil {
48
log.Printf("NewStar: failed to get repos: %v", err)
49
return
50
}
51
52
+
actorDid := syntax.DID(star.Did)
53
+
recipients := sets.Singleton(syntax.DID(repo.Did))
54
eventType := models.NotificationTypeRepoStarred
55
entityType := "repo"
56
entityId := star.RepoAt.String()
···
75
}
76
77
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
78
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
79
if err != nil {
80
log.Printf("failed to fetch collaborators: %v", err)
81
return
82
}
83
+
84
+
// build the recipients list
85
+
// - owner of the repo
86
+
// - collaborators in the repo
87
+
// - remove users already mentioned
88
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
89
for _, c := range collaborators {
90
+
recipients.Insert(c.SubjectDid)
91
+
}
92
+
for _, m := range mentions {
93
+
recipients.Remove(m)
94
}
95
96
actorDid := syntax.DID(issue.Did)
···
112
)
113
n.notifyEvent(
114
actorDid,
115
+
sets.Collect(slices.Values(mentions)),
116
models.NotificationTypeUserMentioned,
117
entityType,
118
entityId,
···
123
}
124
125
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
126
+
issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt))
127
if err != nil {
128
log.Printf("NewIssueComment: failed to get issues: %v", err)
129
return
···
134
}
135
issue := issues[0]
136
137
+
// built the recipients list:
138
+
// - the owner of the repo
139
+
// - | if the comment is a reply -> everybody on that thread
140
+
// | if the comment is a top level -> just the issue owner
141
+
// - remove mentioned users from the recipients list
142
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
143
144
if comment.IsReply() {
145
// if this comment is a reply, then notify everybody in that thread
146
parentAtUri := *comment.ReplyTo
147
148
// find the parent thread, and add all DIDs from here to the recipient list
149
+
for _, t := range issue.CommentList() {
150
if t.Self.AtUri().String() == parentAtUri {
151
+
for _, p := range t.Participants() {
152
+
recipients.Insert(p)
153
+
}
154
}
155
}
156
} else {
157
// not a reply, notify just the issue author
158
+
recipients.Insert(syntax.DID(issue.Did))
159
+
}
160
+
161
+
for _, m := range mentions {
162
+
recipients.Remove(m)
163
}
164
165
actorDid := syntax.DID(comment.Did)
···
181
)
182
n.notifyEvent(
183
actorDid,
184
+
sets.Collect(slices.Values(mentions)),
185
models.NotificationTypeUserMentioned,
186
entityType,
187
entityId,
···
197
198
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
199
actorDid := syntax.DID(follow.UserDid)
200
+
recipients := sets.Singleton(syntax.DID(follow.SubjectDid))
201
eventType := models.NotificationTypeFollowed
202
entityType := "follow"
203
entityId := follow.UserDid
···
220
}
221
222
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
223
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt)))
224
if err != nil {
225
log.Printf("NewPull: failed to get repos: %v", err)
226
return
227
}
228
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt()))
229
if err != nil {
230
log.Printf("failed to fetch collaborators: %v", err)
231
return
232
}
233
+
234
+
// build the recipients list
235
+
// - owner of the repo
236
+
// - collaborators in the repo
237
+
recipients := sets.Singleton(syntax.DID(repo.Did))
238
for _, c := range collaborators {
239
+
recipients.Insert(c.SubjectDid)
240
}
241
242
actorDid := syntax.DID(pull.OwnerDid)
···
270
return
271
}
272
273
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt))
274
if err != nil {
275
log.Printf("NewPullComment: failed to get repos: %v", err)
276
return
···
279
// build up the recipients list:
280
// - repo owner
281
// - all pull participants
282
+
// - remove those already mentioned
283
+
recipients := sets.Singleton(syntax.DID(repo.Did))
284
for _, p := range pull.Participants() {
285
+
recipients.Insert(syntax.DID(p))
286
+
}
287
+
for _, m := range mentions {
288
+
recipients.Remove(m)
289
}
290
291
actorDid := syntax.DID(comment.OwnerDid)
···
309
)
310
n.notifyEvent(
311
actorDid,
312
+
sets.Collect(slices.Values(mentions)),
313
models.NotificationTypeUserMentioned,
314
entityType,
315
entityId,
···
336
}
337
338
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
339
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
340
if err != nil {
341
log.Printf("failed to fetch collaborators: %v", err)
342
return
343
}
344
+
345
+
// build up the recipients list:
346
+
// - repo owner
347
+
// - repo collaborators
348
+
// - all issue participants
349
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
350
for _, c := range collaborators {
351
+
recipients.Insert(c.SubjectDid)
352
}
353
for _, p := range issue.Participants() {
354
+
recipients.Insert(syntax.DID(p))
355
}
356
357
entityType := "pull"
···
381
382
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
383
// Get repo details
384
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt)))
385
if err != nil {
386
log.Printf("NewPullState: failed to get repos: %v", err)
387
return
388
}
389
390
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt()))
391
if err != nil {
392
log.Printf("failed to fetch collaborators: %v", err)
393
return
394
}
395
+
396
+
// build up the recipients list:
397
+
// - repo owner
398
+
// - all pull participants
399
+
recipients := sets.Singleton(syntax.DID(repo.Did))
400
for _, c := range collaborators {
401
+
recipients.Insert(c.SubjectDid)
402
}
403
for _, p := range pull.Participants() {
404
+
recipients.Insert(syntax.DID(p))
405
}
406
407
entityType := "pull"
···
437
438
func (n *databaseNotifier) notifyEvent(
439
actorDid syntax.DID,
440
+
recipients sets.Set[syntax.DID],
441
eventType models.NotificationType,
442
entityType string,
443
entityId string,
···
445
issueId *int64,
446
pullId *int64,
447
) {
448
+
// if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody
449
+
if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions {
450
+
return
451
}
452
453
+
recipients.Remove(actorDid)
454
+
455
prefMap, err := db.GetNotificationPreferences(
456
n.db,
457
+
orm.FilterIn("user_did", slices.Collect(recipients.All())),
458
)
459
if err != nil {
460
// failed to get prefs for users
···
470
defer tx.Rollback()
471
472
// filter based on preferences
473
+
for recipientDid := range recipients.All() {
474
prefs, ok := prefMap[recipientDid]
475
if !ok {
476
prefs = models.DefaultNotificationPreferences(recipientDid)
-1
appview/notify/merged_notifier.go
-1
appview/notify/merged_notifier.go
+2
-2
appview/notify/posthog/notifier.go
+2
-2
appview/notify/posthog/notifier.go
···
37
38
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
39
err := n.client.Enqueue(posthog.Capture{
40
-
DistinctId: star.StarredByDid,
41
Event: "star",
42
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
43
})
···
48
49
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
50
err := n.client.Enqueue(posthog.Capture{
51
-
DistinctId: star.StarredByDid,
52
Event: "unstar",
53
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
54
})
···
37
38
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
39
err := n.client.Enqueue(posthog.Capture{
40
+
DistinctId: star.Did,
41
Event: "star",
42
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
43
})
···
48
49
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
50
err := n.client.Enqueue(posthog.Capture{
51
+
DistinctId: star.Did,
52
Event: "unstar",
53
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
54
})
+3
-2
appview/oauth/handler.go
+3
-2
appview/oauth/handler.go
···
16
"tangled.org/core/api/tangled"
17
"tangled.org/core/appview/db"
18
"tangled.org/core/consts"
19
"tangled.org/core/tid"
20
)
21
···
97
// and create an sh.tangled.spindle.member record with that
98
spindleMembers, err := db.GetSpindleMembers(
99
o.Db,
100
-
db.FilterEq("instance", "spindle.tangled.sh"),
101
-
db.FilterEq("subject", did),
102
)
103
if err != nil {
104
l.Error("failed to get spindle members", "err", err)
···
16
"tangled.org/core/api/tangled"
17
"tangled.org/core/appview/db"
18
"tangled.org/core/consts"
19
+
"tangled.org/core/orm"
20
"tangled.org/core/tid"
21
)
22
···
98
// and create an sh.tangled.spindle.member record with that
99
spindleMembers, err := db.GetSpindleMembers(
100
o.Db,
101
+
orm.FilterEq("instance", "spindle.tangled.sh"),
102
+
orm.FilterEq("subject", did),
103
)
104
if err != nil {
105
l.Error("failed to get spindle members", "err", err)
+15
-2
appview/oauth/oauth.go
+15
-2
appview/oauth/oauth.go
···
202
exp int64
203
lxm string
204
dev bool
205
}
206
207
type ServiceClientOpt func(*ServiceClientOpts)
208
209
func WithService(service string) ServiceClientOpt {
210
return func(s *ServiceClientOpts) {
···
233
}
234
}
235
236
func (s *ServiceClientOpts) Audience() string {
237
return fmt.Sprintf("did:web:%s", s.service)
238
}
···
247
}
248
249
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
250
-
opts := ServiceClientOpts{}
251
for _, o := range os {
252
o(&opts)
253
}
···
274
},
275
Host: opts.Host(),
276
Client: &http.Client{
277
-
Timeout: time.Second * 5,
278
},
279
}, nil
280
}
···
202
exp int64
203
lxm string
204
dev bool
205
+
timeout time.Duration
206
}
207
208
type ServiceClientOpt func(*ServiceClientOpts)
209
+
210
+
func DefaultServiceClientOpts() ServiceClientOpts {
211
+
return ServiceClientOpts{
212
+
timeout: time.Second * 5,
213
+
}
214
+
}
215
216
func WithService(service string) ServiceClientOpt {
217
return func(s *ServiceClientOpts) {
···
240
}
241
}
242
243
+
func WithTimeout(timeout time.Duration) ServiceClientOpt {
244
+
return func(s *ServiceClientOpts) {
245
+
s.timeout = timeout
246
+
}
247
+
}
248
+
249
func (s *ServiceClientOpts) Audience() string {
250
return fmt.Sprintf("did:web:%s", s.service)
251
}
···
260
}
261
262
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
263
+
opts := DefaultServiceClientOpts()
264
for _, o := range os {
265
o(&opts)
266
}
···
287
},
288
Host: opts.Host(),
289
Client: &http.Client{
290
+
Timeout: opts.timeout,
291
},
292
}, nil
293
}
+9
-9
appview/ogcard/card.go
+9
-9
appview/ogcard/card.go
···
334
return nil
335
}
336
337
-
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
tpl, err := template.New("dolly").
339
-
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
if err != nil {
341
-
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
}
343
344
var svgData bytes.Buffer
345
-
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
-
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
}
348
349
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
···
453
454
// Handle SVG separately
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
-
return c.convertSVGToPNG(bodyBytes)
457
}
458
459
// Support content types are in-sync with the allowed custom avatar file types
···
493
}
494
495
// convertSVGToPNG converts SVG data to a PNG image
496
-
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
// Parse the SVG
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
if err != nil {
···
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
549
// Draw the image with circular clipping
550
-
for cy := 0; cy < size; cy++ {
551
-
for cx := 0; cx < size; cx++ {
552
// Calculate distance from center
553
dx := float64(cx - center)
554
dy := float64(cy - center)
···
334
return nil
335
}
336
337
+
func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error {
338
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
340
if err != nil {
341
+
return fmt.Errorf("failed to read dolly template: %w", err)
342
}
343
344
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly template: %w", err)
347
}
348
349
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
···
453
454
// Handle SVG separately
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
+
return convertSVGToPNG(bodyBytes)
457
}
458
459
// Support content types are in-sync with the allowed custom avatar file types
···
493
}
494
495
// convertSVGToPNG converts SVG data to a PNG image
496
+
func convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
// Parse the SVG
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
if err != nil {
···
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
549
// Draw the image with circular clipping
550
+
for cy := range size {
551
+
for cx := range size {
552
// Calculate distance from center
553
dx := float64(cx - center)
554
dy := float64(cy - center)
+44
-10
appview/pages/funcmap.go
+44
-10
appview/pages/funcmap.go
···
22
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23
"github.com/alecthomas/chroma/v2/lexers"
24
"github.com/alecthomas/chroma/v2/styles"
25
-
"github.com/bluesky-social/indigo/atproto/syntax"
26
"github.com/dustin/go-humanize"
27
"github.com/go-enry/go-enry/v2"
28
"github.com/yuin/goldmark"
29
"tangled.org/core/appview/filetree"
30
"tangled.org/core/appview/pages/markup"
31
"tangled.org/core/crypto"
32
)
···
71
}
72
73
return identity.Handle.String()
74
},
75
"truncateAt30": func(s string) string {
76
if len(s) <= 30 {
···
100
"sub": func(a, b int) int {
101
return a - b
102
},
103
"f64": func(a int) float64 {
104
return float64(a)
105
},
···
132
133
return b
134
},
135
-
"didOrHandle": func(did, handle string) string {
136
-
if handle != "" && handle != syntax.HandleInvalid.String() {
137
-
return handle
138
-
} else {
139
-
return did
140
-
}
141
-
},
142
"assoc": func(values ...string) ([][]string, error) {
143
if len(values)%2 != 0 {
144
return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
···
149
}
150
return pairs, nil
151
},
152
-
"append": func(s []string, values ...string) []string {
153
s = append(s, values...)
154
return s
155
},
···
248
},
249
"description": func(text string) template.HTML {
250
p.rctx.RendererType = markup.RendererTypeDefault
251
-
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
252
sanitized := p.rctx.SanitizeDescription(htmlString)
253
return template.HTML(sanitized)
254
},
···
370
}
371
}
372
373
func (p *Pages) AvatarUrl(handle, size string) string {
374
handle = strings.TrimPrefix(handle, "@")
375
376
secret := p.avatar.SharedSecret
377
h := hmac.New(sha256.New, []byte(secret))
···
22
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23
"github.com/alecthomas/chroma/v2/lexers"
24
"github.com/alecthomas/chroma/v2/styles"
25
"github.com/dustin/go-humanize"
26
"github.com/go-enry/go-enry/v2"
27
"github.com/yuin/goldmark"
28
+
emoji "github.com/yuin/goldmark-emoji"
29
"tangled.org/core/appview/filetree"
30
+
"tangled.org/core/appview/models"
31
"tangled.org/core/appview/pages/markup"
32
"tangled.org/core/crypto"
33
)
···
72
}
73
74
return identity.Handle.String()
75
+
},
76
+
"ownerSlashRepo": func(repo *models.Repo) string {
77
+
ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did)
78
+
if err != nil {
79
+
return repo.DidSlashRepo()
80
+
}
81
+
handle := ownerId.Handle
82
+
if handle != "" && !handle.IsInvalidHandle() {
83
+
return string(handle) + "/" + repo.Name
84
+
}
85
+
return repo.DidSlashRepo()
86
},
87
"truncateAt30": func(s string) string {
88
if len(s) <= 30 {
···
112
"sub": func(a, b int) int {
113
return a - b
114
},
115
+
"mul": func(a, b int) int {
116
+
return a * b
117
+
},
118
+
"div": func(a, b int) int {
119
+
return a / b
120
+
},
121
+
"mod": func(a, b int) int {
122
+
return a % b
123
+
},
124
"f64": func(a int) float64 {
125
return float64(a)
126
},
···
153
154
return b
155
},
156
"assoc": func(values ...string) ([][]string, error) {
157
if len(values)%2 != 0 {
158
return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
···
163
}
164
return pairs, nil
165
},
166
+
"append": func(s []any, values ...any) []any {
167
s = append(s, values...)
168
return s
169
},
···
262
},
263
"description": func(text string) template.HTML {
264
p.rctx.RendererType = markup.RendererTypeDefault
265
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New(
266
+
goldmark.WithExtensions(
267
+
emoji.Emoji,
268
+
),
269
+
))
270
sanitized := p.rctx.SanitizeDescription(htmlString)
271
return template.HTML(sanitized)
272
},
···
388
}
389
}
390
391
+
func (p *Pages) resolveDid(did string) string {
392
+
identity, err := p.resolver.ResolveIdent(context.Background(), did)
393
+
394
+
if err != nil {
395
+
return did
396
+
}
397
+
398
+
if identity.Handle.IsInvalidHandle() {
399
+
return "handle.invalid"
400
+
}
401
+
402
+
return identity.Handle.String()
403
+
}
404
+
405
func (p *Pages) AvatarUrl(handle, size string) string {
406
handle = strings.TrimPrefix(handle, "@")
407
+
408
+
handle = p.resolveDid(handle)
409
410
secret := p.avatar.SharedSecret
411
h := hmac.New(sha256.New, []byte(secret))
+12
-2
appview/pages/markup/extension/atlink.go
+12
-2
appview/pages/markup/extension/atlink.go
···
35
return KindAt
36
}
37
38
-
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
40
type atParser struct{}
41
···
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{}
···
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">`)
93
} else {
···
35
return KindAt
36
}
37
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`)
39
+
var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`)
40
41
type atParser struct{}
42
···
56
if m == nil {
57
return nil
58
}
59
+
60
+
// Check for all links in the markdown to see if the handle found is inside one
61
+
linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1)
62
+
for _, linkMatch := range linksIndexes {
63
+
if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] {
64
+
return nil
65
+
}
66
+
}
67
+
68
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
69
block.Advance(m[1])
70
node := &AtNode{}
···
97
98
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
99
if entering {
100
+
w.WriteString(`<a href="/`)
101
w.WriteString(n.(*AtNode).Handle)
102
w.WriteString(`" class="mention">`)
103
} else {
+2
-26
appview/pages/markup/markdown.go
+2
-26
appview/pages/markup/markdown.go
···
12
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
"github.com/alecthomas/chroma/v2/styles"
15
-
treeblood "github.com/wyatt915/goldmark-treeblood"
16
"github.com/yuin/goldmark"
17
highlighting "github.com/yuin/goldmark-highlighting/v2"
18
"github.com/yuin/goldmark/ast"
19
"github.com/yuin/goldmark/extension"
···
65
extension.NewFootnote(
66
extension.WithFootnoteIDPrefix([]byte("footnote")),
67
),
68
-
treeblood.MathML(),
69
callout.CalloutExtention,
70
textension.AtExt,
71
),
72
goldmark.WithParserOptions(
73
parser.WithAutoHeadingID(),
···
302
}
303
304
return path.Join(rctx.CurrentDir, dst)
305
-
}
306
-
307
-
// FindUserMentions returns Set of user handles from given markup soruce.
308
-
// It doesn't guarntee unique DIDs
309
-
func FindUserMentions(source string) []string {
310
-
var (
311
-
mentions []string
312
-
mentionsSet = make(map[string]struct{})
313
-
md = NewMarkdown()
314
-
sourceBytes = []byte(source)
315
-
root = md.Parser().Parse(text.NewReader(sourceBytes))
316
-
)
317
-
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
318
-
if entering && n.Kind() == textension.KindAt {
319
-
handle := n.(*textension.AtNode).Handle
320
-
mentionsSet[handle] = struct{}{}
321
-
return ast.WalkSkipChildren, nil
322
-
}
323
-
return ast.WalkContinue, nil
324
-
})
325
-
for handle := range mentionsSet {
326
-
mentions = append(mentions, handle)
327
-
}
328
-
return mentions
329
}
330
331
func isAbsoluteUrl(link string) bool {
···
12
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
"github.com/alecthomas/chroma/v2/styles"
15
"github.com/yuin/goldmark"
16
+
"github.com/yuin/goldmark-emoji"
17
highlighting "github.com/yuin/goldmark-highlighting/v2"
18
"github.com/yuin/goldmark/ast"
19
"github.com/yuin/goldmark/extension"
···
65
extension.NewFootnote(
66
extension.WithFootnoteIDPrefix([]byte("footnote")),
67
),
68
callout.CalloutExtention,
69
textension.AtExt,
70
+
emoji.Emoji,
71
),
72
goldmark.WithParserOptions(
73
parser.WithAutoHeadingID(),
···
302
}
303
304
return path.Join(rctx.CurrentDir, dst)
305
}
306
307
func isAbsoluteUrl(link string) bool {
+121
appview/pages/markup/markdown_test.go
+121
appview/pages/markup/markdown_test.go
···
···
1
+
package markup
2
+
3
+
import (
4
+
"bytes"
5
+
"testing"
6
+
)
7
+
8
+
func TestAtExtension_Rendering(t *testing.T) {
9
+
tests := []struct {
10
+
name string
11
+
markdown string
12
+
expected string
13
+
}{
14
+
{
15
+
name: "renders simple at mention",
16
+
markdown: "Hello @user.tngl.sh!",
17
+
expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`,
18
+
},
19
+
{
20
+
name: "renders multiple at mentions",
21
+
markdown: "Hi @alice.tngl.sh and @bob.example.com",
22
+
expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`,
23
+
},
24
+
{
25
+
name: "renders at mention in parentheses",
26
+
markdown: "Check this out (@user.tngl.sh)",
27
+
expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`,
28
+
},
29
+
{
30
+
name: "does not render email",
31
+
markdown: "Contact me at test@example.com",
32
+
expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`,
33
+
},
34
+
{
35
+
name: "renders at mention with hyphen",
36
+
markdown: "Follow @user-name.tngl.sh",
37
+
expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`,
38
+
},
39
+
{
40
+
name: "renders at mention with numbers",
41
+
markdown: "@user123.test456.social",
42
+
expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`,
43
+
},
44
+
{
45
+
name: "at mention at start of line",
46
+
markdown: "@user.tngl.sh is cool",
47
+
expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`,
48
+
},
49
+
}
50
+
51
+
for _, tt := range tests {
52
+
t.Run(tt.name, func(t *testing.T) {
53
+
md := NewMarkdown()
54
+
55
+
var buf bytes.Buffer
56
+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
57
+
t.Fatalf("failed to convert markdown: %v", err)
58
+
}
59
+
60
+
result := buf.String()
61
+
if result != tt.expected+"\n" {
62
+
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result)
63
+
}
64
+
})
65
+
}
66
+
}
67
+
68
+
func TestAtExtension_WithOtherMarkdown(t *testing.T) {
69
+
tests := []struct {
70
+
name string
71
+
markdown string
72
+
contains string
73
+
}{
74
+
{
75
+
name: "at mention with bold",
76
+
markdown: "**Hello @user.tngl.sh**",
77
+
contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`,
78
+
},
79
+
{
80
+
name: "at mention with italic",
81
+
markdown: "*Check @user.tngl.sh*",
82
+
contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`,
83
+
},
84
+
{
85
+
name: "at mention in list",
86
+
markdown: "- Item 1\n- @user.tngl.sh\n- Item 3",
87
+
contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`,
88
+
},
89
+
{
90
+
name: "at mention in link",
91
+
markdown: "[@regnault.dev](https://regnault.dev)",
92
+
contains: `<a href="https://regnault.dev">@regnault.dev</a>`,
93
+
},
94
+
{
95
+
name: "at mention in link again",
96
+
markdown: "[check out @regnault.dev](https://regnault.dev)",
97
+
contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`,
98
+
},
99
+
{
100
+
name: "at mention in link again, multiline",
101
+
markdown: "[\ncheck out @regnault.dev](https://regnault.dev)",
102
+
contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>",
103
+
},
104
+
}
105
+
106
+
for _, tt := range tests {
107
+
t.Run(tt.name, func(t *testing.T) {
108
+
md := NewMarkdown()
109
+
110
+
var buf bytes.Buffer
111
+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
112
+
t.Fatalf("failed to convert markdown: %v", err)
113
+
}
114
+
115
+
result := buf.String()
116
+
if !bytes.Contains([]byte(result), []byte(tt.contains)) {
117
+
t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result)
118
+
}
119
+
})
120
+
}
121
+
}
+124
appview/pages/markup/reference_link.go
+124
appview/pages/markup/reference_link.go
···
···
1
+
package markup
2
+
3
+
import (
4
+
"maps"
5
+
"net/url"
6
+
"path"
7
+
"slices"
8
+
"strconv"
9
+
"strings"
10
+
11
+
"github.com/yuin/goldmark/ast"
12
+
"github.com/yuin/goldmark/text"
13
+
"tangled.org/core/appview/models"
14
+
textension "tangled.org/core/appview/pages/markup/extension"
15
+
)
16
+
17
+
// FindReferences collects all links referencing tangled-related objects
18
+
// like issues, PRs, comments or even @-mentions
19
+
// This funciton doesn't actually check for the existence of records in the DB
20
+
// or the PDS; it merely returns a list of what are presumed to be references.
21
+
func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) {
22
+
var (
23
+
refLinkSet = make(map[models.ReferenceLink]struct{})
24
+
mentionsSet = make(map[string]struct{})
25
+
md = NewMarkdown()
26
+
sourceBytes = []byte(source)
27
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
28
+
)
29
+
// trim url scheme. the SSL shouldn't matter
30
+
baseUrl = strings.TrimPrefix(baseUrl, "https://")
31
+
baseUrl = strings.TrimPrefix(baseUrl, "http://")
32
+
33
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
34
+
if !entering {
35
+
return ast.WalkContinue, nil
36
+
}
37
+
switch n.Kind() {
38
+
case textension.KindAt:
39
+
handle := n.(*textension.AtNode).Handle
40
+
mentionsSet[handle] = struct{}{}
41
+
return ast.WalkSkipChildren, nil
42
+
case ast.KindLink:
43
+
dest := string(n.(*ast.Link).Destination)
44
+
ref := parseTangledLink(baseUrl, dest)
45
+
if ref != nil {
46
+
refLinkSet[*ref] = struct{}{}
47
+
}
48
+
return ast.WalkSkipChildren, nil
49
+
case ast.KindAutoLink:
50
+
an := n.(*ast.AutoLink)
51
+
if an.AutoLinkType == ast.AutoLinkURL {
52
+
dest := string(an.URL(sourceBytes))
53
+
ref := parseTangledLink(baseUrl, dest)
54
+
if ref != nil {
55
+
refLinkSet[*ref] = struct{}{}
56
+
}
57
+
}
58
+
return ast.WalkSkipChildren, nil
59
+
}
60
+
return ast.WalkContinue, nil
61
+
})
62
+
mentions := slices.Collect(maps.Keys(mentionsSet))
63
+
references := slices.Collect(maps.Keys(refLinkSet))
64
+
return mentions, references
65
+
}
66
+
67
+
func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink {
68
+
u, err := url.Parse(urlStr)
69
+
if err != nil {
70
+
return nil
71
+
}
72
+
73
+
if u.Host != "" && !strings.EqualFold(u.Host, baseHost) {
74
+
return nil
75
+
}
76
+
77
+
p := path.Clean(u.Path)
78
+
parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' })
79
+
if len(parts) < 4 {
80
+
// need at least: handle / repo / kind / id
81
+
return nil
82
+
}
83
+
84
+
var (
85
+
handle = parts[0]
86
+
repo = parts[1]
87
+
kindSeg = parts[2]
88
+
subjectSeg = parts[3]
89
+
)
90
+
91
+
handle = strings.TrimPrefix(handle, "@")
92
+
93
+
var kind models.RefKind
94
+
switch kindSeg {
95
+
case "issues":
96
+
kind = models.RefKindIssue
97
+
case "pulls":
98
+
kind = models.RefKindPull
99
+
default:
100
+
return nil
101
+
}
102
+
103
+
subjectId, err := strconv.Atoi(subjectSeg)
104
+
if err != nil {
105
+
return nil
106
+
}
107
+
var commentId *int
108
+
if u.Fragment != "" {
109
+
if strings.HasPrefix(u.Fragment, "comment-") {
110
+
commentIdStr := u.Fragment[len("comment-"):]
111
+
if id, err := strconv.Atoi(commentIdStr); err == nil {
112
+
commentId = &id
113
+
}
114
+
}
115
+
}
116
+
117
+
return &models.ReferenceLink{
118
+
Handle: handle,
119
+
Repo: repo,
120
+
Kind: kind,
121
+
SubjectId: subjectId,
122
+
CommentId: commentId,
123
+
}
124
+
}
+42
appview/pages/markup/reference_link_test.go
+42
appview/pages/markup/reference_link_test.go
···
···
1
+
package markup_test
2
+
3
+
import (
4
+
"testing"
5
+
6
+
"github.com/stretchr/testify/assert"
7
+
"tangled.org/core/appview/models"
8
+
"tangled.org/core/appview/pages/markup"
9
+
)
10
+
11
+
func TestMarkupParsing(t *testing.T) {
12
+
tests := []struct {
13
+
name string
14
+
source string
15
+
wantHandles []string
16
+
wantRefLinks []models.ReferenceLink
17
+
}{
18
+
{
19
+
name: "normal link",
20
+
source: `[link](http://127.0.0.1:3000/alice.pds.tngl.boltless.dev/coolproj/issues/1)`,
21
+
wantHandles: make([]string, 0),
22
+
wantRefLinks: []models.ReferenceLink{
23
+
{Handle: "alice.pds.tngl.boltless.dev", Repo: "coolproj", Kind: models.RefKindIssue, SubjectId: 1, CommentId: nil},
24
+
},
25
+
},
26
+
{
27
+
name: "commonmark style autolink",
28
+
source: `<http://127.0.0.1:3000/alice.pds.tngl.boltless.dev/coolproj/issues/1>`,
29
+
wantHandles: make([]string, 0),
30
+
wantRefLinks: []models.ReferenceLink{
31
+
{Handle: "alice.pds.tngl.boltless.dev", Repo: "coolproj", Kind: models.RefKindIssue, SubjectId: 1, CommentId: nil},
32
+
},
33
+
},
34
+
}
35
+
for _, tt := range tests {
36
+
t.Run(tt.name, func(t *testing.T) {
37
+
handles, refLinks := markup.FindReferences("http://127.0.0.1:3000", tt.source)
38
+
assert.ElementsMatch(t, tt.wantHandles, handles)
39
+
assert.ElementsMatch(t, tt.wantRefLinks, refLinks)
40
+
})
41
+
}
42
+
}
+41
-19
appview/pages/pages.go
+41
-19
appview/pages/pages.go
···
31
"github.com/bluesky-social/indigo/atproto/identity"
32
"github.com/bluesky-social/indigo/atproto/syntax"
33
"github.com/go-git/go-git/v5/plumbing"
34
-
"github.com/go-git/go-git/v5/plumbing/object"
35
)
36
37
//go:embed templates/* static legal
···
211
return tpl.ExecuteTemplate(w, "layouts/base", params)
212
}
213
214
func (p *Pages) Favicon(w io.Writer) error {
215
-
return p.executePlain("fragments/dolly/silhouette", w, nil)
216
}
217
218
type LoginParams struct {
···
407
type KnotsParams struct {
408
LoggedInUser *oauth.User
409
Registrations []models.Registration
410
}
411
412
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
419
Members []string
420
Repos map[string][]models.Repo
421
IsOwner bool
422
}
423
424
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
···
436
type SpindlesParams struct {
437
LoggedInUser *oauth.User
438
Spindles []models.Spindle
439
}
440
441
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
444
445
type SpindleListingParams struct {
446
models.Spindle
447
}
448
449
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
455
Spindle models.Spindle
456
Members []string
457
Repos map[string][]models.Repo
458
}
459
460
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
482
483
type ProfileCard struct {
484
UserDid string
485
-
UserHandle string
486
FollowStatus models.FollowStatus
487
Punchcard *models.Punchcard
488
Profile *models.Profile
···
625
return p.executePlain("user/fragments/editPins", w, params)
626
}
627
628
-
type RepoStarFragmentParams struct {
629
IsStarred bool
630
-
RepoAt syntax.ATURI
631
-
Stats models.RepoStats
632
}
633
634
-
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
635
-
return p.executePlain("repo/fragments/repoStar", w, params)
636
}
637
638
type RepoIndexParams struct {
···
640
RepoInfo repoinfo.RepoInfo
641
Active string
642
TagMap map[string][]string
643
-
CommitsTrunc []*object.Commit
644
TagsTrunc []*types.TagReference
645
BranchesTrunc []types.Branch
646
// ForkInfo *types.ForkInfo
···
831
}
832
833
type Collaborator struct {
834
-
Did string
835
-
Handle string
836
-
Role string
837
}
838
839
type RepoSettingsParams struct {
···
908
RepoInfo repoinfo.RepoInfo
909
Active string
910
Issues []models.Issue
911
LabelDefs map[string]*models.LabelDefinition
912
Page pagination.Page
913
FilteringByOpen bool
···
925
Active string
926
Issue *models.Issue
927
CommentList []models.CommentListItem
928
LabelDefs map[string]*models.LabelDefinition
929
930
OrderedReactionKinds []models.ReactionKind
···
1078
Pull *models.Pull
1079
Stack models.Stack
1080
AbandonedPulls []*models.Pull
1081
BranchDeleteStatus *models.BranchDeleteStatus
1082
MergeCheck types.MergeCheckResponse
1083
ResubmitCheck ResubmitResult
···
1249
return p.executePlain("repo/fragments/compareAllowPull", w, params)
1250
}
1251
1252
-
type RepoCompareDiffParams struct {
1253
-
LoggedInUser *oauth.User
1254
-
RepoInfo repoinfo.RepoInfo
1255
-
Diff types.NiceDiff
1256
}
1257
1258
-
func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error {
1259
-
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1260
}
1261
1262
type LabelPanelParams struct {
···
1376
ShowRendered bool
1377
RenderToggle bool
1378
RenderedContents template.HTML
1379
-
String models.String
1380
Stats models.StringStats
1381
Owner identity.Identity
1382
}
1383
···
31
"github.com/bluesky-social/indigo/atproto/identity"
32
"github.com/bluesky-social/indigo/atproto/syntax"
33
"github.com/go-git/go-git/v5/plumbing"
34
)
35
36
//go:embed templates/* static legal
···
210
return tpl.ExecuteTemplate(w, "layouts/base", params)
211
}
212
213
+
type DollyParams struct {
214
+
Classes string
215
+
FillColor string
216
+
}
217
+
218
+
func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
219
+
return p.executePlain("fragments/dolly/logo", w, params)
220
+
}
221
+
222
func (p *Pages) Favicon(w io.Writer) error {
223
+
return p.Dolly(w, DollyParams{
224
+
Classes: "text-black dark:text-white",
225
+
})
226
}
227
228
type LoginParams struct {
···
417
type KnotsParams struct {
418
LoggedInUser *oauth.User
419
Registrations []models.Registration
420
+
Tabs []map[string]any
421
+
Tab string
422
}
423
424
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
431
Members []string
432
Repos map[string][]models.Repo
433
IsOwner bool
434
+
Tabs []map[string]any
435
+
Tab string
436
}
437
438
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
···
450
type SpindlesParams struct {
451
LoggedInUser *oauth.User
452
Spindles []models.Spindle
453
+
Tabs []map[string]any
454
+
Tab string
455
}
456
457
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
460
461
type SpindleListingParams struct {
462
models.Spindle
463
+
Tabs []map[string]any
464
+
Tab string
465
}
466
467
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
473
Spindle models.Spindle
474
Members []string
475
Repos map[string][]models.Repo
476
+
Tabs []map[string]any
477
+
Tab string
478
}
479
480
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
502
503
type ProfileCard struct {
504
UserDid string
505
FollowStatus models.FollowStatus
506
Punchcard *models.Punchcard
507
Profile *models.Profile
···
644
return p.executePlain("user/fragments/editPins", w, params)
645
}
646
647
+
type StarBtnFragmentParams struct {
648
IsStarred bool
649
+
SubjectAt syntax.ATURI
650
+
StarCount int
651
}
652
653
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
654
+
return p.executePlain("fragments/starBtn-oob", w, params)
655
}
656
657
type RepoIndexParams struct {
···
659
RepoInfo repoinfo.RepoInfo
660
Active string
661
TagMap map[string][]string
662
+
CommitsTrunc []types.Commit
663
TagsTrunc []*types.TagReference
664
BranchesTrunc []types.Branch
665
// ForkInfo *types.ForkInfo
···
850
}
851
852
type Collaborator struct {
853
+
Did string
854
+
Role string
855
}
856
857
type RepoSettingsParams struct {
···
926
RepoInfo repoinfo.RepoInfo
927
Active string
928
Issues []models.Issue
929
+
IssueCount int
930
LabelDefs map[string]*models.LabelDefinition
931
Page pagination.Page
932
FilteringByOpen bool
···
944
Active string
945
Issue *models.Issue
946
CommentList []models.CommentListItem
947
+
Backlinks []models.RichReferenceLink
948
LabelDefs map[string]*models.LabelDefinition
949
950
OrderedReactionKinds []models.ReactionKind
···
1098
Pull *models.Pull
1099
Stack models.Stack
1100
AbandonedPulls []*models.Pull
1101
+
Backlinks []models.RichReferenceLink
1102
BranchDeleteStatus *models.BranchDeleteStatus
1103
MergeCheck types.MergeCheckResponse
1104
ResubmitCheck ResubmitResult
···
1270
return p.executePlain("repo/fragments/compareAllowPull", w, params)
1271
}
1272
1273
+
type RepoCompareDiffFragmentParams struct {
1274
+
Diff types.NiceDiff
1275
+
DiffOpts types.DiffOpts
1276
}
1277
1278
+
func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1279
+
return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1280
}
1281
1282
type LabelPanelParams struct {
···
1396
ShowRendered bool
1397
RenderToggle bool
1398
RenderedContents template.HTML
1399
+
String *models.String
1400
Stats models.StringStats
1401
+
IsStarred bool
1402
+
StarCount int
1403
Owner identity.Identity
1404
}
1405
+25
-22
appview/pages/repoinfo/repoinfo.go
+25
-22
appview/pages/repoinfo/repoinfo.go
···
1
package repoinfo
2
3
import (
4
"path"
5
"slices"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
"tangled.org/core/appview/models"
9
"tangled.org/core/appview/state/userutil"
10
)
11
12
-
func (r RepoInfo) Owner() string {
13
if r.OwnerHandle != "" {
14
return r.OwnerHandle
15
} else {
···
18
}
19
20
func (r RepoInfo) FullName() string {
21
-
return path.Join(r.Owner(), r.Name)
22
}
23
24
-
func (r RepoInfo) OwnerWithoutAt() string {
25
if r.OwnerHandle != "" {
26
return r.OwnerHandle
27
} else {
···
30
}
31
32
func (r RepoInfo) FullNameWithoutAt() string {
33
-
return path.Join(r.OwnerWithoutAt(), r.Name)
34
}
35
36
func (r RepoInfo) GetTabs() [][]string {
···
48
return tabs
49
}
50
51
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
70
}
71
72
// each tab on a repo could have some metadata:
···
1
package repoinfo
2
3
import (
4
+
"fmt"
5
"path"
6
"slices"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
"tangled.org/core/appview/models"
11
"tangled.org/core/appview/state/userutil"
12
)
13
14
+
func (r RepoInfo) owner() string {
15
if r.OwnerHandle != "" {
16
return r.OwnerHandle
17
} else {
···
20
}
21
22
func (r RepoInfo) FullName() string {
23
+
return path.Join(r.owner(), r.Name)
24
}
25
26
+
func (r RepoInfo) ownerWithoutAt() string {
27
if r.OwnerHandle != "" {
28
return r.OwnerHandle
29
} else {
···
32
}
33
34
func (r RepoInfo) FullNameWithoutAt() string {
35
+
return path.Join(r.ownerWithoutAt(), r.Name)
36
}
37
38
func (r RepoInfo) GetTabs() [][]string {
···
50
return tabs
51
}
52
53
+
func (r RepoInfo) RepoAt() syntax.ATURI {
54
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey))
55
+
}
56
+
57
type RepoInfo struct {
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
73
}
74
75
// each tab on a repo could have some metadata:
+9
-29
appview/pages/templates/brand/brand.html
+9
-29
appview/pages/templates/brand/brand.html
···
4
<div class="grid grid-cols-10">
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
-
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
Assets and guidelines for using Tangled's logo and brand elements.
9
</p>
10
</header>
···
14
15
<!-- Introduction Section -->
16
<section>
17
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
follow the below guidelines when using Dolly and the logotype.
20
</p>
21
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
</p>
24
</section>
···
34
</div>
35
<div class="order-1 lg:order-2">
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
<p class="text-gray-700 dark:text-gray-300">
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
backgrounds and designs.
···
53
</div>
54
<div class="order-1 lg:order-2">
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
<p class="text-gray-700 dark:text-gray-300">
58
This version features white text and elements, ideal for dark backgrounds
59
and inverted designs.
···
81
</div>
82
<div class="order-1 lg:order-2">
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
</p>
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
</div>
124
<div class="order-1 lg:order-2">
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
White logo mark on colored backgrounds.
128
</p>
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
</div>
166
<div class="order-1 lg:order-2">
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
Dark logo mark on lighter, pastel backgrounds.
170
</p>
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
</div>
187
<div class="order-1 lg:order-2">
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
Custom coloring of the logotype is permitted.
191
</p>
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
</p>
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
-
</p>
198
-
</div>
199
-
</section>
200
-
201
-
<!-- Silhouette Section -->
202
-
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
-
<div class="order-2 lg:order-1">
204
-
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
-
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
-
alt="Dolly silhouette"
207
-
class="w-full max-w-32 mx-auto" />
208
-
</div>
209
-
</div>
210
-
<div class="order-1 lg:order-2">
211
-
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
-
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
-
<p class="text-gray-700 dark:text-gray-300">
214
-
The silhouette can be used where a subtle brand presence is needed,
215
-
or as a background element. Works on any background color with proper contrast.
216
-
For example, we use this as the site's favicon.
217
</p>
218
</div>
219
</section>
···
4
<div class="grid grid-cols-10">
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-500 dark:text-gray-300 mb-1">
8
Assets and guidelines for using Tangled's logo and brand elements.
9
</p>
10
</header>
···
14
15
<!-- Introduction Section -->
16
<section>
17
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
follow the below guidelines when using Dolly and the logotype.
20
</p>
21
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
</p>
24
</section>
···
34
</div>
35
<div class="order-1 lg:order-2">
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p>
38
<p class="text-gray-700 dark:text-gray-300">
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
backgrounds and designs.
···
53
</div>
54
<div class="order-1 lg:order-2">
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p>
57
<p class="text-gray-700 dark:text-gray-300">
58
This version features white text and elements, ideal for dark backgrounds
59
and inverted designs.
···
81
</div>
82
<div class="order-1 lg:order-2">
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
</p>
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
</div>
124
<div class="order-1 lg:order-2">
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
127
White logo mark on colored backgrounds.
128
</p>
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
</div>
166
<div class="order-1 lg:order-2">
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
169
Dark logo mark on lighter, pastel backgrounds.
170
</p>
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
</div>
187
<div class="order-1 lg:order-2">
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
190
Custom coloring of the logotype is permitted.
191
</p>
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
</p>
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
</p>
198
</div>
199
</section>
+14
-2
appview/pages/templates/fragments/dolly/logo.html
+14
-2
appview/pages/templates/fragments/dolly/logo.html
···
2
<svg
3
version="1.1"
4
id="svg1"
5
-
class="{{ . }}"
6
width="25"
7
height="25"
8
viewBox="0 0 25 25"
···
17
xmlns:svg="http://www.w3.org/2000/svg"
18
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
xmlns:cc="http://creativecommons.org/ns#">
20
<sodipodi:namedview
21
id="namedview1"
22
pagecolor="#ffffff"
···
51
id="g1"
52
transform="translate(-0.42924038,-0.87777209)">
53
<path
54
-
fill="currentColor"
55
style="stroke-width:0.111183;"
56
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
id="path4"
···
2
<svg
3
version="1.1"
4
id="svg1"
5
+
class="{{ .Classes }}"
6
width="25"
7
height="25"
8
viewBox="0 0 25 25"
···
17
xmlns:svg="http://www.w3.org/2000/svg"
18
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
xmlns:cc="http://creativecommons.org/ns#">
20
+
<style>
21
+
.dolly {
22
+
color: #000000;
23
+
}
24
+
25
+
@media (prefers-color-scheme: dark) {
26
+
.dolly {
27
+
color: #ffffff;
28
+
}
29
+
}
30
+
</style>
31
<sodipodi:namedview
32
id="namedview1"
33
pagecolor="#ffffff"
···
62
id="g1"
63
transform="translate(-0.42924038,-0.87777209)">
64
<path
65
+
class="dolly"
66
+
fill="{{ or .FillColor "currentColor" }}"
67
style="stroke-width:0.111183;"
68
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
69
id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
-95
appview/pages/templates/fragments/dolly/silhouette.html
···
1
-
{{ define "fragments/dolly/silhouette" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
width="25"
6
-
height="25"
7
-
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
-
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
-
inkscape:export-xdpi="96"
11
-
inkscape:export-ydpi="96"
12
-
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
13
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
-
xmlns="http://www.w3.org/2000/svg"
16
-
xmlns:svg="http://www.w3.org/2000/svg"
17
-
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
-
xmlns:cc="http://creativecommons.org/ns#">
19
-
<style>
20
-
.dolly {
21
-
color: #000000;
22
-
}
23
-
24
-
@media (prefers-color-scheme: dark) {
25
-
.dolly {
26
-
color: #ffffff;
27
-
}
28
-
}
29
-
</style>
30
-
<sodipodi:namedview
31
-
id="namedview1"
32
-
pagecolor="#ffffff"
33
-
bordercolor="#000000"
34
-
borderopacity="0.25"
35
-
inkscape:showpageshadow="2"
36
-
inkscape:pageopacity="0.0"
37
-
inkscape:pagecheckerboard="true"
38
-
inkscape:deskcolor="#d5d5d5"
39
-
inkscape:zoom="64"
40
-
inkscape:cx="4.96875"
41
-
inkscape:cy="13.429688"
42
-
inkscape:window-width="3840"
43
-
inkscape:window-height="2160"
44
-
inkscape:window-x="0"
45
-
inkscape:window-y="0"
46
-
inkscape:window-maximized="0"
47
-
inkscape:current-layer="g1"
48
-
borderlayer="true">
49
-
<inkscape:page
50
-
x="0"
51
-
y="0"
52
-
width="25"
53
-
height="25"
54
-
id="page2"
55
-
margin="0"
56
-
bleed="0" />
57
-
</sodipodi:namedview>
58
-
<g
59
-
inkscape:groupmode="layer"
60
-
inkscape:label="Image"
61
-
id="g1"
62
-
transform="translate(-0.42924038,-0.87777209)">
63
-
<path
64
-
class="dolly"
65
-
fill="currentColor"
66
-
style="stroke-width:0.111183"
67
-
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
-
id="path7"
69
-
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
70
-
</g>
71
-
<metadata
72
-
id="metadata1">
73
-
<rdf:RDF>
74
-
<cc:Work
75
-
rdf:about="">
76
-
<cc:license
77
-
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
-
</cc:Work>
79
-
<cc:License
80
-
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
-
<cc:permits
82
-
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
-
<cc:permits
84
-
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
-
<cc:requires
86
-
rdf:resource="http://creativecommons.org/ns#Notice" />
87
-
<cc:requires
88
-
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
-
<cc:permits
90
-
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
-
</cc:License>
92
-
</rdf:RDF>
93
-
</metadata>
94
-
</svg>
95
-
{{ end }}
···
+1
-1
appview/pages/templates/fragments/logotype.html
+1
-1
appview/pages/templates/fragments/logotype.html
···
1
{{ define "fragments/logotype" }}
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }}
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
···
1
{{ define "fragments/logotype" }}
2
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }}
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
···
1
{{ define "fragments/logotypeSmall" }}
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
4
<span class="font-bold text-xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
···
1
{{ define "fragments/logotypeSmall" }}
2
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}}
4
<span class="font-bold text-xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
+5
appview/pages/templates/fragments/starBtn-oob.html
+5
appview/pages/templates/fragments/starBtn-oob.html
+26
appview/pages/templates/fragments/starBtn.html
+26
appview/pages/templates/fragments/starBtn.html
···
···
1
+
{{ define "fragments/starBtn" }}
2
+
{{/* NOTE: this fragment is always replaced with hx-swap-oob */}}
3
+
<button
4
+
id="starBtn"
5
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
+
data-star-subject-at="{{ .SubjectAt }}"
7
+
{{ if .IsStarred }}
8
+
hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
9
+
{{ else }}
10
+
hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
11
+
{{ end }}
12
+
13
+
hx-trigger="click"
14
+
hx-disabled-elt="#starBtn"
15
+
>
16
+
{{ if .IsStarred }}
17
+
{{ i "star" "w-4 h-4 fill-current" }}
18
+
{{ else }}
19
+
{{ i "star" "w-4 h-4" }}
20
+
{{ end }}
21
+
<span class="text-sm">
22
+
{{ .StarCount }}
23
+
</span>
24
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
+
</button>
26
+
{{ end }}
+8
appview/pages/templates/fragments/tabSelector.html
+8
appview/pages/templates/fragments/tabSelector.html
···
2
{{ $name := .Name }}
3
{{ $all := .Values }}
4
{{ $active := .Active }}
5
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
6
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
7
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
8
{{ range $index, $value := $all }}
9
{{ $isActive := eq $value.Key $active }}
10
<a href="?{{ $name }}={{ $value.Key }}"
11
class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
12
{{ if $value.Icon }}
13
{{ i $value.Icon "size-4" }}
···
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
appview/pages/templates/fragments/tinyAvatarList.html
+22
appview/pages/templates/fragments/tinyAvatarList.html
···
···
1
+
{{ define "fragments/tinyAvatarList" }}
2
+
{{ $all := .all }}
3
+
{{ $classes := .classes }}
4
+
{{ $ps := take $all 5 }}
5
+
<div class="inline-flex items-center -space-x-3">
6
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
7
+
{{ range $i, $p := $ps }}
8
+
<img
9
+
src="{{ tinyAvatar . }}"
10
+
alt=""
11
+
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}"
12
+
/>
13
+
{{ end }}
14
+
15
+
{{ if gt (len $all) 5 }}
16
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
17
+
+{{ sub (len $all) 5 }}
18
+
</span>
19
+
{{ end }}
20
+
</div>
21
+
{{ end }}
22
+
+23
-7
appview/pages/templates/knots/dashboard.html
+23
-7
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }} · knots{{ end }}
2
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
5
<div class="flex justify-between items-center">
6
-
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
7
<div id="right-side" class="flex gap-2">
8
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
···
35
</div>
36
37
{{ 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">
39
<div class="flex flex-col gap-2">
40
{{ block "member" . }} {{ end }}
41
</div>
···
79
<button
80
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
81
title="Delete knot"
82
-
hx-delete="/knots/{{ .Domain }}"
83
hx-swap="outerHTML"
84
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
85
hx-headers='{"shouldRedirect": "true"}'
···
95
<button
96
class="btn gap-2 group"
97
title="Retry knot verification"
98
-
hx-post="/knots/{{ .Domain }}/retry"
99
hx-swap="none"
100
hx-headers='{"shouldRefresh": "true"}'
101
>
···
113
<button
114
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
115
title="Remove member"
116
-
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
117
hx-swap="none"
118
hx-vals='{"member": "{{$member}}" }'
119
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
···
1
+
{{ define "title" }}{{ .Registration.Domain }} · {{ .Tab }} settings{{ end }}
2
3
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "knotDash" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "knotDash" }}
20
+
<div>
21
<div class="flex justify-between items-center">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} · {{ .Registration.Domain }}</h2>
23
<div id="right-side" class="flex gap-2">
24
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
25
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
···
51
</div>
52
53
{{ if .Members }}
54
+
<section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
55
<div class="flex flex-col gap-2">
56
{{ block "member" . }} {{ end }}
57
</div>
···
95
<button
96
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
97
title="Delete knot"
98
+
hx-delete="/settings/knots/{{ .Domain }}"
99
hx-swap="outerHTML"
100
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
101
hx-headers='{"shouldRedirect": "true"}'
···
111
<button
112
class="btn gap-2 group"
113
title="Retry knot verification"
114
+
hx-post="/settings/knots/{{ .Domain }}/retry"
115
hx-swap="none"
116
hx-headers='{"shouldRefresh": "true"}'
117
>
···
129
<button
130
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
131
title="Remove member"
132
+
hx-post="/settings/knots/{{ $root.Registration.Domain }}/remove"
133
hx-swap="none"
134
hx-vals='{"member": "{{$member}}" }'
135
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+1
-1
appview/pages/templates/knots/fragments/addMemberModal.html
+1
-1
appview/pages/templates/knots/fragments/addMemberModal.html
+3
-3
appview/pages/templates/knots/fragments/knotListing.html
+3
-3
appview/pages/templates/knots/fragments/knotListing.html
···
7
8
{{ define "knotLeftSide" }}
9
{{ if .Registered }}
10
-
<a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
{{ i "hard-drive" "w-4 h-4" }}
12
<span class="hover:underline">
13
{{ .Domain }}
···
56
<button
57
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
58
title="Delete knot"
59
-
hx-delete="/knots/{{ .Domain }}"
60
hx-swap="outerHTML"
61
hx-target="#knot-{{.Id}}"
62
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
···
72
<button
73
class="btn gap-2 group"
74
title="Retry knot verification"
75
-
hx-post="/knots/{{ .Domain }}/retry"
76
hx-swap="none"
77
hx-target="#knot-{{.Id}}"
78
>
···
7
8
{{ define "knotLeftSide" }}
9
{{ if .Registered }}
10
+
<a href="/settings/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
{{ i "hard-drive" "w-4 h-4" }}
12
<span class="hover:underline">
13
{{ .Domain }}
···
56
<button
57
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
58
title="Delete knot"
59
+
hx-delete="/settings/knots/{{ .Domain }}"
60
hx-swap="outerHTML"
61
hx-target="#knot-{{.Id}}"
62
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
···
72
<button
73
class="btn gap-2 group"
74
title="Retry knot verification"
75
+
hx-post="/settings/knots/{{ .Domain }}/retry"
76
hx-swap="none"
77
hx-target="#knot-{{.Id}}"
78
>
+42
-11
appview/pages/templates/knots/index.html
+42
-11
appview/pages/templates/knots/index.html
···
1
-
{{ define "title" }}knots{{ end }}
2
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>
11
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
13
<div class="flex flex-col gap-6">
14
-
{{ block "about" . }} {{ end }}
15
{{ block "list" . }} {{ end }}
16
{{ block "register" . }} {{ end }}
17
</div>
···
50
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
51
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
52
<form
53
-
hx-post="/knots/register"
54
class="max-w-2xl mb-2 space-y-4"
55
hx-indicator="#register-button"
56
hx-swap="none"
···
84
85
</section>
86
{{ end }}
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
3
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "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>
29
30
+
<section>
31
<div class="flex flex-col gap-6">
32
{{ block "list" . }} {{ end }}
33
{{ block "register" . }} {{ end }}
34
</div>
···
67
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
68
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
69
<form
70
+
hx-post="/settings/knots/register"
71
class="max-w-2xl mb-2 space-y-4"
72
hx-indicator="#register-button"
73
hx-swap="none"
···
101
102
</section>
103
{{ end }}
104
+
105
+
{{ define "docsButton" }}
106
+
<a
107
+
class="btn flex items-center gap-2"
108
+
href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide">
109
+
{{ i "book" "size-4" }}
110
+
docs
111
+
</a>
112
+
<div
113
+
id="add-email-modal"
114
+
popover
115
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
116
+
</div>
117
+
{{ end }}
+4
appview/pages/templates/layouts/base.html
+4
appview/pages/templates/layouts/base.html
···
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
<script defer src="/static/actor-typeahead.js" type="module"></script>
13
14
+
<link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/>
15
+
<link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/>
16
+
<link rel="apple-touch-icon" href="/static/logos/dolly.png"/>
17
+
18
<!-- preconnect to image cdn -->
19
<link rel="preconnect" href="https://avatar.tangled.sh" />
20
<link rel="preconnect" href="https://camo.tangled.sh" />
+1
-7
appview/pages/templates/layouts/fragments/topbar.html
+1
-7
appview/pages/templates/layouts/fragments/topbar.html
···
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
-
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
-
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
-
alpha
10
-
</span>
11
</a>
12
</div>
13
···
61
<a href="/{{ $user }}">profile</a>
62
<a href="/{{ $user }}?tab=repos">repositories</a>
63
<a href="/{{ $user }}?tab=strings">strings</a>
64
-
<a href="/knots">knots</a>
65
-
<a href="/spindles">spindles</a>
66
<a href="/settings">settings</a>
67
<a href="#"
68
hx-post="/logout"
···
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/logotypeSmall" }}
7
</a>
8
</div>
9
···
57
<a href="/{{ $user }}">profile</a>
58
<a href="/{{ $user }}?tab=repos">repositories</a>
59
<a href="/{{ $user }}?tab=strings">strings</a>
60
<a href="/settings">settings</a>
61
<a href="#"
62
hx-post="/logout"
+8
-7
appview/pages/templates/layouts/profilebase.html
+8
-7
appview/pages/templates/layouts/profilebase.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
3
{{ define "extrameta" }}
4
-
{{ $avatarUrl := fullAvatar .Card.UserHandle }}
5
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
6
<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 }}" />
9
<meta property="og:image" content="{{ $avatarUrl }}" />
10
<meta property="og:image:width" content="512" />
11
<meta property="og:image:height" content="512" />
12
13
<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 }}" />
16
<meta name="twitter:image" content="{{ $avatarUrl }}" />
17
{{ end }}
18
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }}{{ end }}
2
3
{{ define "extrameta" }}
4
+
{{ $handle := resolve .Card.UserDid }}
5
+
{{ $avatarUrl := fullAvatar $handle }}
6
+
<meta property="og:title" content="{{ $handle }}" />
7
<meta property="og:type" content="profile" />
8
+
<meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" />
9
+
<meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" />
10
<meta property="og:image" content="{{ $avatarUrl }}" />
11
<meta property="og:image:width" content="512" />
12
<meta property="og:image:height" content="512" />
13
14
<meta name="twitter:card" content="summary" />
15
+
<meta name="twitter:title" content="{{ $handle }}" />
16
+
<meta name="twitter:description" content="{{ or .Card.Profile.Description $handle }}" />
17
<meta name="twitter:image" content="{{ $avatarUrl }}" />
18
{{ end }}
19
+4
-1
appview/pages/templates/layouts/repobase.html
+4
-1
appview/pages/templates/layouts/repobase.html
···
49
</div>
50
51
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
52
+
{{ template "fragments/starBtn"
53
+
(dict "SubjectAt" .RepoInfo.RepoAt
54
+
"IsStarred" .RepoInfo.IsStarred
55
+
"StarCount" .RepoInfo.Stats.StarCount) }}
56
<a
57
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
58
hx-boost="true"
+35
-10
appview/pages/templates/repo/commit.html
+35
-10
appview/pages/templates/repo/commit.html
···
25
</div>
26
27
<div class="flex flex-wrap items-center space-x-2">
28
-
<p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300">
29
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
30
-
31
-
{{ if $did }}
32
-
{{ template "user/fragments/picHandleLink" $did }}
33
-
{{ else }}
34
-
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
-
{{ end }}
36
37
<span class="px-1 select-none before:content-['\00B7']"></span>
38
-
{{ template "repo/fragments/time" $commit.Author.When }}
39
<span class="px-1 select-none before:content-['\00B7']"></span>
40
41
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
···
79
</section>
80
{{end}}
81
82
{{ define "topbarLayout" }}
83
<header class="col-span-full" style="z-index: 20;">
84
{{ template "layouts/fragments/topbar" . }}
···
111
{{ end }}
112
113
{{ define "contentAfter" }}
114
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }}
115
{{end}}
116
117
{{ define "contentAfterLeft" }}
···
25
</div>
26
27
<div class="flex flex-wrap items-center space-x-2">
28
+
<p class="flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
29
+
{{ template "attribution" . }}
30
31
<span class="px-1 select-none before:content-['\00B7']"></span>
32
+
{{ template "repo/fragments/time" $commit.Committer.When }}
33
<span class="px-1 select-none before:content-['\00B7']"></span>
34
35
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
···
73
</section>
74
{{end}}
75
76
+
{{ define "attribution" }}
77
+
{{ $commit := .Diff.Commit }}
78
+
{{ $showCommitter := true }}
79
+
{{ if eq $commit.Author.Email $commit.Committer.Email }}
80
+
{{ $showCommitter = false }}
81
+
{{ end }}
82
+
83
+
{{ if $showCommitter }}
84
+
authored by {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid) }}
85
+
{{ range $commit.CoAuthors }}
86
+
{{ template "attributedUser" (list .Email .Name $.EmailToDid) }}
87
+
{{ end }}
88
+
and committed by {{ template "attributedUser" (list $commit.Committer.Email $commit.Committer.Name $.EmailToDid) }}
89
+
{{ else }}
90
+
{{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid )}}
91
+
{{ end }}
92
+
{{ end }}
93
+
94
+
{{ define "attributedUser" }}
95
+
{{ $email := index . 0 }}
96
+
{{ $name := index . 1 }}
97
+
{{ $map := index . 2 }}
98
+
{{ $did := index $map $email }}
99
+
100
+
{{ if $did }}
101
+
{{ template "user/fragments/picHandleLink" $did }}
102
+
{{ else }}
103
+
<a href="mailto:{{ $email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $name }}</a>
104
+
{{ end }}
105
+
{{ end }}
106
+
107
{{ define "topbarLayout" }}
108
<header class="col-span-full" style="z-index: 20;">
109
{{ template "layouts/fragments/topbar" . }}
···
136
{{ end }}
137
138
{{ define "contentAfter" }}
139
+
{{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
140
{{end}}
141
142
{{ define "contentAfterLeft" }}
+1
-1
appview/pages/templates/repo/compare/compare.html
+1
-1
appview/pages/templates/repo/compare/compare.html
+2
-2
appview/pages/templates/repo/empty.html
+2
-2
appview/pages/templates/repo/empty.html
···
26
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
{{ $knot := .RepoInfo.Knot }}
28
{{ if eq $knot "knot1.tangled.sh" }}
29
-
{{ $knot = "tangled.sh" }}
30
{{ end }}
31
<div class="w-full flex place-content-center">
32
<div class="py-6 w-fit flex flex-col gap-4">
···
35
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
-
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
</div>
41
</div>
···
26
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
{{ $knot := .RepoInfo.Knot }}
28
{{ if eq $knot "knot1.tangled.sh" }}
29
+
{{ $knot = "tangled.org" }}
30
{{ end }}
31
<div class="w-full flex place-content-center">
32
<div class="py-6 w-fit flex flex-col gap-4">
···
35
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code></p>
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
</div>
41
</div>
+2
-1
appview/pages/templates/repo/fork.html
+2
-1
appview/pages/templates/repo/fork.html
···
25
value="{{ . }}"
26
class="mr-2"
27
id="domain-{{ . }}"
28
/>
29
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
30
</div>
···
33
{{ end }}
34
</div>
35
</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
</fieldset>
38
39
<div class="space-y-2">
···
25
value="{{ . }}"
26
class="mr-2"
27
id="domain-{{ . }}"
28
+
{{if eq (len $.Knots) 1}}checked{{end}}
29
/>
30
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
31
</div>
···
34
{{ end }}
35
</div>
36
</div>
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>
38
</fieldset>
39
40
<div class="space-y-2">
+49
appview/pages/templates/repo/fragments/backlinks.html
+49
appview/pages/templates/repo/fragments/backlinks.html
···
···
1
+
{{ define "repo/fragments/backlinks" }}
2
+
{{ if .Backlinks }}
3
+
<div id="at-uri-panel" class="px-2 md:px-0">
4
+
<div>
5
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span>
6
+
</div>
7
+
<ul>
8
+
{{ range .Backlinks }}
9
+
<li>
10
+
{{ $repoOwner := resolve .Handle }}
11
+
{{ $repoName := .Repo }}
12
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
13
+
<div class="flex flex-col">
14
+
<div class="flex gap-2 items-center">
15
+
{{ if .State.IsClosed }}
16
+
<span class="text-gray-500 dark:text-gray-400">
17
+
{{ i "ban" "size-3" }}
18
+
</span>
19
+
{{ else if eq .Kind.String "issues" }}
20
+
<span class="text-green-600 dark:text-green-500">
21
+
{{ i "circle-dot" "size-3" }}
22
+
</span>
23
+
{{ else if .State.IsOpen }}
24
+
<span class="text-green-600 dark:text-green-500">
25
+
{{ i "git-pull-request" "size-3" }}
26
+
</span>
27
+
{{ else if .State.IsMerged }}
28
+
<span class="text-purple-600 dark:text-purple-500">
29
+
{{ i "git-merge" "size-3" }}
30
+
</span>
31
+
{{ else }}
32
+
<span class="text-gray-600 dark:text-gray-300">
33
+
{{ i "git-pull-request-closed" "size-3" }}
34
+
</span>
35
+
{{ end }}
36
+
<a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
+
</div>
38
+
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
+
<div>
40
+
<span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span>
41
+
</div>
42
+
{{ end }}
43
+
</div>
44
+
</li>
45
+
{{ end }}
46
+
</ul>
47
+
</div>
48
+
{{ end }}
49
+
{{ end }}
+3
-2
appview/pages/templates/repo/fragments/cloneDropdown.html
+3
-2
appview/pages/templates/repo/fragments/cloneDropdown.html
···
43
44
<!-- SSH Clone -->
45
<div class="mb-3">
46
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label>
47
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
48
<code
49
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
50
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
<button
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
43
44
<!-- SSH Clone -->
45
<div class="mb-3">
46
+
{{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }}
47
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label>
48
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
49
<code
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"
51
onclick="window.getSelection().selectAllChildren(this)"
52
+
data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}"
53
+
>git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code>
54
<button
55
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
56
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+3
-4
appview/pages/templates/repo/fragments/diff.html
+3
-4
appview/pages/templates/repo/fragments/diff.html
···
1
{{ define "repo/fragments/diff" }}
2
-
{{ $repo := index . 0 }}
3
-
{{ $diff := index . 1 }}
4
-
{{ $opts := index . 2 }}
5
6
{{ $commit := $diff.Commit }}
7
{{ $diff := $diff.Diff }}
···
18
{{ else }}
19
{{ range $idx, $hunk := $diff }}
20
{{ with $hunk }}
21
-
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
22
<summary class="list-none cursor-pointer sticky top-0">
23
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
24
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
···
1
{{ define "repo/fragments/diff" }}
2
+
{{ $diff := index . 0 }}
3
+
{{ $opts := index . 1 }}
4
5
{{ $commit := $diff.Commit }}
6
{{ $diff := $diff.Diff }}
···
17
{{ else }}
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
+
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
<summary class="list-none cursor-pointer sticky top-0">
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+15
-1
appview/pages/templates/repo/fragments/editLabelPanel.html
+15
-1
appview/pages/templates/repo/fragments/editLabelPanel.html
···
170
{{ $fieldName := $def.AtUri }}
171
{{ $valueType := $def.ValueType }}
172
{{ $value := .value }}
173
{{ if $valueType.IsDidFormat }}
174
{{ $value = trimPrefix (resolve .value) "@" }}
175
{{ end }}
176
-
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
177
{{ end }}
178
179
{{ define "nullTypeInput" }}
···
170
{{ $fieldName := $def.AtUri }}
171
{{ $valueType := $def.ValueType }}
172
{{ $value := .value }}
173
+
174
{{ if $valueType.IsDidFormat }}
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}}">
190
{{ end }}
191
{{ end }}
192
193
{{ define "nullTypeInput" }}
+1
-16
appview/pages/templates/repo/fragments/participants.html
+1
-16
appview/pages/templates/repo/fragments/participants.html
···
6
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
</div>
9
-
<div class="flex items-center -space-x-3 mt-2">
10
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
-
{{ range $i, $p := $ps }}
12
-
<img
13
-
src="{{ tinyAvatar . }}"
14
-
alt=""
15
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
-
/>
17
-
{{ end }}
18
-
19
-
{{ if gt (len $all) 5 }}
20
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
-
+{{ sub (len $all) 5 }}
22
-
</span>
23
-
{{ end }}
24
-
</div>
25
</div>
26
{{ end }}
···
6
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
</div>
9
+
{{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "w-8 h-8") }}
10
</div>
11
{{ end }}
-26
appview/pages/templates/repo/fragments/repoStar.html
-26
appview/pages/templates/repo/fragments/repoStar.html
···
1
-
{{ define "repo/fragments/repoStar" }}
2
-
<button
3
-
id="starBtn"
4
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
-
{{ if .IsStarred }}
6
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
7
-
{{ else }}
8
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
9
-
{{ end }}
10
-
11
-
hx-trigger="click"
12
-
hx-target="this"
13
-
hx-swap="outerHTML"
14
-
hx-disabled-elt="#starBtn"
15
-
>
16
-
{{ if .IsStarred }}
17
-
{{ i "star" "w-4 h-4 fill-current" }}
18
-
{{ else }}
19
-
{{ i "star" "w-4 h-4" }}
20
-
{{ end }}
21
-
<span class="text-sm">
22
-
{{ .Stats.StarCount }}
23
-
</span>
24
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
-
</button>
26
-
{{ end }}
···
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
14
{{- range .LeftLines -}}
15
{{- if .IsEmpty -}}
16
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
-
</div>
21
{{- else if eq .Op.String "-" -}}
22
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
-
<div class="px-2">{{ .Content }}</div>
26
-
</div>
27
{{- else if eq .Op.String " " -}}
28
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
-
<div class="px-2">{{ .Content }}</div>
32
-
</div>
33
{{- end -}}
34
{{- end -}}
35
-
{{- end -}}</div></div></pre>
36
37
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
38
{{- range .RightLines -}}
39
{{- if .IsEmpty -}}
40
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
-
</div>
45
{{- else if eq .Op.String "+" -}}
46
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
-
<div class="px-2" >{{ .Content }}</div>
50
-
</div>
51
{{- else if eq .Op.String " " -}}
52
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
-
<div class="px-2">{{ .Content }}</div>
56
-
</div>
57
{{- end -}}
58
{{- end -}}
59
-
{{- end -}}</div></div></pre>
60
</div>
61
{{ end }}
···
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
14
{{- range .LeftLines -}}
15
{{- if .IsEmpty -}}
16
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
+
</span>
21
{{- else if eq .Op.String "-" -}}
22
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
+
</span>
27
{{- else if eq .Op.String " " -}}
28
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
+
</span>
33
{{- end -}}
34
{{- end -}}
35
+
{{- end -}}</div></div></div>
36
37
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
38
{{- range .RightLines -}}
39
{{- if .IsEmpty -}}
40
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
+
</span>
45
{{- else if eq .Op.String "+" -}}
46
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
+
</span>
51
{{- else if eq .Op.String " " -}}
52
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
+
</span>
57
{{- end -}}
58
{{- end -}}
59
+
{{- end -}}</div></div></div>
60
</div>
61
{{ end }}
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
{{ define "repo/fragments/unifiedDiff" }}
2
{{ $name := .Id }}
3
-
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
4
{{- $oldStart := .OldPosition -}}
5
{{- $newStart := .NewPosition -}}
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
{{- $lineNrSepStyle1 := "" -}}
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
{{- range .Lines -}}
16
{{- if eq .Op.String "+" -}}
17
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
-
<div class="px-2">{{ .Line }}</div>
22
-
</div>
23
{{- $newStart = add64 $newStart 1 -}}
24
{{- end -}}
25
{{- if eq .Op.String "-" -}}
26
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
-
<div class="px-2">{{ .Line }}</div>
31
-
</div>
32
{{- $oldStart = add64 $oldStart 1 -}}
33
{{- end -}}
34
{{- if eq .Op.String " " -}}
35
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
-
<div class="px-2">{{ .Line }}</div>
40
-
</div>
41
{{- $newStart = add64 $newStart 1 -}}
42
{{- $oldStart = add64 $oldStart 1 -}}
43
{{- end -}}
44
{{- end -}}
45
-
{{- end -}}</div></div></pre>
46
{{ end }}
47
-
···
1
{{ define "repo/fragments/unifiedDiff" }}
2
{{ $name := .Id }}
3
+
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
4
{{- $oldStart := .OldPosition -}}
5
{{- $newStart := .NewPosition -}}
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
{{- $lineNrSepStyle1 := "" -}}
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
{{- range .Lines -}}
16
{{- if eq .Op.String "+" -}}
17
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
+
</span>
23
{{- $newStart = add64 $newStart 1 -}}
24
{{- end -}}
25
{{- if eq .Op.String "-" -}}
26
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
+
</span>
32
{{- $oldStart = add64 $oldStart 1 -}}
33
{{- end -}}
34
{{- if eq .Op.String " " -}}
35
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
+
</span>
41
{{- $newStart = add64 $newStart 1 -}}
42
{{- $oldStart = add64 $oldStart 1 -}}
43
{{- end -}}
44
{{- end -}}
45
+
{{- end -}}</div></div></div>
46
{{ end }}
+31
-9
appview/pages/templates/repo/index.html
+31
-9
appview/pages/templates/repo/index.html
···
14
{{ end }}
15
<div class="flex items-center justify-between pb-5">
16
{{ block "branchSelector" . }}{{ end }}
17
-
<div class="flex md:hidden items-center gap-2">
18
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold">
19
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
20
</a>
···
47
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
48
{{ range $value := .Languages }}
49
<div
50
-
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
>
52
{{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }}
53
<div>{{ or $value.Name "Other" }}
···
66
67
{{ define "branchSelector" }}
68
<div class="flex gap-2 items-center justify-between w-full">
69
-
<div class="flex gap-2 items-center">
70
<select
71
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
72
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
···
228
<span
229
class="mx-1 before:content-['ยท'] before:select-none"
230
></span>
231
-
<span>
232
-
{{ $did := index $.EmailToDid .Author.Email }}
233
-
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
234
-
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
235
-
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
236
-
</span>
237
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
238
{{ template "repo/fragments/time" .Committer.When }}
239
···
259
{{ end }}
260
</div>
261
</div>
262
{{ end }}
263
264
{{ define "branchList" }}
···
14
{{ end }}
15
<div class="flex items-center justify-between pb-5">
16
{{ block "branchSelector" . }}{{ end }}
17
+
<div class="flex md:hidden items-center gap-3">
18
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold">
19
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
20
</a>
···
47
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
48
{{ range $value := .Languages }}
49
<div
50
+
class="flex items-center gap-2 text-xs align-items-center justify-center"
51
>
52
{{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }}
53
<div>{{ or $value.Name "Other" }}
···
66
67
{{ define "branchSelector" }}
68
<div class="flex gap-2 items-center justify-between w-full">
69
+
<div class="flex gap-2 items-stretch">
70
<select
71
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
72
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
···
228
<span
229
class="mx-1 before:content-['ยท'] before:select-none"
230
></span>
231
+
{{ template "attribution" (list . $.EmailToDid) }}
232
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
233
{{ template "repo/fragments/time" .Committer.When }}
234
···
254
{{ end }}
255
</div>
256
</div>
257
+
{{ end }}
258
+
259
+
{{ define "attribution" }}
260
+
{{ $commit := index . 0 }}
261
+
{{ $map := index . 1 }}
262
+
<span class="flex items-center">
263
+
{{ $author := index $map $commit.Author.Email }}
264
+
{{ $coauthors := $commit.CoAuthors }}
265
+
{{ $all := list }}
266
+
267
+
{{ if $author }}
268
+
{{ $all = append $all $author }}
269
+
{{ end }}
270
+
{{ range $coauthors }}
271
+
{{ $co := index $map .Email }}
272
+
{{ if $co }}
273
+
{{ $all = append $all $co }}
274
+
{{ end }}
275
+
{{ end }}
276
+
277
+
{{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }}
278
+
<a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
279
+
class="no-underline hover:underline">
280
+
{{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }}
281
+
{{ if $coauthors }} +{{ length $coauthors }}{{ end }}
282
+
</a>
283
+
</span>
284
{{ end }}
285
286
{{ define "branchList" }}
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
19
{{ end }}
20
21
{{ define "timestamp" }}
22
-
<a href="#{{ .Comment.Id }}"
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 }}">
25
{{ if .Comment.Deleted }}
26
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
27
{{ else if .Comment.Edited }}
···
19
{{ end }}
20
21
{{ define "timestamp" }}
22
+
<a href="#comment-{{ .Comment.Id }}"
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-{{ .Comment.Id }}">
25
{{ if .Comment.Deleted }}
26
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
27
{{ else if .Comment.Edited }}
+3
appview/pages/templates/repo/issues/issue.html
+3
appview/pages/templates/repo/issues/issue.html
···
20
"Subject" $.Issue.AtUri
21
"State" $.Issue.Labels) }}
22
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
+
{{ template "repo/fragments/backlinks"
24
+
(dict "RepoInfo" $.RepoInfo
25
+
"Backlinks" $.Backlinks) }}
26
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
27
</div>
28
</div>
+97
-27
appview/pages/templates/repo/issues/issues.html
+97
-27
appview/pages/templates/repo/issues/issues.html
···
32
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
33
<div class="flex-1 flex relative">
34
<input
35
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
36
type="text"
37
name="q"
···
53
</button>
54
</form>
55
<div class="sm:row-start-1">
56
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
57
</div>
58
<a
59
href="/{{ .RepoInfo.FullName }}/issues/new"
···
70
<div class="mt-2">
71
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
72
</div>
73
-
{{ block "pagination" . }} {{ end }}
74
{{ end }}
75
76
{{ define "pagination" }}
77
-
<div class="flex justify-end mt-4 gap-2">
78
-
{{ $currentState := "closed" }}
79
-
{{ if .FilteringByOpen }}
80
-
{{ $currentState = "open" }}
81
-
{{ end }}
82
83
{{ if gt .Page.Offset 0 }}
84
-
{{ $prev := .Page.Previous }}
85
-
<a
86
-
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
87
-
hx-boost="true"
88
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
89
-
>
90
-
{{ i "chevron-left" "w-4 h-4" }}
91
-
previous
92
-
</a>
93
-
{{ else }}
94
-
<div></div>
95
{{ end }}
96
97
{{ if eq (len .Issues) .Page.Limit }}
98
-
{{ $next := .Page.Next }}
99
-
<a
100
-
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
101
-
hx-boost="true"
102
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
103
-
>
104
-
next
105
-
{{ i "chevron-right" "w-4 h-4" }}
106
-
</a>
107
{{ end }}
108
</div>
109
{{ end }}
···
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"
···
54
</button>
55
</form>
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
60
href="/{{ .RepoInfo.FullName }}/issues/new"
···
71
<div class="mt-2">
72
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
73
</div>
74
+
{{if gt .IssueCount .Page.Limit }}
75
+
{{ block "pagination" . }} {{ end }}
76
+
{{ end }}
77
{{ end }}
78
79
{{ define "pagination" }}
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) }}
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
+
"
98
{{ if gt .Page.Offset 0 }}
99
+
hx-boost="true"
100
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}"
101
{{ end }}
102
+
>
103
+
{{ i "chevron-left" "w-4 h-4" }}
104
+
previous
105
+
</a>
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
+
"
170
{{ if eq (len .Issues) .Page.Limit }}
171
+
hx-boost="true"
172
+
href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}"
173
{{ end }}
174
+
>
175
+
next
176
+
{{ i "chevron-right" "w-4 h-4" }}
177
+
</a>
178
</div>
179
{{ end }}
+40
-23
appview/pages/templates/repo/log.html
+40
-23
appview/pages/templates/repo/log.html
···
17
<div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700">
18
{{ $grid := "grid grid-cols-14 gap-4" }}
19
<div class="{{ $grid }}">
20
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div>
21
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
</div>
26
{{ range $index, $commit := .Commits }}
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
<div class="{{ $grid }} py-3">
29
-
<div class="align-top truncate col-span-2">
30
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
-
{{ if $did }}
32
-
{{ template "user/fragments/picHandleLink" $did }}
33
-
{{ else }}
34
-
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
-
{{ end }}
36
</div>
37
<div class="align-top font-mono flex items-start col-span-3">
38
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
···
61
<div class="align-top col-span-6">
62
<div>
63
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
64
{{ if gt (len $messageParts) 1 }}
65
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
66
{{ end }}
···
72
</span>
73
{{ end }}
74
{{ end }}
75
</div>
76
77
{{ if gt (len $messageParts) 1 }}
78
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
79
{{ end }}
80
-
</div>
81
-
<div class="align-top col-span-1">
82
-
<!-- ci status -->
83
-
{{ $pipeline := index $.Pipelines .Hash.String }}
84
-
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
85
-
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
-
{{ end }}
87
</div>
88
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
</div>
···
152
</a>
153
</span>
154
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
155
-
<span>
156
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
-
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
-
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
-
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
-
</a>
161
-
</span>
162
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
163
<span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span>
164
···
176
</div>
177
</section>
178
179
{{ end }}
180
181
{{ define "repoAfter" }}
···
17
<div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700">
18
{{ $grid := "grid grid-cols-14 gap-4" }}
19
<div class="{{ $grid }}">
20
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Author</div>
21
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
24
</div>
25
{{ range $index, $commit := .Commits }}
26
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
27
<div class="{{ $grid }} py-3">
28
+
<div class="align-top col-span-3">
29
+
{{ template "attribution" (list $commit $.EmailToDid) }}
30
</div>
31
<div class="align-top font-mono flex items-start col-span-3">
32
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
···
55
<div class="align-top col-span-6">
56
<div>
57
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
58
+
59
{{ if gt (len $messageParts) 1 }}
60
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
61
{{ end }}
···
67
</span>
68
{{ end }}
69
{{ end }}
70
+
71
+
<!-- ci status -->
72
+
<span class="text-xs">
73
+
{{ $pipeline := index $.Pipelines .Hash.String }}
74
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
75
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
76
+
{{ end }}
77
+
</span>
78
</div>
79
80
{{ if gt (len $messageParts) 1 }}
81
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
82
{{ end }}
83
</div>
84
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
85
</div>
···
148
</a>
149
</span>
150
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
151
+
{{ template "attribution" (list $commit $.EmailToDid) }}
152
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
153
<span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span>
154
···
166
</div>
167
</section>
168
169
+
{{ end }}
170
+
171
+
{{ define "attribution" }}
172
+
{{ $commit := index . 0 }}
173
+
{{ $map := index . 1 }}
174
+
<span class="flex items-center gap-1">
175
+
{{ $author := index $map $commit.Author.Email }}
176
+
{{ $coauthors := $commit.CoAuthors }}
177
+
{{ $all := list }}
178
+
179
+
{{ if $author }}
180
+
{{ $all = append $all $author }}
181
+
{{ end }}
182
+
{{ range $coauthors }}
183
+
{{ $co := index $map .Email }}
184
+
{{ if $co }}
185
+
{{ $all = append $all $co }}
186
+
{{ end }}
187
+
{{ end }}
188
+
189
+
{{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }}
190
+
<a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
191
+
class="no-underline hover:underline">
192
+
{{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }}
193
+
{{ if $coauthors }} +{{ length $coauthors }}{{ end }}
194
+
</a>
195
+
</span>
196
{{ end }}
197
198
{{ define "repoAfter" }}
+2
-1
appview/pages/templates/repo/new.html
+2
-1
appview/pages/templates/repo/new.html
···
155
class="mr-2"
156
id="domain-{{ . }}"
157
required
158
/>
159
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
</div>
···
164
</div>
165
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
A knot hosts repository data and handles Git operations.
167
-
You can also <a href="/knots" class="underline">register your own knot</a>.
168
</p>
169
</div>
170
{{ end }}
···
155
class="mr-2"
156
id="domain-{{ . }}"
157
required
158
+
{{if eq (len $.Knots) 1}}checked{{end}}
159
/>
160
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
161
</div>
···
165
</div>
166
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
167
A knot hosts repository data and handles Git operations.
168
+
You can also <a href="/settings/knots" class="underline">register your own knot</a>.
169
</p>
170
</div>
171
{{ end }}
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
···
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>
+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
"Subject" $.Pull.AtUri
22
"State" $.Pull.Labels) }}
23
{{ template "repo/fragments/participants" $.Pull.Participants }}
24
+
{{ template "repo/fragments/backlinks"
25
+
(dict "RepoInfo" $.RepoInfo
26
+
"Backlinks" $.Backlinks) }}
27
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
28
</div>
29
</div>
+2
-1
appview/pages/templates/repo/pulls/pulls.html
+2
-1
appview/pages/templates/repo/pulls/pulls.html
···
38
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
39
<div class="flex-1 flex relative">
40
<input
41
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
42
type="text"
43
name="q"
···
59
</button>
60
</form>
61
<div class="sm:row-start-1">
62
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
63
</div>
64
<a
65
href="/{{ .RepoInfo.FullName }}/pulls/new"
···
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"
···
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") }}
64
</div>
65
<a
66
href="/{{ .RepoInfo.FullName }}/pulls/new"
+5
-4
appview/pages/templates/repo/settings/access.html
+5
-4
appview/pages/templates/repo/settings/access.html
···
29
{{ template "addCollaboratorButton" . }}
30
{{ end }}
31
{{ range .Collaborators }}
32
<div class="border border-gray-200 dark:border-gray-700 rounded p-4">
33
<div class="flex items-center gap-3">
34
<img
35
-
src="{{ fullAvatar .Handle }}"
36
-
alt="{{ .Handle }}"
37
class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/>
38
39
<div class="flex-1 min-w-0">
40
-
<a href="/{{ .Handle }}" class="block truncate">
41
-
{{ didOrHandle .Did .Handle }}
42
</a>
43
<p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p>
44
</div>
···
29
{{ template "addCollaboratorButton" . }}
30
{{ end }}
31
{{ range .Collaborators }}
32
+
{{ $handle := resolve .Did }}
33
<div class="border border-gray-200 dark:border-gray-700 rounded p-4">
34
<div class="flex items-center gap-3">
35
<img
36
+
src="{{ fullAvatar $handle }}"
37
+
alt="{{ $handle }}"
38
class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/>
39
40
<div class="flex-1 min-w-0">
41
+
<a href="/{{ $handle }}" class="block truncate">
42
+
{{ $handle }}
43
</a>
44
<p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p>
45
</div>
+1
-1
appview/pages/templates/repo/settings/pipelines.html
+1
-1
appview/pages/templates/repo/settings/pipelines.html
···
22
<p class="text-gray-500 dark:text-gray-400">
23
Choose a spindle to execute your workflows on. Only repository owners
24
can configure spindles. Spindles can be selfhosted,
25
-
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
26
click to learn more.
27
</a>
28
</p>
···
22
<p class="text-gray-500 dark:text-gray-400">
23
Choose a spindle to execute your workflows on. Only repository owners
24
can configure spindles. Spindles can be selfhosted,
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide">
26
click to learn more.
27
</a>
28
</p>
+22
-6
appview/pages/templates/spindles/dashboard.html
+22
-6
appview/pages/templates/spindles/dashboard.html
···
1
-
{{ define "title" }}{{.Spindle.Instance}} · spindles{{ end }}
2
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
5
<div class="flex justify-between items-center">
6
-
<h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1>
7
<div id="right-side" class="flex gap-2">
8
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }}
···
71
<button
72
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
73
title="Delete spindle"
74
-
hx-delete="/spindles/{{ .Instance }}"
75
hx-swap="outerHTML"
76
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
77
hx-headers='{"shouldRedirect": "true"}'
···
87
<button
88
class="btn gap-2 group"
89
title="Retry spindle verification"
90
-
hx-post="/spindles/{{ .Instance }}/retry"
91
hx-swap="none"
92
hx-headers='{"shouldRefresh": "true"}'
93
>
···
104
<button
105
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
106
title="Remove member"
107
-
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
108
hx-swap="none"
109
hx-vals='{"member": "{{$member}}" }'
110
hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
···
1
+
{{ define "title" }}{{.Spindle.Instance}} · {{ .Tab }} settings{{ end }}
2
3
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "spindleDash" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "spindleDash" }}
20
+
<div>
21
<div class="flex justify-between items-center">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} · {{ .Spindle.Instance }}</h2>
23
<div id="right-side" class="flex gap-2">
24
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
25
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }}
···
87
<button
88
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
89
title="Delete spindle"
90
+
hx-delete="/settings/spindles/{{ .Instance }}"
91
hx-swap="outerHTML"
92
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
93
hx-headers='{"shouldRedirect": "true"}'
···
103
<button
104
class="btn gap-2 group"
105
title="Retry spindle verification"
106
+
hx-post="/settings/spindles/{{ .Instance }}/retry"
107
hx-swap="none"
108
hx-headers='{"shouldRefresh": "true"}'
109
>
···
120
<button
121
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
122
title="Remove member"
123
+
hx-post="/settings/spindles/{{ $root.Spindle.Instance }}/remove"
124
hx-swap="none"
125
hx-vals='{"member": "{{$member}}" }'
126
hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
+3
-3
appview/pages/templates/spindles/fragments/spindleListing.html
+3
-3
appview/pages/templates/spindles/fragments/spindleListing.html
···
7
8
{{ define "spindleLeftSide" }}
9
{{ if .Verified }}
10
-
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
{{ i "hard-drive" "w-4 h-4" }}
12
<span class="hover:underline">
13
{{ .Instance }}
···
50
<button
51
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
52
title="Delete spindle"
53
-
hx-delete="/spindles/{{ .Instance }}"
54
hx-swap="outerHTML"
55
hx-target="#spindle-{{.Id}}"
56
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
···
66
<button
67
class="btn gap-2 group"
68
title="Retry spindle verification"
69
-
hx-post="/spindles/{{ .Instance }}/retry"
70
hx-swap="none"
71
hx-target="#spindle-{{.Id}}"
72
>
···
7
8
{{ define "spindleLeftSide" }}
9
{{ if .Verified }}
10
+
<a href="/settings/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
{{ i "hard-drive" "w-4 h-4" }}
12
<span class="hover:underline">
13
{{ .Instance }}
···
50
<button
51
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
52
title="Delete spindle"
53
+
hx-delete="/settings/spindles/{{ .Instance }}"
54
hx-swap="outerHTML"
55
hx-target="#spindle-{{.Id}}"
56
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
···
66
<button
67
class="btn gap-2 group"
68
title="Retry spindle verification"
69
+
hx-post="/settings/spindles/{{ .Instance }}/retry"
70
hx-swap="none"
71
hx-target="#spindle-{{.Id}}"
72
>
+90
-59
appview/pages/templates/spindles/index.html
+90
-59
appview/pages/templates/spindles/index.html
···
1
-
{{ define "title" }}spindles{{ end }}
2
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>
10
</div>
11
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
13
<div class="flex flex-col gap-6">
14
-
{{ block "about" . }} {{ end }}
15
{{ block "list" . }} {{ end }}
16
{{ block "register" . }} {{ end }}
17
</div>
···
20
21
{{ define "about" }}
22
<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>
26
</section>
27
{{ end }}
28
29
{{ 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 }}
40
</div>
41
-
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
42
-
</section>
43
{{ end }}
44
45
{{ 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>
78
79
-
<div id="register-error" class="dark:text-red-400"></div>
80
-
</form>
81
82
-
</section>
83
{{ end }}
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
3
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "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>
28
</div>
29
30
+
<section>
31
<div class="flex flex-col gap-6">
32
{{ block "list" . }} {{ end }}
33
{{ block "register" . }} {{ end }}
34
</div>
···
37
38
{{ define "about" }}
39
<section class="rounded flex items-center gap-2">
40
+
<p class="text-gray-500 dark:text-gray-400">
41
+
Spindles are small CI runners.
42
+
</p>
43
</section>
44
{{ end }}
45
46
{{ define "list" }}
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
55
</div>
56
+
{{ end }}
57
+
</div>
58
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
59
+
</section>
60
{{ end }}
61
62
{{ define "register" }}
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>
95
96
+
<div id="register-error" class="dark:text-red-400"></div>
97
+
</form>
98
+
99
+
</section>
100
+
{{ end }}
101
102
+
{{ define "docsButton" }}
103
+
<a
104
+
class="btn flex items-center gap-2"
105
+
href="https://docs.tangled.org/spindles.html#self-hosting-guide">
106
+
{{ i "book" "size-4" }}
107
+
docs
108
+
</a>
109
+
<div
110
+
id="add-email-modal"
111
+
popover
112
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
113
+
</div>
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 }}
2
3
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
<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 }}" />
8
{{ end }}
9
10
···
35
{{ $s := index . 1 }}
36
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
<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
</div>
40
{{ with $s.Description }}
41
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
1
+
{{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }}
2
3
{{ define "extrameta" }}
4
+
{{ $handle := resolve .Card.UserDid }}
5
+
<meta property="og:title" content="{{ $handle }}" />
6
<meta property="og:type" content="profile" />
7
+
<meta property="og:url" content="https://tangled.org/{{ $handle }}" />
8
+
<meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" />
9
{{ end }}
10
11
···
36
{{ $s := index . 1 }}
37
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
38
<div class="font-medium dark:text-white flex gap-2 items-center">
39
+
<a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
40
</div>
41
{{ with $s.Description }}
42
<div class="text-gray-600 dark:text-gray-300 text-sm">
+11
-7
appview/pages/templates/strings/string.html
+11
-7
appview/pages/templates/strings/string.html
···
1
-
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
2
3
{{ define "extrameta" }}
4
-
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
<meta property="og:type" content="object" />
7
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
···
9
{{ end }}
10
11
{{ define "content" }}
12
-
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
13
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
14
<div class="text-lg flex items-center justify-between">
15
<div>
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
21
-
<div class="flex gap-2 text-base">
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
37
<span class="hidden md:inline">delete</span>
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
</button>
40
-
</div>
41
-
{{ end }}
42
</div>
43
<span>
44
{{ with .String.Description }}
···
1
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ resolve .Owner.DID.String }}{{ end }}
2
3
{{ define "extrameta" }}
4
+
{{ $ownerId := resolve .Owner.DID.String }}
5
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
<meta property="og:type" content="object" />
7
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
···
9
{{ end }}
10
11
{{ define "content" }}
12
+
{{ $ownerId := resolve .Owner.DID.String }}
13
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
14
<div class="text-lg flex items-center justify-between">
15
<div>
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
+
<div class="flex gap-2 items-stretch text-base">
21
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
37
<span class="hidden md:inline">delete</span>
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
</button>
40
+
{{ end }}
41
+
{{ template "fragments/starBtn"
42
+
(dict "SubjectAt" .String.AtUri
43
+
"IsStarred" .IsStarred
44
+
"StarCount" .StarCount) }}
45
+
</div>
46
</div>
47
<span>
48
{{ with .String.Description }}
+1
-2
appview/pages/templates/timeline/fragments/goodfirstissues.html
+1
-2
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
3
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
<div class="flex-1 flex flex-col gap-2">
6
-
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
<p>
8
-
Make your first contribution to an open-source project this October.
9
<em>good-first-issue</em> helps new contributors find easy ways to
10
start contributing to open-source projects.
11
</p>
···
3
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
<div class="flex-1 flex flex-col gap-2">
6
<p>
7
+
Make your first contribution to an open-source project.
8
<em>good-first-issue</em> helps new contributors find easy ways to
9
start contributing to open-source projects.
10
</p>
+5
-5
appview/pages/templates/timeline/fragments/timeline.html
+5
-5
appview/pages/templates/timeline/fragments/timeline.html
···
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
{{ if .Repo }}
16
{{ template "timeline/fragments/repoEvent" (list $ .) }}
17
-
{{ else if .Star }}
18
{{ template "timeline/fragments/starEvent" (list $ .) }}
19
{{ else if .Follow }}
20
{{ template "timeline/fragments/followEvent" (list $ .) }}
···
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
</div>
54
{{ with $repo }}
55
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
56
{{ end }}
57
{{ end }}
58
59
{{ define "timeline/fragments/starEvent" }}
60
{{ $root := index . 0 }}
61
{{ $event := index . 1 }}
62
-
{{ $star := $event.Star }}
63
{{ with $star }}
64
-
{{ $starrerHandle := resolve .StarredByDid }}
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
66
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
67
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
</div>
74
{{ with .Repo }}
75
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
76
{{ end }}
77
{{ end }}
78
{{ end }}
···
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
{{ if .Repo }}
16
{{ template "timeline/fragments/repoEvent" (list $ .) }}
17
+
{{ else if .RepoStar }}
18
{{ template "timeline/fragments/starEvent" (list $ .) }}
19
{{ else if .Follow }}
20
{{ template "timeline/fragments/followEvent" (list $ .) }}
···
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
</div>
54
{{ with $repo }}
55
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
56
{{ end }}
57
{{ end }}
58
59
{{ define "timeline/fragments/starEvent" }}
60
{{ $root := index . 0 }}
61
{{ $event := index . 1 }}
62
+
{{ $star := $event.RepoStar }}
63
{{ with $star }}
64
+
{{ $starrerHandle := resolve .Did }}
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
66
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
67
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
</div>
74
{{ with .Repo }}
75
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
76
{{ end }}
77
{{ end }}
78
{{ end }}
+4
-2
appview/pages/templates/user/followers.html
+4
-2
appview/pages/templates/user/followers.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
···
19
"FollowersCount" .FollowersCount
20
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
-
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
23
{{ end }}
24
</div>
25
{{ end }}
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท followers {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
···
19
"FollowersCount" .FollowersCount
20
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
23
+
<span>This user does not have any followers yet.</span>
24
+
</div>
25
{{ end }}
26
</div>
27
{{ end }}
+4
-2
appview/pages/templates/user/following.html
+4
-2
appview/pages/templates/user/following.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
···
19
"FollowersCount" .FollowersCount
20
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
-
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
23
{{ end }}
24
</div>
25
{{ end }}
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท following {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
···
19
"FollowersCount" .FollowersCount
20
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
23
+
<span>This user does not follow anyone yet.</span>
24
+
</div>
25
{{ end }}
26
</div>
27
{{ end }}
+2
-2
appview/pages/templates/user/fragments/followCard.html
+2
-2
appview/pages/templates/user/fragments/followCard.html
···
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
</div>
8
9
-
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
<a href="/{{ $userIdent }}">
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
</a>
14
{{ with .Profile }}
15
-
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
{{ end }}
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
···
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
</div>
8
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0">
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
<a href="/{{ $userIdent }}">
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
</a>
14
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p>
16
{{ end }}
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+1
-1
appview/pages/templates/user/fragments/profileCard.html
+1
-1
appview/pages/templates/user/fragments/profileCard.html
+2
-1
appview/pages/templates/user/fragments/repoCard.html
+2
-1
appview/pages/templates/user/fragments/repoCard.html
···
1
{{ define "user/fragments/repoCard" }}
2
{{ $root := index . 0 }}
3
{{ $repo := index . 1 }}
4
{{ $fullName := index . 2 }}
···
29
</div>
30
{{ if and $starButton $root.LoggedInUser }}
31
<div class="shrink-0">
32
-
{{ template "repo/fragments/repoStar" $starData }}
33
</div>
34
{{ end }}
35
</div>
···
1
{{ define "user/fragments/repoCard" }}
2
+
{{/* root, repo, fullName [,starButton [,starData]] */}}
3
{{ $root := index . 0 }}
4
{{ $repo := index . 1 }}
5
{{ $fullName := index . 2 }}
···
30
</div>
31
{{ if and $starButton $root.LoggedInUser }}
32
<div class="shrink-0">
33
+
{{ template "fragments/starBtn" $starData }}
34
</div>
35
{{ end }}
36
</div>
+22
-4
appview/pages/templates/user/overview.html
+22
-4
appview/pages/templates/user/overview.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
···
16
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
17
<div class="flex flex-col gap-4 relative">
18
{{ if .ProfileTimeline.IsEmpty }}
19
-
<p class="dark:text-white">This user does not have any activity yet.</p>
20
{{ end }}
21
22
{{ with .ProfileTimeline }}
···
33
</p>
34
35
<div class="flex flex-col gap-1">
36
{{ block "repoEvents" .RepoEvents }} {{ end }}
37
{{ block "issueEvents" .IssueEvents }} {{ end }}
38
{{ block "pullEvents" .PullEvents }} {{ end }}
···
43
{{ end }}
44
{{ end }}
45
</div>
46
{{ end }}
47
48
{{ define "repoEvents" }}
···
224
{{ define "ownRepos" }}
225
<div>
226
<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"
228
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
229
<span>PINNED REPOS</span>
230
</a>
···
244
{{ template "user/fragments/repoCard" (list $ . false) }}
245
</div>
246
{{ else }}
247
-
<p class="dark:text-white">This user does not have any pinned repos.</p>
248
{{ end }}
249
</div>
250
</div>
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }}{{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
···
16
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
17
<div class="flex flex-col gap-4 relative">
18
{{ if .ProfileTimeline.IsEmpty }}
19
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
20
+
<span class="flex items-center gap-2">
21
+
This user does not have any activity yet.
22
+
</span>
23
+
</div>
24
{{ end }}
25
26
{{ with .ProfileTimeline }}
···
37
</p>
38
39
<div class="flex flex-col gap-1">
40
+
{{ block "commits" .Commits }} {{ end }}
41
{{ block "repoEvents" .RepoEvents }} {{ end }}
42
{{ block "issueEvents" .IssueEvents }} {{ end }}
43
{{ block "pullEvents" .PullEvents }} {{ end }}
···
48
{{ end }}
49
{{ end }}
50
</div>
51
+
{{ end }}
52
+
53
+
{{ define "commits" }}
54
+
{{ if . }}
55
+
<div class="flex flex-wrap items-center gap-1">
56
+
{{ i "git-commit-horizontal" "size-5" }}
57
+
created {{ . }} commits
58
+
</div>
59
+
{{ end }}
60
{{ end }}
61
62
{{ define "repoEvents" }}
···
238
{{ define "ownRepos" }}
239
<div>
240
<div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2">
241
+
<a href="/{{ resolve $.Card.UserDid }}?tab=repos"
242
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
243
<span>PINNED REPOS</span>
244
</a>
···
258
{{ template "user/fragments/repoCard" (list $ . false) }}
259
</div>
260
{{ else }}
261
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
262
+
<span class="flex items-center gap-2">
263
+
This user does not have any pinned repos.
264
+
</span>
265
+
</div>
266
{{ end }}
267
</div>
268
</div>
+4
-2
appview/pages/templates/user/repos.html
+4
-2
appview/pages/templates/user/repos.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
···
13
{{ template "user/fragments/repoCard" (list $ . false) }}
14
</div>
15
{{ else }}
16
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
17
{{ end }}
18
</div>
19
{{ end }}
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
···
13
{{ template "user/fragments/repoCard" (list $ . false) }}
14
</div>
15
{{ else }}
16
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
17
+
<span>This user does not have any repos yet.</span>
18
+
</div>
19
{{ end }}
20
</div>
21
{{ end }}
+1
-1
appview/pages/templates/user/settings/notifications.html
+1
-1
appview/pages/templates/user/settings/notifications.html
+9
-6
appview/pages/templates/user/signup.html
+9
-6
appview/pages/templates/user/signup.html
···
43
page to complete your registration.
44
</span>
45
<div class="w-full mt-4 text-center">
46
-
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
</div>
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
<span>join now</span>
50
</button>
51
</form>
52
-
<p class="text-sm text-gray-500">
53
-
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
54
-
</p>
55
-
56
-
<p id="signup-msg" class="error w-full"></p>
57
</main>
58
</body>
59
</html>
···
43
page to complete your registration.
44
</span>
45
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div>
47
</div>
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
<span>join now</span>
50
</button>
51
+
<p class="text-sm text-gray-500">
52
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
53
+
</p>
54
+
55
+
<p id="signup-msg" class="error w-full"></p>
56
+
<p class="text-sm text-gray-500 pt-4">
57
+
By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>.
58
+
</p>
59
</form>
60
</main>
61
</body>
62
</html>
+4
-2
appview/pages/templates/user/starred.html
+4
-2
appview/pages/templates/user/starred.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
···
13
{{ template "user/fragments/repoCard" (list $ . true) }}
14
</div>
15
{{ else }}
16
-
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
17
{{ end }}
18
</div>
19
{{ end }}
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
···
13
{{ template "user/fragments/repoCard" (list $ . true) }}
14
</div>
15
{{ else }}
16
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
17
+
<span>This user does not have any starred repos yet.</span>
18
+
</div>
19
{{ end }}
20
</div>
21
{{ end }}
+5
-3
appview/pages/templates/user/strings.html
+5
-3
appview/pages/templates/user/strings.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
···
13
{{ template "singleString" (list $ .) }}
14
</div>
15
{{ else }}
16
-
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
17
{{ end }}
18
</div>
19
{{ end }}
···
23
{{ $s := index . 1 }}
24
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
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>
27
</div>
28
{{ with $s.Description }}
29
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
1
+
{{ define "title" }}{{ resolve .Card.UserDid }} ยท strings {{ end }}
2
3
{{ define "profileContent" }}
4
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
···
13
{{ template "singleString" (list $ .) }}
14
</div>
15
{{ else }}
16
+
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
17
+
<span>This user does not have any strings yet.</span>
18
+
</div>
19
{{ end }}
20
</div>
21
{{ end }}
···
25
{{ $s := index . 1 }}
26
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
27
<div class="font-medium dark:text-white flex gap-2 items-center">
28
+
<a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
29
</div>
30
{{ with $s.Description }}
31
<div class="text-gray-600 dark:text-gray-300 text-sm">
+16
-22
appview/pipelines/pipelines.go
+16
-22
appview/pipelines/pipelines.go
···
16
"tangled.org/core/appview/reporesolver"
17
"tangled.org/core/eventconsumer"
18
"tangled.org/core/idresolver"
19
"tangled.org/core/rbac"
20
spindlemodel "tangled.org/core/spindle/models"
21
···
78
return
79
}
80
81
-
repoInfo := f.RepoInfo(user)
82
-
83
ps, err := db.GetPipelineStatuses(
84
p.db,
85
30,
86
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
87
-
db.FilterEq("repo_name", repoInfo.Name),
88
-
db.FilterEq("knot", repoInfo.Knot),
89
)
90
if err != nil {
91
l.Error("failed to query db", "err", err)
···
94
95
p.pages.Pipelines(w, pages.PipelinesParams{
96
LoggedInUser: user,
97
-
RepoInfo: repoInfo,
98
Pipelines: ps,
99
})
100
}
···
108
l.Error("failed to get repo and knot", "err", err)
109
return
110
}
111
-
112
-
repoInfo := f.RepoInfo(user)
113
114
pipelineId := chi.URLParam(r, "pipeline")
115
if pipelineId == "" {
···
126
ps, err := db.GetPipelineStatuses(
127
p.db,
128
1,
129
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
130
-
db.FilterEq("repo_name", repoInfo.Name),
131
-
db.FilterEq("knot", repoInfo.Knot),
132
-
db.FilterEq("id", pipelineId),
133
)
134
if err != nil {
135
l.Error("failed to query db", "err", err)
···
145
146
p.pages.Workflow(w, pages.WorkflowParams{
147
LoggedInUser: user,
148
-
RepoInfo: repoInfo,
149
Pipeline: singlePipeline,
150
Workflow: workflow,
151
})
···
176
ctx, cancel := context.WithCancel(r.Context())
177
defer cancel()
178
179
-
user := p.oauth.GetUser(r)
180
f, err := p.repoResolver.Resolve(r)
181
if err != nil {
182
l.Error("failed to get repo and knot", "err", err)
···
184
return
185
}
186
187
-
repoInfo := f.RepoInfo(user)
188
-
189
pipelineId := chi.URLParam(r, "pipeline")
190
workflow := chi.URLParam(r, "workflow")
191
if pipelineId == "" || workflow == "" {
···
196
ps, err := db.GetPipelineStatuses(
197
p.db,
198
1,
199
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
200
-
db.FilterEq("repo_name", repoInfo.Name),
201
-
db.FilterEq("knot", repoInfo.Knot),
202
-
db.FilterEq("id", pipelineId),
203
)
204
if err != nil || len(ps) != 1 {
205
l.Error("pipeline query failed", "err", err, "count", len(ps))
···
208
}
209
210
singlePipeline := ps[0]
211
-
spindle := repoInfo.Spindle
212
-
knot := repoInfo.Knot
213
rkey := singlePipeline.Rkey
214
215
if spindle == "" || knot == "" || rkey == "" {
···
16
"tangled.org/core/appview/reporesolver"
17
"tangled.org/core/eventconsumer"
18
"tangled.org/core/idresolver"
19
+
"tangled.org/core/orm"
20
"tangled.org/core/rbac"
21
spindlemodel "tangled.org/core/spindle/models"
22
···
79
return
80
}
81
82
ps, err := db.GetPipelineStatuses(
83
p.db,
84
30,
85
+
orm.FilterEq("repo_owner", f.Did),
86
+
orm.FilterEq("repo_name", f.Name),
87
+
orm.FilterEq("knot", f.Knot),
88
)
89
if err != nil {
90
l.Error("failed to query db", "err", err)
···
93
94
p.pages.Pipelines(w, pages.PipelinesParams{
95
LoggedInUser: user,
96
+
RepoInfo: p.repoResolver.GetRepoInfo(r, user),
97
Pipelines: ps,
98
})
99
}
···
107
l.Error("failed to get repo and knot", "err", err)
108
return
109
}
110
111
pipelineId := chi.URLParam(r, "pipeline")
112
if pipelineId == "" {
···
123
ps, err := db.GetPipelineStatuses(
124
p.db,
125
1,
126
+
orm.FilterEq("repo_owner", f.Did),
127
+
orm.FilterEq("repo_name", f.Name),
128
+
orm.FilterEq("knot", f.Knot),
129
+
orm.FilterEq("id", pipelineId),
130
)
131
if err != nil {
132
l.Error("failed to query db", "err", err)
···
142
143
p.pages.Workflow(w, pages.WorkflowParams{
144
LoggedInUser: user,
145
+
RepoInfo: p.repoResolver.GetRepoInfo(r, user),
146
Pipeline: singlePipeline,
147
Workflow: workflow,
148
})
···
173
ctx, cancel := context.WithCancel(r.Context())
174
defer cancel()
175
176
f, err := p.repoResolver.Resolve(r)
177
if err != nil {
178
l.Error("failed to get repo and knot", "err", err)
···
180
return
181
}
182
183
pipelineId := chi.URLParam(r, "pipeline")
184
workflow := chi.URLParam(r, "workflow")
185
if pipelineId == "" || workflow == "" {
···
190
ps, err := db.GetPipelineStatuses(
191
p.db,
192
1,
193
+
orm.FilterEq("repo_owner", f.Did),
194
+
orm.FilterEq("repo_name", f.Name),
195
+
orm.FilterEq("knot", f.Knot),
196
+
orm.FilterEq("id", pipelineId),
197
)
198
if err != nil || len(ps) != 1 {
199
l.Error("pipeline query failed", "err", err, "count", len(ps))
···
202
}
203
204
singlePipeline := ps[0]
205
+
spindle := f.Spindle
206
+
knot := f.Knot
207
rkey := singlePipeline.Rkey
208
209
if spindle == "" || knot == "" || rkey == "" {
+4
-3
appview/pulls/opengraph.go
+4
-3
appview/pulls/opengraph.go
···
13
"tangled.org/core/appview/db"
14
"tangled.org/core/appview/models"
15
"tangled.org/core/appview/ogcard"
16
"tangled.org/core/patchutil"
17
"tangled.org/core/types"
18
)
···
241
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
245
if err != nil {
246
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
}
···
276
}
277
278
// Get comment count from database
279
-
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
if err != nil {
281
log.Printf("failed to get pull comments: %v", err)
282
}
···
293
filesChanged = niceDiff.Stat.FilesChanged
294
}
295
296
-
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297
if err != nil {
298
log.Println("failed to draw pull summary card", err)
299
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
···
13
"tangled.org/core/appview/db"
14
"tangled.org/core/appview/models"
15
"tangled.org/core/appview/ogcard"
16
+
"tangled.org/core/orm"
17
"tangled.org/core/patchutil"
18
"tangled.org/core/types"
19
)
···
242
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
243
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
244
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
245
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
246
if err != nil {
247
log.Printf("dolly silhouette not available (this is ok): %v", err)
248
}
···
277
}
278
279
// Get comment count from database
280
+
comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID))
281
if err != nil {
282
log.Printf("failed to get pull comments: %v", err)
283
}
···
294
filesChanged = niceDiff.Stat.FilesChanged
295
}
296
297
+
card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged)
298
if err != nil {
299
log.Println("failed to draw pull summary card", err)
300
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+193
-177
appview/pulls/pulls.go
+193
-177
appview/pulls/pulls.go
···
1
package pulls
2
3
import (
4
"database/sql"
5
"encoding/json"
6
"errors"
···
18
"tangled.org/core/appview/config"
19
"tangled.org/core/appview/db"
20
pulls_indexer "tangled.org/core/appview/indexer/pulls"
21
"tangled.org/core/appview/models"
22
"tangled.org/core/appview/notify"
23
"tangled.org/core/appview/oauth"
24
"tangled.org/core/appview/pages"
25
"tangled.org/core/appview/pages/markup"
26
"tangled.org/core/appview/reporesolver"
27
"tangled.org/core/appview/validator"
28
"tangled.org/core/appview/xrpcclient"
29
"tangled.org/core/idresolver"
30
"tangled.org/core/patchutil"
31
"tangled.org/core/rbac"
32
"tangled.org/core/tid"
···
41
)
42
43
type Pulls struct {
44
-
oauth *oauth.OAuth
45
-
repoResolver *reporesolver.RepoResolver
46
-
pages *pages.Pages
47
-
idResolver *idresolver.Resolver
48
-
db *db.DB
49
-
config *config.Config
50
-
notifier notify.Notifier
51
-
enforcer *rbac.Enforcer
52
-
logger *slog.Logger
53
-
validator *validator.Validator
54
-
indexer *pulls_indexer.Indexer
55
}
56
57
func New(
···
59
repoResolver *reporesolver.RepoResolver,
60
pages *pages.Pages,
61
resolver *idresolver.Resolver,
62
db *db.DB,
63
config *config.Config,
64
notifier notify.Notifier,
···
68
logger *slog.Logger,
69
) *Pulls {
70
return &Pulls{
71
-
oauth: oauth,
72
-
repoResolver: repoResolver,
73
-
pages: pages,
74
-
idResolver: resolver,
75
-
db: db,
76
-
config: config,
77
-
notifier: notifier,
78
-
enforcer: enforcer,
79
-
logger: logger,
80
-
validator: validator,
81
-
indexer: indexer,
82
}
83
}
84
···
123
124
s.pages.PullActionsFragment(w, pages.PullActionsParams{
125
LoggedInUser: user,
126
-
RepoInfo: f.RepoInfo(user),
127
Pull: pull,
128
RoundNumber: roundNumber,
129
MergeCheck: mergeCheckResponse,
···
150
return
151
}
152
153
// can be nil if this pull is not stacked
154
stack, _ := r.Context().Value("stack").(models.Stack)
155
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
···
160
if user != nil && user.Did == pull.OwnerDid {
161
resubmitResult = s.resubmitCheck(r, f, pull, stack)
162
}
163
-
164
-
repoInfo := f.RepoInfo(user)
165
166
m := make(map[string]models.Pipeline)
167
···
179
ps, err := db.GetPipelineStatuses(
180
s.db,
181
len(shas),
182
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
183
-
db.FilterEq("repo_name", repoInfo.Name),
184
-
db.FilterEq("knot", repoInfo.Knot),
185
-
db.FilterIn("sha", shas),
186
)
187
if err != nil {
188
log.Printf("failed to fetch pipeline statuses: %s", err)
···
206
207
labelDefs, err := db.GetLabelDefinitions(
208
s.db,
209
-
db.FilterIn("at_uri", f.Repo.Labels),
210
-
db.FilterContains("scope", tangled.RepoPullNSID),
211
)
212
if err != nil {
213
log.Println("failed to fetch labels", err)
···
222
223
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
224
LoggedInUser: user,
225
-
RepoInfo: repoInfo,
226
Pull: pull,
227
Stack: stack,
228
AbandonedPulls: abandonedPulls,
229
BranchDeleteStatus: branchDeleteStatus,
230
MergeCheck: mergeCheckResponse,
231
ResubmitCheck: resubmitResult,
···
239
})
240
}
241
242
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
243
if pull.State == models.PullMerged {
244
return types.MergeCheckResponse{}
245
}
···
268
r.Context(),
269
&xrpcc,
270
&tangled.RepoMergeCheck_Input{
271
-
Did: f.OwnerDid(),
272
Name: f.Name,
273
Branch: pull.TargetBranch,
274
Patch: patch,
···
306
return result
307
}
308
309
-
func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
310
if pull.State != models.PullMerged {
311
return nil
312
}
···
317
}
318
319
var branch string
320
-
var repo *models.Repo
321
// check if the branch exists
322
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
323
if pull.IsBranchBased() {
324
branch = pull.PullSource.Branch
325
-
repo = &f.Repo
326
} else if pull.IsForkBased() {
327
branch = pull.PullSource.Branch
328
repo = pull.PullSource.Repo
···
361
}
362
}
363
364
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
365
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
366
return pages.Unknown
367
}
···
381
repoName = sourceRepo.Name
382
} else {
383
// pulls within the same repo
384
-
knot = f.Knot
385
-
ownerDid = f.OwnerDid()
386
-
repoName = f.Name
387
}
388
389
scheme := "http"
···
395
Host: host,
396
}
397
398
-
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
399
-
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
400
if err != nil {
401
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
402
log.Println("failed to call XRPC repo.branches", xrpcerr)
···
424
425
func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
426
user := s.oauth.GetUser(r)
427
-
f, err := s.repoResolver.Resolve(r)
428
-
if err != nil {
429
-
log.Println("failed to get repo and knot", err)
430
-
return
431
-
}
432
433
var diffOpts types.DiffOpts
434
if d := r.URL.Query().Get("diff"); d == "split" {
···
457
458
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
459
LoggedInUser: user,
460
-
RepoInfo: f.RepoInfo(user),
461
Pull: pull,
462
Stack: stack,
463
Round: roundIdInt,
···
471
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
472
user := s.oauth.GetUser(r)
473
474
-
f, err := s.repoResolver.Resolve(r)
475
-
if err != nil {
476
-
log.Println("failed to get repo and knot", err)
477
-
return
478
-
}
479
-
480
var diffOpts types.DiffOpts
481
if d := r.URL.Query().Get("diff"); d == "split" {
482
diffOpts.Split = true
···
521
522
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
523
LoggedInUser: s.oauth.GetUser(r),
524
-
RepoInfo: f.RepoInfo(user),
525
Pull: pull,
526
Round: roundIdInt,
527
Interdiff: interdiff,
···
598
599
pulls, err := db.GetPulls(
600
s.db,
601
-
db.FilterIn("id", ids),
602
)
603
if err != nil {
604
log.Println("failed to get pulls", err)
···
646
}
647
pulls = pulls[:n]
648
649
-
repoInfo := f.RepoInfo(user)
650
ps, err := db.GetPipelineStatuses(
651
s.db,
652
len(shas),
653
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
654
-
db.FilterEq("repo_name", repoInfo.Name),
655
-
db.FilterEq("knot", repoInfo.Knot),
656
-
db.FilterIn("sha", shas),
657
)
658
if err != nil {
659
log.Printf("failed to fetch pipeline statuses: %s", err)
···
666
667
labelDefs, err := db.GetLabelDefinitions(
668
s.db,
669
-
db.FilterIn("at_uri", f.Repo.Labels),
670
-
db.FilterContains("scope", tangled.RepoPullNSID),
671
)
672
if err != nil {
673
log.Println("failed to fetch labels", err)
···
682
683
s.pages.RepoPulls(w, pages.RepoPullsParams{
684
LoggedInUser: s.oauth.GetUser(r),
685
-
RepoInfo: f.RepoInfo(user),
686
Pulls: pulls,
687
LabelDefs: defs,
688
FilteringBy: state,
···
693
}
694
695
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
696
-
l := s.logger.With("handler", "PullComment")
697
user := s.oauth.GetUser(r)
698
f, err := s.repoResolver.Resolve(r)
699
if err != nil {
···
720
case http.MethodGet:
721
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
722
LoggedInUser: user,
723
-
RepoInfo: f.RepoInfo(user),
724
Pull: pull,
725
RoundNumber: roundNumber,
726
})
···
731
s.pages.Notice(w, "pull", "Comment body is required")
732
return
733
}
734
735
// Start a transaction
736
tx, err := s.db.BeginTx(r.Context(), nil)
···
774
Body: body,
775
CommentAt: atResp.Uri,
776
SubmissionId: pull.Submissions[roundNumber].ID,
777
}
778
779
// Create the pull comment in the database with the commentAt field
···
791
return
792
}
793
794
-
rawMentions := markup.FindUserMentions(comment.Body)
795
-
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
796
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
797
-
var mentions []syntax.DID
798
-
for _, ident := range idents {
799
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
800
-
mentions = append(mentions, ident.DID)
801
-
}
802
-
}
803
s.notifier.NewPullComment(r.Context(), comment, mentions)
804
805
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
806
return
807
}
808
}
···
826
Host: host,
827
}
828
829
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
830
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
831
if err != nil {
832
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
853
854
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
855
LoggedInUser: user,
856
-
RepoInfo: f.RepoInfo(user),
857
Branches: result.Branches,
858
Strategy: strategy,
859
SourceBranch: sourceBranch,
···
876
}
877
878
// Determine PR type based on input parameters
879
-
isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed()
880
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
881
isForkBased := fromFork != "" && sourceBranch != ""
882
isPatchBased := patch != "" && !isBranchBased && !isForkBased
···
974
func (s *Pulls) handleBranchBasedPull(
975
w http.ResponseWriter,
976
r *http.Request,
977
-
f *reporesolver.ResolvedRepo,
978
user *oauth.User,
979
title,
980
body,
···
986
if !s.config.Core.Dev {
987
scheme = "https"
988
}
989
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
990
xrpcc := &indigoxrpc.Client{
991
Host: host,
992
}
993
994
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
995
-
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch)
996
if err != nil {
997
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
998
log.Println("failed to call XRPC repo.compare", xrpcerr)
···
1029
Sha: comparison.Rev2,
1030
}
1031
1032
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1033
}
1034
1035
-
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1036
if err := s.validator.ValidatePatch(&patch); err != nil {
1037
s.logger.Error("patch validation failed", "err", err)
1038
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1039
return
1040
}
1041
1042
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1043
}
1044
1045
-
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) {
1046
repoString := strings.SplitN(forkRepo, "/", 2)
1047
forkOwnerDid := repoString[0]
1048
repoName := repoString[1]
···
1144
Sha: sourceRev,
1145
}
1146
1147
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1148
}
1149
1150
func (s *Pulls) createPullRequest(
1151
w http.ResponseWriter,
1152
r *http.Request,
1153
-
f *reporesolver.ResolvedRepo,
1154
user *oauth.User,
1155
title, body, targetBranch string,
1156
patch string,
···
1165
s.createStackedPullRequest(
1166
w,
1167
r,
1168
-
f,
1169
user,
1170
targetBranch,
1171
patch,
···
1211
}
1212
}
1213
1214
rkey := tid.TID()
1215
initialSubmission := models.PullSubmission{
1216
Patch: patch,
···
1222
Body: body,
1223
TargetBranch: targetBranch,
1224
OwnerDid: user.Did,
1225
-
RepoAt: f.RepoAt(),
1226
Rkey: rkey,
1227
Submissions: []*models.PullSubmission{
1228
&initialSubmission,
1229
},
···
1235
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1236
return
1237
}
1238
-
pullId, err := db.NextPullId(tx, f.RepoAt())
1239
if err != nil {
1240
log.Println("failed to get pull id", err)
1241
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1242
return
1243
}
1244
1245
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1246
Collection: tangled.RepoPullNSID,
1247
Repo: user.Did,
···
1250
Val: &tangled.RepoPull{
1251
Title: title,
1252
Target: &tangled.RepoPull_Target{
1253
-
Repo: string(f.RepoAt()),
1254
Branch: targetBranch,
1255
},
1256
-
Patch: patch,
1257
Source: recordPullSource,
1258
CreatedAt: time.Now().Format(time.RFC3339),
1259
},
···
1273
1274
s.notifier.NewPull(r.Context(), pull)
1275
1276
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1277
}
1278
1279
func (s *Pulls) createStackedPullRequest(
1280
w http.ResponseWriter,
1281
r *http.Request,
1282
-
f *reporesolver.ResolvedRepo,
1283
user *oauth.User,
1284
targetBranch string,
1285
patch string,
···
1311
1312
// build a stack out of this patch
1313
stackId := uuid.New()
1314
-
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1315
if err != nil {
1316
log.Println("failed to create stack", err)
1317
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
···
1328
// apply all record creations at once
1329
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1330
for _, p := range stack {
1331
record := p.AsRecord()
1332
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1333
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1334
Collection: tangled.RepoPullNSID,
1335
Rkey: &p.Rkey,
···
1337
Val: &record,
1338
},
1339
},
1340
-
}
1341
-
writes = append(writes, &write)
1342
}
1343
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1344
Repo: user.Did,
···
1366
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1367
return
1368
}
1369
}
1370
1371
if err = tx.Commit(); err != nil {
···
1374
return
1375
}
1376
1377
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1378
}
1379
1380
func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
···
1405
1406
func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1407
user := s.oauth.GetUser(r)
1408
-
f, err := s.repoResolver.Resolve(r)
1409
-
if err != nil {
1410
-
log.Println("failed to get repo and knot", err)
1411
-
return
1412
-
}
1413
1414
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1415
-
RepoInfo: f.RepoInfo(user),
1416
})
1417
}
1418
···
1433
Host: host,
1434
}
1435
1436
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1437
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1438
if err != nil {
1439
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1466
}
1467
1468
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1469
-
RepoInfo: f.RepoInfo(user),
1470
Branches: withoutDefault,
1471
})
1472
}
1473
1474
func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1475
user := s.oauth.GetUser(r)
1476
-
f, err := s.repoResolver.Resolve(r)
1477
-
if err != nil {
1478
-
log.Println("failed to get repo and knot", err)
1479
-
return
1480
-
}
1481
1482
forks, err := db.GetForksByDid(s.db, user.Did)
1483
if err != nil {
···
1486
}
1487
1488
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1489
-
RepoInfo: f.RepoInfo(user),
1490
Forks: forks,
1491
Selected: r.URL.Query().Get("fork"),
1492
})
···
1508
// fork repo
1509
repo, err := db.GetRepo(
1510
s.db,
1511
-
db.FilterEq("did", forkOwnerDid),
1512
-
db.FilterEq("name", forkName),
1513
)
1514
if err != nil {
1515
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
···
1554
Host: targetHost,
1555
}
1556
1557
-
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1558
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1559
if err != nil {
1560
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1579
})
1580
1581
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1582
-
RepoInfo: f.RepoInfo(user),
1583
SourceBranches: sourceBranches.Branches,
1584
TargetBranches: targetBranches.Branches,
1585
})
···
1587
1588
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1589
user := s.oauth.GetUser(r)
1590
-
f, err := s.repoResolver.Resolve(r)
1591
-
if err != nil {
1592
-
log.Println("failed to get repo and knot", err)
1593
-
return
1594
-
}
1595
1596
pull, ok := r.Context().Value("pull").(*models.Pull)
1597
if !ok {
···
1603
switch r.Method {
1604
case http.MethodGet:
1605
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1606
-
RepoInfo: f.RepoInfo(user),
1607
Pull: pull,
1608
})
1609
return
···
1670
return
1671
}
1672
1673
-
if !f.RepoInfo(user).Roles.IsPushAllowed() {
1674
log.Println("unauthorized user")
1675
w.WriteHeader(http.StatusUnauthorized)
1676
return
···
1685
Host: host,
1686
}
1687
1688
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1689
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1690
if err != nil {
1691
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1812
func (s *Pulls) resubmitPullHelper(
1813
w http.ResponseWriter,
1814
r *http.Request,
1815
-
f *reporesolver.ResolvedRepo,
1816
user *oauth.User,
1817
pull *models.Pull,
1818
patch string,
···
1821
) {
1822
if pull.IsStacked() {
1823
log.Println("resubmitting stacked PR")
1824
-
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1825
return
1826
}
1827
···
1876
return
1877
}
1878
1879
-
var recordPullSource *tangled.RepoPull_Source
1880
-
if pull.IsBranchBased() {
1881
-
recordPullSource = &tangled.RepoPull_Source{
1882
-
Branch: pull.PullSource.Branch,
1883
-
Sha: sourceRev,
1884
-
}
1885
}
1886
-
if pull.IsForkBased() {
1887
-
repoAt := pull.PullSource.RepoAt.String()
1888
-
recordPullSource = &tangled.RepoPull_Source{
1889
-
Branch: pull.PullSource.Branch,
1890
-
Repo: &repoAt,
1891
-
Sha: sourceRev,
1892
-
}
1893
-
}
1894
1895
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1896
Collection: tangled.RepoPullNSID,
···
1898
Rkey: pull.Rkey,
1899
SwapRecord: ex.Cid,
1900
Record: &lexutil.LexiconTypeDecoder{
1901
-
Val: &tangled.RepoPull{
1902
-
Title: pull.Title,
1903
-
Target: &tangled.RepoPull_Target{
1904
-
Repo: string(f.RepoAt()),
1905
-
Branch: pull.TargetBranch,
1906
-
},
1907
-
Patch: patch, // new patch
1908
-
Source: recordPullSource,
1909
-
CreatedAt: time.Now().Format(time.RFC3339),
1910
-
},
1911
},
1912
})
1913
if err != nil {
···
1922
return
1923
}
1924
1925
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1926
}
1927
1928
func (s *Pulls) resubmitStackedPullHelper(
1929
w http.ResponseWriter,
1930
r *http.Request,
1931
-
f *reporesolver.ResolvedRepo,
1932
user *oauth.User,
1933
pull *models.Pull,
1934
patch string,
···
1937
targetBranch := pull.TargetBranch
1938
1939
origStack, _ := r.Context().Value("stack").(models.Stack)
1940
-
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1941
if err != nil {
1942
log.Println("failed to create resubmitted stack", err)
1943
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1992
}
1993
defer tx.Rollback()
1994
1995
// pds updates to make
1996
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1997
···
2025
return
2026
}
2027
2028
record := p.AsRecord()
2029
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2030
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2031
Collection: tangled.RepoPullNSID,
···
2060
return
2061
}
2062
2063
record := np.AsRecord()
2064
-
2065
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2066
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2067
Collection: tangled.RepoPullNSID,
···
2079
tx,
2080
p.ParentChangeId,
2081
// these should be enough filters to be unique per-stack
2082
-
db.FilterEq("repo_at", p.RepoAt.String()),
2083
-
db.FilterEq("owner_did", p.OwnerDid),
2084
-
db.FilterEq("change_id", p.ChangeId),
2085
)
2086
2087
if err != nil {
···
2098
return
2099
}
2100
2101
-
client, err := s.oauth.AuthorizedClient(r)
2102
-
if err != nil {
2103
-
log.Println("failed to authorize client")
2104
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2105
-
return
2106
-
}
2107
-
2108
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2109
Repo: user.Did,
2110
Writes: writes,
···
2115
return
2116
}
2117
2118
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2119
}
2120
2121
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2168
2169
authorName := ident.Handle.String()
2170
mergeInput := &tangled.RepoMerge_Input{
2171
-
Did: f.OwnerDid(),
2172
Name: f.Name,
2173
Branch: pull.TargetBranch,
2174
Patch: patch,
···
2233
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2234
}
2235
2236
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2237
}
2238
2239
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2253
}
2254
2255
// auth filter: only owner or collaborators can close
2256
-
roles := f.RolesInRepo(user)
2257
isOwner := roles.IsOwner()
2258
isCollaborator := roles.IsCollaborator()
2259
isPullAuthor := user.Did == pull.OwnerDid
···
2305
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2306
}
2307
2308
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2309
}
2310
2311
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2326
}
2327
2328
// auth filter: only owner or collaborators can close
2329
-
roles := f.RolesInRepo(user)
2330
isOwner := roles.IsOwner()
2331
isCollaborator := roles.IsCollaborator()
2332
isPullAuthor := user.Did == pull.OwnerDid
···
2378
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2379
}
2380
2381
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2382
}
2383
2384
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2385
formatPatches, err := patchutil.ExtractPatches(patch)
2386
if err != nil {
2387
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2406
body := fp.Body
2407
rkey := tid.TID()
2408
2409
initialSubmission := models.PullSubmission{
2410
Patch: fp.Raw,
2411
SourceRev: fp.SHA,
···
2416
Body: body,
2417
TargetBranch: targetBranch,
2418
OwnerDid: user.Did,
2419
-
RepoAt: f.RepoAt(),
2420
Rkey: rkey,
2421
Submissions: []*models.PullSubmission{
2422
&initialSubmission,
2423
},
···
1
package pulls
2
3
import (
4
+
"context"
5
"database/sql"
6
"encoding/json"
7
"errors"
···
19
"tangled.org/core/appview/config"
20
"tangled.org/core/appview/db"
21
pulls_indexer "tangled.org/core/appview/indexer/pulls"
22
+
"tangled.org/core/appview/mentions"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/notify"
25
"tangled.org/core/appview/oauth"
26
"tangled.org/core/appview/pages"
27
"tangled.org/core/appview/pages/markup"
28
+
"tangled.org/core/appview/pages/repoinfo"
29
"tangled.org/core/appview/reporesolver"
30
"tangled.org/core/appview/validator"
31
"tangled.org/core/appview/xrpcclient"
32
"tangled.org/core/idresolver"
33
+
"tangled.org/core/orm"
34
"tangled.org/core/patchutil"
35
"tangled.org/core/rbac"
36
"tangled.org/core/tid"
···
45
)
46
47
type Pulls struct {
48
+
oauth *oauth.OAuth
49
+
repoResolver *reporesolver.RepoResolver
50
+
pages *pages.Pages
51
+
idResolver *idresolver.Resolver
52
+
mentionsResolver *mentions.Resolver
53
+
db *db.DB
54
+
config *config.Config
55
+
notifier notify.Notifier
56
+
enforcer *rbac.Enforcer
57
+
logger *slog.Logger
58
+
validator *validator.Validator
59
+
indexer *pulls_indexer.Indexer
60
}
61
62
func New(
···
64
repoResolver *reporesolver.RepoResolver,
65
pages *pages.Pages,
66
resolver *idresolver.Resolver,
67
+
mentionsResolver *mentions.Resolver,
68
db *db.DB,
69
config *config.Config,
70
notifier notify.Notifier,
···
74
logger *slog.Logger,
75
) *Pulls {
76
return &Pulls{
77
+
oauth: oauth,
78
+
repoResolver: repoResolver,
79
+
pages: pages,
80
+
idResolver: resolver,
81
+
mentionsResolver: mentionsResolver,
82
+
db: db,
83
+
config: config,
84
+
notifier: notifier,
85
+
enforcer: enforcer,
86
+
logger: logger,
87
+
validator: validator,
88
+
indexer: indexer,
89
}
90
}
91
···
130
131
s.pages.PullActionsFragment(w, pages.PullActionsParams{
132
LoggedInUser: user,
133
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
134
Pull: pull,
135
RoundNumber: roundNumber,
136
MergeCheck: mergeCheckResponse,
···
157
return
158
}
159
160
+
backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
161
+
if err != nil {
162
+
log.Println("failed to get pull backlinks", err)
163
+
s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
164
+
return
165
+
}
166
+
167
// can be nil if this pull is not stacked
168
stack, _ := r.Context().Value("stack").(models.Stack)
169
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
···
174
if user != nil && user.Did == pull.OwnerDid {
175
resubmitResult = s.resubmitCheck(r, f, pull, stack)
176
}
177
178
m := make(map[string]models.Pipeline)
179
···
191
ps, err := db.GetPipelineStatuses(
192
s.db,
193
len(shas),
194
+
orm.FilterEq("repo_owner", f.Did),
195
+
orm.FilterEq("repo_name", f.Name),
196
+
orm.FilterEq("knot", f.Knot),
197
+
orm.FilterIn("sha", shas),
198
)
199
if err != nil {
200
log.Printf("failed to fetch pipeline statuses: %s", err)
···
218
219
labelDefs, err := db.GetLabelDefinitions(
220
s.db,
221
+
orm.FilterIn("at_uri", f.Labels),
222
+
orm.FilterContains("scope", tangled.RepoPullNSID),
223
)
224
if err != nil {
225
log.Println("failed to fetch labels", err)
···
234
235
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
236
LoggedInUser: user,
237
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
238
Pull: pull,
239
Stack: stack,
240
AbandonedPulls: abandonedPulls,
241
+
Backlinks: backlinks,
242
BranchDeleteStatus: branchDeleteStatus,
243
MergeCheck: mergeCheckResponse,
244
ResubmitCheck: resubmitResult,
···
252
})
253
}
254
255
+
func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
256
if pull.State == models.PullMerged {
257
return types.MergeCheckResponse{}
258
}
···
281
r.Context(),
282
&xrpcc,
283
&tangled.RepoMergeCheck_Input{
284
+
Did: f.Did,
285
Name: f.Name,
286
Branch: pull.TargetBranch,
287
Patch: patch,
···
319
return result
320
}
321
322
+
func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
323
if pull.State != models.PullMerged {
324
return nil
325
}
···
330
}
331
332
var branch string
333
// check if the branch exists
334
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
335
if pull.IsBranchBased() {
336
branch = pull.PullSource.Branch
337
} else if pull.IsForkBased() {
338
branch = pull.PullSource.Branch
339
repo = pull.PullSource.Repo
···
372
}
373
}
374
375
+
func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
376
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
377
return pages.Unknown
378
}
···
392
repoName = sourceRepo.Name
393
} else {
394
// pulls within the same repo
395
+
knot = repo.Knot
396
+
ownerDid = repo.Did
397
+
repoName = repo.Name
398
}
399
400
scheme := "http"
···
406
Host: host,
407
}
408
409
+
didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName)
410
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName)
411
if err != nil {
412
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
413
log.Println("failed to call XRPC repo.branches", xrpcerr)
···
435
436
func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
437
user := s.oauth.GetUser(r)
438
439
var diffOpts types.DiffOpts
440
if d := r.URL.Query().Get("diff"); d == "split" {
···
463
464
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
465
LoggedInUser: user,
466
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
467
Pull: pull,
468
Stack: stack,
469
Round: roundIdInt,
···
477
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
478
user := s.oauth.GetUser(r)
479
480
var diffOpts types.DiffOpts
481
if d := r.URL.Query().Get("diff"); d == "split" {
482
diffOpts.Split = true
···
521
522
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
523
LoggedInUser: s.oauth.GetUser(r),
524
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
525
Pull: pull,
526
Round: roundIdInt,
527
Interdiff: interdiff,
···
598
599
pulls, err := db.GetPulls(
600
s.db,
601
+
orm.FilterIn("id", ids),
602
)
603
if err != nil {
604
log.Println("failed to get pulls", err)
···
646
}
647
pulls = pulls[:n]
648
649
ps, err := db.GetPipelineStatuses(
650
s.db,
651
len(shas),
652
+
orm.FilterEq("repo_owner", f.Did),
653
+
orm.FilterEq("repo_name", f.Name),
654
+
orm.FilterEq("knot", f.Knot),
655
+
orm.FilterIn("sha", shas),
656
)
657
if err != nil {
658
log.Printf("failed to fetch pipeline statuses: %s", err)
···
665
666
labelDefs, err := db.GetLabelDefinitions(
667
s.db,
668
+
orm.FilterIn("at_uri", f.Labels),
669
+
orm.FilterContains("scope", tangled.RepoPullNSID),
670
)
671
if err != nil {
672
log.Println("failed to fetch labels", err)
···
681
682
s.pages.RepoPulls(w, pages.RepoPullsParams{
683
LoggedInUser: s.oauth.GetUser(r),
684
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
685
Pulls: pulls,
686
LabelDefs: defs,
687
FilteringBy: state,
···
692
}
693
694
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
695
user := s.oauth.GetUser(r)
696
f, err := s.repoResolver.Resolve(r)
697
if err != nil {
···
718
case http.MethodGet:
719
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
720
LoggedInUser: user,
721
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
722
Pull: pull,
723
RoundNumber: roundNumber,
724
})
···
729
s.pages.Notice(w, "pull", "Comment body is required")
730
return
731
}
732
+
733
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
734
735
// Start a transaction
736
tx, err := s.db.BeginTx(r.Context(), nil)
···
774
Body: body,
775
CommentAt: atResp.Uri,
776
SubmissionId: pull.Submissions[roundNumber].ID,
777
+
Mentions: mentions,
778
+
References: references,
779
}
780
781
// Create the pull comment in the database with the commentAt field
···
793
return
794
}
795
796
s.notifier.NewPullComment(r.Context(), comment, mentions)
797
798
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
799
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
800
return
801
}
802
}
···
820
Host: host,
821
}
822
823
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
824
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
825
if err != nil {
826
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
847
848
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
849
LoggedInUser: user,
850
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
851
Branches: result.Branches,
852
Strategy: strategy,
853
SourceBranch: sourceBranch,
···
870
}
871
872
// Determine PR type based on input parameters
873
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
874
+
isPushAllowed := roles.IsPushAllowed()
875
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
876
isForkBased := fromFork != "" && sourceBranch != ""
877
isPatchBased := patch != "" && !isBranchBased && !isForkBased
···
969
func (s *Pulls) handleBranchBasedPull(
970
w http.ResponseWriter,
971
r *http.Request,
972
+
repo *models.Repo,
973
user *oauth.User,
974
title,
975
body,
···
981
if !s.config.Core.Dev {
982
scheme = "https"
983
}
984
+
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
985
xrpcc := &indigoxrpc.Client{
986
Host: host,
987
}
988
989
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
990
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch)
991
if err != nil {
992
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
993
log.Println("failed to call XRPC repo.compare", xrpcerr)
···
1024
Sha: comparison.Rev2,
1025
}
1026
1027
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1028
}
1029
1030
+
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1031
if err := s.validator.ValidatePatch(&patch); err != nil {
1032
s.logger.Error("patch validation failed", "err", err)
1033
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1034
return
1035
}
1036
1037
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1038
}
1039
1040
+
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
1041
repoString := strings.SplitN(forkRepo, "/", 2)
1042
forkOwnerDid := repoString[0]
1043
repoName := repoString[1]
···
1139
Sha: sourceRev,
1140
}
1141
1142
+
s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1143
}
1144
1145
func (s *Pulls) createPullRequest(
1146
w http.ResponseWriter,
1147
r *http.Request,
1148
+
repo *models.Repo,
1149
user *oauth.User,
1150
title, body, targetBranch string,
1151
patch string,
···
1160
s.createStackedPullRequest(
1161
w,
1162
r,
1163
+
repo,
1164
user,
1165
targetBranch,
1166
patch,
···
1206
}
1207
}
1208
1209
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
1210
+
1211
rkey := tid.TID()
1212
initialSubmission := models.PullSubmission{
1213
Patch: patch,
···
1219
Body: body,
1220
TargetBranch: targetBranch,
1221
OwnerDid: user.Did,
1222
+
RepoAt: repo.RepoAt(),
1223
Rkey: rkey,
1224
+
Mentions: mentions,
1225
+
References: references,
1226
Submissions: []*models.PullSubmission{
1227
&initialSubmission,
1228
},
···
1234
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1235
return
1236
}
1237
+
pullId, err := db.NextPullId(tx, repo.RepoAt())
1238
if err != nil {
1239
log.Println("failed to get pull id", err)
1240
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1241
return
1242
}
1243
1244
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1245
+
if err != nil {
1246
+
log.Println("failed to upload patch", err)
1247
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1248
+
return
1249
+
}
1250
+
1251
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1252
Collection: tangled.RepoPullNSID,
1253
Repo: user.Did,
···
1256
Val: &tangled.RepoPull{
1257
Title: title,
1258
Target: &tangled.RepoPull_Target{
1259
+
Repo: string(repo.RepoAt()),
1260
Branch: targetBranch,
1261
},
1262
+
PatchBlob: blob.Blob,
1263
Source: recordPullSource,
1264
CreatedAt: time.Now().Format(time.RFC3339),
1265
},
···
1279
1280
s.notifier.NewPull(r.Context(), pull)
1281
1282
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1283
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
1284
}
1285
1286
func (s *Pulls) createStackedPullRequest(
1287
w http.ResponseWriter,
1288
r *http.Request,
1289
+
repo *models.Repo,
1290
user *oauth.User,
1291
targetBranch string,
1292
patch string,
···
1318
1319
// build a stack out of this patch
1320
stackId := uuid.New()
1321
+
stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String())
1322
if err != nil {
1323
log.Println("failed to create stack", err)
1324
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
···
1335
// apply all record creations at once
1336
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1337
for _, p := range stack {
1338
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1339
+
if err != nil {
1340
+
log.Println("failed to upload patch blob", err)
1341
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1342
+
return
1343
+
}
1344
+
1345
record := p.AsRecord()
1346
+
record.PatchBlob = blob.Blob
1347
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1348
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1349
Collection: tangled.RepoPullNSID,
1350
Rkey: &p.Rkey,
···
1352
Val: &record,
1353
},
1354
},
1355
+
})
1356
}
1357
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1358
Repo: user.Did,
···
1380
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1381
return
1382
}
1383
+
1384
}
1385
1386
if err = tx.Commit(); err != nil {
···
1389
return
1390
}
1391
1392
+
// notify about each pull
1393
+
//
1394
+
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1395
+
for _, p := range stack {
1396
+
s.notifier.NewPull(r.Context(), p)
1397
+
}
1398
+
1399
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1400
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1401
}
1402
1403
func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
···
1428
1429
func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1430
user := s.oauth.GetUser(r)
1431
1432
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1433
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1434
})
1435
}
1436
···
1451
Host: host,
1452
}
1453
1454
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1455
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1456
if err != nil {
1457
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1484
}
1485
1486
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1487
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1488
Branches: withoutDefault,
1489
})
1490
}
1491
1492
func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1493
user := s.oauth.GetUser(r)
1494
1495
forks, err := db.GetForksByDid(s.db, user.Did)
1496
if err != nil {
···
1499
}
1500
1501
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1502
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1503
Forks: forks,
1504
Selected: r.URL.Query().Get("fork"),
1505
})
···
1521
// fork repo
1522
repo, err := db.GetRepo(
1523
s.db,
1524
+
orm.FilterEq("did", forkOwnerDid),
1525
+
orm.FilterEq("name", forkName),
1526
)
1527
if err != nil {
1528
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
···
1567
Host: targetHost,
1568
}
1569
1570
+
targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1571
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1572
if err != nil {
1573
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1592
})
1593
1594
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1595
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1596
SourceBranches: sourceBranches.Branches,
1597
TargetBranches: targetBranches.Branches,
1598
})
···
1600
1601
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1602
user := s.oauth.GetUser(r)
1603
1604
pull, ok := r.Context().Value("pull").(*models.Pull)
1605
if !ok {
···
1611
switch r.Method {
1612
case http.MethodGet:
1613
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1614
+
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1615
Pull: pull,
1616
})
1617
return
···
1678
return
1679
}
1680
1681
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
1682
+
if !roles.IsPushAllowed() {
1683
log.Println("unauthorized user")
1684
w.WriteHeader(http.StatusUnauthorized)
1685
return
···
1694
Host: host,
1695
}
1696
1697
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1698
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1699
if err != nil {
1700
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
1821
func (s *Pulls) resubmitPullHelper(
1822
w http.ResponseWriter,
1823
r *http.Request,
1824
+
repo *models.Repo,
1825
user *oauth.User,
1826
pull *models.Pull,
1827
patch string,
···
1830
) {
1831
if pull.IsStacked() {
1832
log.Println("resubmitting stacked PR")
1833
+
s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId)
1834
return
1835
}
1836
···
1885
return
1886
}
1887
1888
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1889
+
if err != nil {
1890
+
log.Println("failed to upload patch blob", err)
1891
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1892
+
return
1893
}
1894
+
record := pull.AsRecord()
1895
+
record.PatchBlob = blob.Blob
1896
+
record.CreatedAt = time.Now().Format(time.RFC3339)
1897
1898
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1899
Collection: tangled.RepoPullNSID,
···
1901
Rkey: pull.Rkey,
1902
SwapRecord: ex.Cid,
1903
Record: &lexutil.LexiconTypeDecoder{
1904
+
Val: &record,
1905
},
1906
})
1907
if err != nil {
···
1916
return
1917
}
1918
1919
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1920
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1921
}
1922
1923
func (s *Pulls) resubmitStackedPullHelper(
1924
w http.ResponseWriter,
1925
r *http.Request,
1926
+
repo *models.Repo,
1927
user *oauth.User,
1928
pull *models.Pull,
1929
patch string,
···
1932
targetBranch := pull.TargetBranch
1933
1934
origStack, _ := r.Context().Value("stack").(models.Stack)
1935
+
newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId)
1936
if err != nil {
1937
log.Println("failed to create resubmitted stack", err)
1938
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1987
}
1988
defer tx.Rollback()
1989
1990
+
client, err := s.oauth.AuthorizedClient(r)
1991
+
if err != nil {
1992
+
log.Println("failed to authorize client")
1993
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1994
+
return
1995
+
}
1996
+
1997
// pds updates to make
1998
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1999
···
2027
return
2028
}
2029
2030
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2031
+
if err != nil {
2032
+
log.Println("failed to upload patch blob", err)
2033
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2034
+
return
2035
+
}
2036
record := p.AsRecord()
2037
+
record.PatchBlob = blob.Blob
2038
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2039
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2040
Collection: tangled.RepoPullNSID,
···
2069
return
2070
}
2071
2072
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2073
+
if err != nil {
2074
+
log.Println("failed to upload patch blob", err)
2075
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2076
+
return
2077
+
}
2078
record := np.AsRecord()
2079
+
record.PatchBlob = blob.Blob
2080
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2081
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2082
Collection: tangled.RepoPullNSID,
···
2094
tx,
2095
p.ParentChangeId,
2096
// these should be enough filters to be unique per-stack
2097
+
orm.FilterEq("repo_at", p.RepoAt.String()),
2098
+
orm.FilterEq("owner_did", p.OwnerDid),
2099
+
orm.FilterEq("change_id", p.ChangeId),
2100
)
2101
2102
if err != nil {
···
2113
return
2114
}
2115
2116
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2117
Repo: user.Did,
2118
Writes: writes,
···
2123
return
2124
}
2125
2126
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2127
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2128
}
2129
2130
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2177
2178
authorName := ident.Handle.String()
2179
mergeInput := &tangled.RepoMerge_Input{
2180
+
Did: f.Did,
2181
Name: f.Name,
2182
Branch: pull.TargetBranch,
2183
Patch: patch,
···
2242
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2243
}
2244
2245
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2246
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2247
}
2248
2249
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2263
}
2264
2265
// auth filter: only owner or collaborators can close
2266
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2267
isOwner := roles.IsOwner()
2268
isCollaborator := roles.IsCollaborator()
2269
isPullAuthor := user.Did == pull.OwnerDid
···
2315
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2316
}
2317
2318
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2319
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2320
}
2321
2322
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2337
}
2338
2339
// auth filter: only owner or collaborators can close
2340
+
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2341
isOwner := roles.IsOwner()
2342
isCollaborator := roles.IsCollaborator()
2343
isPullAuthor := user.Did == pull.OwnerDid
···
2389
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2390
}
2391
2392
+
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2393
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2394
}
2395
2396
+
func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2397
formatPatches, err := patchutil.ExtractPatches(patch)
2398
if err != nil {
2399
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2418
body := fp.Body
2419
rkey := tid.TID()
2420
2421
+
mentions, references := s.mentionsResolver.Resolve(ctx, body)
2422
+
2423
initialSubmission := models.PullSubmission{
2424
Patch: fp.Raw,
2425
SourceRev: fp.SHA,
···
2430
Body: body,
2431
TargetBranch: targetBranch,
2432
OwnerDid: user.Did,
2433
+
RepoAt: repo.RepoAt(),
2434
Rkey: rkey,
2435
+
Mentions: mentions,
2436
+
References: references,
2437
Submissions: []*models.PullSubmission{
2438
&initialSubmission,
2439
},
+3
-2
appview/repo/archive.go
+3
-2
appview/repo/archive.go
···
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)
···
31
xrpcc := &indigoxrpc.Client{
32
Host: host,
33
}
34
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
35
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
36
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
rp.pages.Error503(w)
···
18
l := rp.logger.With("handler", "DownloadArchive")
19
ref := chi.URLParam(r, "ref")
20
ref, _ = url.PathUnescape(ref)
21
+
ref = strings.TrimSuffix(ref, ".tar.gz")
22
f, err := rp.repoResolver.Resolve(r)
23
if err != nil {
24
l.Error("failed to get repo and knot", "err", err)
···
32
xrpcc := &indigoxrpc.Client{
33
Host: host,
34
}
35
+
didSlashRepo := f.DidSlashRepo()
36
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo)
37
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
38
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
39
rp.pages.Error503(w)
+21
-14
appview/repo/artifact.go
+21
-14
appview/repo/artifact.go
···
14
"tangled.org/core/appview/db"
15
"tangled.org/core/appview/models"
16
"tangled.org/core/appview/pages"
17
-
"tangled.org/core/appview/reporesolver"
18
"tangled.org/core/appview/xrpcclient"
19
"tangled.org/core/tid"
20
"tangled.org/core/types"
21
···
131
132
rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
133
LoggedInUser: user,
134
-
RepoInfo: f.RepoInfo(user),
135
Artifact: artifact,
136
})
137
}
···
156
157
artifacts, err := db.GetArtifact(
158
rp.db,
159
-
db.FilterEq("repo_at", f.RepoAt()),
160
-
db.FilterEq("tag", tag.Tag.Hash[:]),
161
-
db.FilterEq("name", filename),
162
)
163
if err != nil {
164
log.Println("failed to get artifacts", err)
···
174
175
artifact := artifacts[0]
176
177
-
ownerPds := f.OwnerId.PDSEndpoint()
178
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
179
q := url.Query()
180
q.Set("cid", artifact.BlobCid.String())
···
228
229
artifacts, err := db.GetArtifact(
230
rp.db,
231
-
db.FilterEq("repo_at", f.RepoAt()),
232
-
db.FilterEq("tag", tag[:]),
233
-
db.FilterEq("name", filename),
234
)
235
if err != nil {
236
log.Println("failed to get artifacts", err)
···
270
defer tx.Rollback()
271
272
err = db.DeleteArtifact(tx,
273
-
db.FilterEq("repo_at", f.RepoAt()),
274
-
db.FilterEq("tag", artifact.Tag[:]),
275
-
db.FilterEq("name", filename),
276
)
277
if err != nil {
278
log.Println("failed to remove artifact record from db", err)
···
290
w.Write([]byte{})
291
}
292
293
-
func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
294
tagParam, err := url.QueryUnescape(tagParam)
295
if err != nil {
296
return nil, err
···
305
Host: host,
306
}
307
308
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
309
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
310
if err != nil {
311
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
14
"tangled.org/core/appview/db"
15
"tangled.org/core/appview/models"
16
"tangled.org/core/appview/pages"
17
"tangled.org/core/appview/xrpcclient"
18
+
"tangled.org/core/orm"
19
"tangled.org/core/tid"
20
"tangled.org/core/types"
21
···
131
132
rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
133
LoggedInUser: user,
134
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
135
Artifact: artifact,
136
})
137
}
···
156
157
artifacts, err := db.GetArtifact(
158
rp.db,
159
+
orm.FilterEq("repo_at", f.RepoAt()),
160
+
orm.FilterEq("tag", tag.Tag.Hash[:]),
161
+
orm.FilterEq("name", filename),
162
)
163
if err != nil {
164
log.Println("failed to get artifacts", err)
···
174
175
artifact := artifacts[0]
176
177
+
ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did)
178
+
if err != nil {
179
+
log.Println("failed to resolve repo owner did", f.Did, err)
180
+
http.Error(w, "repository owner not found", http.StatusNotFound)
181
+
return
182
+
}
183
+
184
+
ownerPds := ownerId.PDSEndpoint()
185
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
186
q := url.Query()
187
q.Set("cid", artifact.BlobCid.String())
···
235
236
artifacts, err := db.GetArtifact(
237
rp.db,
238
+
orm.FilterEq("repo_at", f.RepoAt()),
239
+
orm.FilterEq("tag", tag[:]),
240
+
orm.FilterEq("name", filename),
241
)
242
if err != nil {
243
log.Println("failed to get artifacts", err)
···
277
defer tx.Rollback()
278
279
err = db.DeleteArtifact(tx,
280
+
orm.FilterEq("repo_at", f.RepoAt()),
281
+
orm.FilterEq("tag", artifact.Tag[:]),
282
+
orm.FilterEq("name", filename),
283
)
284
if err != nil {
285
log.Println("failed to remove artifact record from db", err)
···
297
w.Write([]byte{})
298
}
299
300
+
func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) {
301
tagParam, err := url.QueryUnescape(tagParam)
302
if err != nil {
303
return nil, err
···
312
Host: host,
313
}
314
315
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
316
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
317
if err != nil {
318
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+11
-9
appview/repo/blob.go
+11
-9
appview/repo/blob.go
···
54
xrpcc := &indigoxrpc.Client{
55
Host: host,
56
}
57
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
58
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
59
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
60
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
···
62
return
63
}
64
65
// Use XRPC response directly instead of converting to internal types
66
var breadcrumbs [][]string
67
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
68
if filePath != "" {
69
for idx, elem := range strings.Split(filePath, "/") {
70
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
···
78
79
rp.pages.RepoBlob(w, pages.RepoBlobParams{
80
LoggedInUser: user,
81
-
RepoInfo: f.RepoInfo(user),
82
BreadCrumbs: breadcrumbs,
83
BlobView: blobView,
84
RepoBlob_Output: resp,
···
105
if !rp.config.Core.Dev {
106
scheme = "https"
107
}
108
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
109
baseURL := &url.URL{
110
Scheme: scheme,
111
Host: f.Knot,
···
176
}
177
178
// NewBlobView creates a BlobView from the XRPC response
179
-
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
180
view := models.BlobView{
181
Contents: "",
182
Lines: 0,
···
198
199
// Determine if binary
200
if resp.IsBinary != nil && *resp.IsBinary {
201
-
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
202
ext := strings.ToLower(filepath.Ext(resp.Path))
203
204
switch ext {
···
250
return view
251
}
252
253
-
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
254
scheme := "http"
255
if !config.Core.Dev {
256
scheme = "https"
257
}
258
259
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
260
baseURL := &url.URL{
261
Scheme: scheme,
262
-
Host: f.Knot,
263
Path: "/xrpc/sh.tangled.repo.blob",
264
}
265
query := baseURL.Query()
···
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)
···
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))})
···
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,
···
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,
···
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,
···
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 {
···
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()
+2
-2
appview/repo/branches.go
+2
-2
appview/repo/branches.go
···
29
xrpcc := &indigoxrpc.Client{
30
Host: host,
31
}
32
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
33
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
34
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
35
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
···
46
user := rp.oauth.GetUser(r)
47
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
48
LoggedInUser: user,
49
-
RepoInfo: f.RepoInfo(user),
50
RepoBranchesResponse: result,
51
})
52
}
···
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)
···
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
}
+4
-8
appview/repo/compare.go
+4
-8
appview/repo/compare.go
···
36
Host: host,
37
}
38
39
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
40
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
41
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
42
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
···
88
return
89
}
90
91
-
repoinfo := f.RepoInfo(user)
92
-
93
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
94
LoggedInUser: user,
95
-
RepoInfo: repoinfo,
96
Branches: branches,
97
Tags: tags.Tags,
98
Base: base,
···
151
Host: host,
152
}
153
154
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
155
156
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
157
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
···
202
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
203
}
204
205
-
repoinfo := f.RepoInfo(user)
206
-
207
rp.pages.RepoCompare(w, pages.RepoCompareParams{
208
LoggedInUser: user,
209
-
RepoInfo: repoinfo,
210
Branches: branches.Branches,
211
Tags: tags.Tags,
212
Base: base,
···
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)
···
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,
···
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 {
···
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,
+24
-17
appview/repo/feed.go
+24
-17
appview/repo/feed.go
···
11
"tangled.org/core/appview/db"
12
"tangled.org/core/appview/models"
13
"tangled.org/core/appview/pagination"
14
-
"tangled.org/core/appview/reporesolver"
15
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
"github.com/gorilla/feeds"
18
)
19
20
-
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
21
const feedLimitPerType = 100
22
23
-
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
24
if err != nil {
25
return nil, err
26
}
···
28
issues, err := db.GetIssuesPaginated(
29
rp.db,
30
pagination.Page{Limit: feedLimitPerType},
31
-
db.FilterEq("repo_at", f.RepoAt()),
32
)
33
if err != nil {
34
return nil, err
35
}
36
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"},
40
Items: make([]*feeds.Item, 0),
41
Updated: time.UnixMilli(0),
42
}
43
44
for _, pull := range pulls {
45
-
items, err := rp.createPullItems(ctx, pull, f)
46
if err != nil {
47
return nil, err
48
}
···
50
}
51
52
for _, issue := range issues {
53
-
item, err := rp.createIssueItem(ctx, issue, f)
54
if err != nil {
55
return nil, err
56
}
···
71
return feed, nil
72
}
73
74
-
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
75
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
76
if err != nil {
77
return nil, err
···
80
var items []*feeds.Item
81
82
state := rp.getPullState(pull)
83
-
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
84
85
mainItem := &feeds.Item{
86
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
87
Description: description,
88
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
89
Created: pull.Created,
90
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
91
}
···
98
99
roundItem := &feeds.Item{
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)},
103
Created: round.Created,
104
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
105
}
···
109
return items, nil
110
}
111
112
-
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
113
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
114
if err != nil {
115
return nil, err
···
122
123
return &feeds.Item{
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)},
127
Created: issue.Created,
128
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
129
}, nil
···
152
log.Println("failed to fully resolve repo:", err)
153
return
154
}
155
156
-
feed, err := rp.getRepoFeed(r.Context(), f)
157
if err != nil {
158
log.Println("failed to get repo feed:", err)
159
rp.pages.Error500(w)
···
11
"tangled.org/core/appview/db"
12
"tangled.org/core/appview/models"
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
15
16
+
"github.com/bluesky-social/indigo/atproto/identity"
17
"github.com/bluesky-social/indigo/atproto/syntax"
18
"github.com/gorilla/feeds"
19
)
20
21
+
func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) {
22
const feedLimitPerType = 100
23
24
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt()))
25
if err != nil {
26
return nil, err
27
}
···
29
issues, err := db.GetIssuesPaginated(
30
rp.db,
31
pagination.Page{Limit: feedLimitPerType},
32
+
orm.FilterEq("repo_at", repo.RepoAt()),
33
)
34
if err != nil {
35
return nil, err
36
}
37
38
feed := &feeds.Feed{
39
+
Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo),
40
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"},
41
Items: make([]*feeds.Item, 0),
42
Updated: time.UnixMilli(0),
43
}
44
45
for _, pull := range pulls {
46
+
items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo)
47
if err != nil {
48
return nil, err
49
}
···
51
}
52
53
for _, issue := range issues {
54
+
item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo)
55
if err != nil {
56
return nil, err
57
}
···
72
return feed, nil
73
}
74
75
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
76
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
77
if err != nil {
78
return nil, err
···
81
var items []*feeds.Item
82
83
state := rp.getPullState(pull)
84
+
description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo)
85
86
mainItem := &feeds.Item{
87
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
88
Description: description,
89
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)},
90
Created: pull.Created,
91
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
92
}
···
99
100
roundItem := &feeds.Item{
101
Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
102
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo),
103
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)},
104
Created: round.Created,
105
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
106
}
···
110
return items, nil
111
}
112
113
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) {
114
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
115
if err != nil {
116
return nil, err
···
123
124
return &feeds.Item{
125
Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
126
+
Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo),
127
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)},
128
Created: issue.Created,
129
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
130
}, nil
···
153
log.Println("failed to fully resolve repo:", err)
154
return
155
}
156
+
repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity)
157
+
if !ok || repoOwnerId.Handle.IsInvalidHandle() {
158
+
log.Println("failed to get resolved repo owner id")
159
+
return
160
+
}
161
+
ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name
162
163
+
feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo)
164
if err != nil {
165
log.Println("failed to get repo feed:", err)
166
rp.pages.Error500(w)
+18
-19
appview/repo/index.go
+18
-19
appview/repo/index.go
···
22
"tangled.org/core/appview/db"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/pages"
25
-
"tangled.org/core/appview/reporesolver"
26
"tangled.org/core/appview/xrpcclient"
27
"tangled.org/core/types"
28
29
"github.com/go-chi/chi/v5"
···
52
}
53
54
user := rp.oauth.GetUser(r)
55
-
repoInfo := f.RepoInfo(user)
56
57
// Build index response from multiple XRPC calls
58
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
···
62
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
63
LoggedInUser: user,
64
NeedsKnotUpgrade: true,
65
-
RepoInfo: repoInfo,
66
})
67
return
68
}
···
124
l.Error("failed to get email to did map", "err", err)
125
}
126
127
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
128
if err != nil {
129
l.Error("failed to GetVerifiedObjectCommits", "err", err)
130
}
···
140
for _, c := range commitsTrunc {
141
shas = append(shas, c.Hash.String())
142
}
143
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
144
if err != nil {
145
l.Error("failed to fetch pipeline statuses", "err", err)
146
// non-fatal
···
148
149
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
150
LoggedInUser: user,
151
-
RepoInfo: repoInfo,
152
TagMap: tagMap,
153
RepoIndexResponse: *result,
154
CommitsTrunc: commitsTrunc,
···
165
func (rp *Repo) getLanguageInfo(
166
ctx context.Context,
167
l *slog.Logger,
168
-
f *reporesolver.ResolvedRepo,
169
xrpcc *indigoxrpc.Client,
170
currentRef string,
171
isDefaultRef bool,
···
173
// first attempt to fetch from db
174
langs, err := db.GetRepoLanguages(
175
rp.db,
176
-
db.FilterEq("repo_at", f.RepoAt()),
177
-
db.FilterEq("ref", currentRef),
178
)
179
180
if err != nil || langs == nil {
181
// 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)
184
if err != nil {
185
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
186
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
···
195
196
for _, lang := range ls.Languages {
197
langs = append(langs, models.RepoLanguage{
198
-
RepoAt: f.RepoAt(),
199
Ref: currentRef,
200
IsDefaultRef: isDefaultRef,
201
Language: lang.Name,
···
210
defer tx.Rollback()
211
212
// update appview's cache
213
-
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
214
if err != nil {
215
// non-fatal
216
l.Error("failed to cache lang results", "err", err)
···
255
}
256
257
// 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)
260
261
// first get branches to determine the ref if not specified
262
-
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
263
if err != nil {
264
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
265
}
···
303
wg.Add(1)
304
go func() {
305
defer wg.Done()
306
-
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
307
if err != nil {
308
errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err))
309
return
···
318
wg.Add(1)
319
go func() {
320
defer wg.Done()
321
-
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
322
if err != nil {
323
errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err))
324
return
···
330
wg.Add(1)
331
go func() {
332
defer wg.Done()
333
-
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
334
if err != nil {
335
errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err))
336
return
···
22
"tangled.org/core/appview/db"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/pages"
25
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/orm"
27
"tangled.org/core/types"
28
29
"github.com/go-chi/chi/v5"
···
52
}
53
54
user := rp.oauth.GetUser(r)
55
56
// Build index response from multiple XRPC calls
57
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
···
61
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
62
LoggedInUser: user,
63
NeedsKnotUpgrade: true,
64
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
65
})
66
return
67
}
···
123
l.Error("failed to get email to did map", "err", err)
124
}
125
126
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc)
127
if err != nil {
128
l.Error("failed to GetVerifiedObjectCommits", "err", err)
129
}
···
139
for _, c := range commitsTrunc {
140
shas = append(shas, c.Hash.String())
141
}
142
+
pipelines, err := getPipelineStatuses(rp.db, f, shas)
143
if err != nil {
144
l.Error("failed to fetch pipeline statuses", "err", err)
145
// non-fatal
···
147
148
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
149
LoggedInUser: user,
150
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
151
TagMap: tagMap,
152
RepoIndexResponse: *result,
153
CommitsTrunc: commitsTrunc,
···
164
func (rp *Repo) getLanguageInfo(
165
ctx context.Context,
166
l *slog.Logger,
167
+
repo *models.Repo,
168
xrpcc *indigoxrpc.Client,
169
currentRef string,
170
isDefaultRef bool,
···
172
// first attempt to fetch from db
173
langs, err := db.GetRepoLanguages(
174
rp.db,
175
+
orm.FilterEq("repo_at", repo.RepoAt()),
176
+
orm.FilterEq("ref", currentRef),
177
)
178
179
if err != nil || langs == nil {
180
// non-fatal, fetch langs from ks via XRPC
181
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
182
+
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo)
183
if err != nil {
184
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
185
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
···
194
195
for _, lang := range ls.Languages {
196
langs = append(langs, models.RepoLanguage{
197
+
RepoAt: repo.RepoAt(),
198
Ref: currentRef,
199
IsDefaultRef: isDefaultRef,
200
Language: lang.Name,
···
209
defer tx.Rollback()
210
211
// update appview's cache
212
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs)
213
if err != nil {
214
// non-fatal
215
l.Error("failed to cache lang results", "err", err)
···
254
}
255
256
// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
257
+
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) {
258
+
didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
259
260
// first get branches to determine the ref if not specified
261
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo)
262
if err != nil {
263
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
264
}
···
302
wg.Add(1)
303
go func() {
304
defer wg.Done()
305
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo)
306
if err != nil {
307
errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err))
308
return
···
317
wg.Add(1)
318
go func() {
319
defer wg.Done()
320
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo)
321
if err != nil {
322
errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err))
323
return
···
329
wg.Add(1)
330
go func() {
331
defer wg.Done()
332
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo)
333
if err != nil {
334
errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err))
335
return
+8
-11
appview/repo/log.go
+8
-11
appview/repo/log.go
···
57
cursor = strconv.Itoa(offset)
58
}
59
60
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
61
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
62
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
63
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
···
116
l.Error("failed to fetch email to did mapping", "err", err)
117
}
118
119
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
if err != nil {
121
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
}
123
-
124
-
repoInfo := f.RepoInfo(user)
125
126
var shas []string
127
for _, c := range xrpcResp.Commits {
128
shas = append(shas, c.Hash.String())
129
}
130
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
131
if err != nil {
132
l.Error("failed to getPipelineStatuses", "err", err)
133
// non-fatal
···
136
rp.pages.RepoLog(w, pages.RepoLogParams{
137
LoggedInUser: user,
138
TagMap: tagMap,
139
-
RepoInfo: repoInfo,
140
RepoLogResponse: xrpcResp,
141
EmailToDid: emailToDidMap,
142
VerifiedCommits: vc,
···
174
Host: host,
175
}
176
177
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
179
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
180
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
···
194
l.Error("failed to get email to did mapping", "err", err)
195
}
196
197
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
198
if err != nil {
199
l.Error("failed to GetVerifiedCommits", "err", err)
200
}
201
202
user := rp.oauth.GetUser(r)
203
-
repoInfo := f.RepoInfo(user)
204
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
205
if err != nil {
206
l.Error("failed to getPipelineStatuses", "err", err)
207
// non-fatal
···
213
214
rp.pages.RepoCommit(w, pages.RepoCommitParams{
215
LoggedInUser: user,
216
-
RepoInfo: f.RepoInfo(user),
217
RepoCommitResponse: result,
218
EmailToDid: emailToDidMap,
219
VerifiedCommit: vc,
···
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)
···
116
l.Error("failed to fetch email to did mapping", "err", err)
117
}
118
119
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
if err != nil {
121
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
}
123
124
var shas []string
125
for _, c := range xrpcResp.Commits {
126
shas = append(shas, c.Hash.String())
127
}
128
+
pipelines, err := getPipelineStatuses(rp.db, f, shas)
129
if err != nil {
130
l.Error("failed to getPipelineStatuses", "err", err)
131
// non-fatal
···
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,
···
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)
···
192
l.Error("failed to get email to did mapping", "err", err)
193
}
194
195
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.Commit{result.Diff.Commit})
196
if err != nil {
197
l.Error("failed to GetVerifiedCommits", "err", err)
198
}
199
200
user := rp.oauth.GetUser(r)
201
+
pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This})
202
if err != nil {
203
l.Error("failed to getPipelineStatuses", "err", err)
204
// non-fatal
···
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,
+5
-4
appview/repo/opengraph.go
+5
-4
appview/repo/opengraph.go
···
16
"tangled.org/core/appview/db"
17
"tangled.org/core/appview/models"
18
"tangled.org/core/appview/ogcard"
19
"tangled.org/core/types"
20
)
21
···
236
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
240
if err != nil {
241
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
}
···
338
var languageStats []types.RepoLanguageDetails
339
langs, err := db.GetRepoLanguages(
340
rp.db,
341
-
db.FilterEq("repo_at", f.RepoAt()),
342
-
db.FilterEq("is_default_ref", 1),
343
)
344
if err != nil {
345
log.Printf("failed to get language stats from db: %v", err)
···
374
})
375
}
376
377
-
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
378
if err != nil {
379
log.Println("failed to draw repo summary card", err)
380
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
···
16
"tangled.org/core/appview/db"
17
"tangled.org/core/appview/models"
18
"tangled.org/core/appview/ogcard"
19
+
"tangled.org/core/orm"
20
"tangled.org/core/types"
21
)
22
···
237
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
238
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
239
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
240
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
241
if err != nil {
242
log.Printf("dolly silhouette not available (this is ok): %v", err)
243
}
···
339
var languageStats []types.RepoLanguageDetails
340
langs, err := db.GetRepoLanguages(
341
rp.db,
342
+
orm.FilterEq("repo_at", f.RepoAt()),
343
+
orm.FilterEq("is_default_ref", 1),
344
)
345
if err != nil {
346
log.Printf("failed to get language stats from db: %v", err)
···
375
})
376
}
377
378
+
card, err := rp.drawRepoSummaryCard(f, languageStats)
379
if err != nil {
380
log.Println("failed to draw repo summary card", err)
381
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+37
-35
appview/repo/repo.go
+37
-35
appview/repo/repo.go
···
24
xrpcclient "tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/eventconsumer"
26
"tangled.org/core/idresolver"
27
"tangled.org/core/rbac"
28
"tangled.org/core/tid"
29
"tangled.org/core/xrpc/serviceauth"
···
118
}
119
}
120
121
-
newRepo := f.Repo
122
newRepo.Spindle = newSpindle
123
record := newRepo.AsRecord()
124
···
257
l.Info("wrote label record to PDS")
258
259
// update the repo to subscribe to this label
260
-
newRepo := f.Repo
261
newRepo.Labels = append(newRepo.Labels, aturi)
262
repoRecord := newRepo.AsRecord()
263
···
345
// get form values
346
labelId := r.FormValue("label-id")
347
348
-
label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
349
if err != nil {
350
fail("Failed to find label definition.", err)
351
return
···
369
}
370
371
// update repo record to remove the label reference
372
-
newRepo := f.Repo
373
var updated []string
374
removedAt := label.AtUri().String()
375
for _, l := range newRepo.Labels {
···
409
410
err = db.UnsubscribeLabel(
411
tx,
412
-
db.FilterEq("repo_at", f.RepoAt()),
413
-
db.FilterEq("label_at", removedAt),
414
)
415
if err != nil {
416
fail("Failed to unsubscribe label.", err)
417
return
418
}
419
420
-
err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
421
if err != nil {
422
fail("Failed to delete label definition.", err)
423
return
···
456
}
457
458
labelAts := r.Form["label"]
459
-
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
460
if err != nil {
461
fail("Failed to subscribe to label.", err)
462
return
463
}
464
465
-
newRepo := f.Repo
466
newRepo.Labels = append(newRepo.Labels, labelAts...)
467
468
// dedup
···
477
return
478
}
479
480
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
481
if err != nil {
482
fail("Failed to update labels, no record found on PDS.", err)
483
return
···
542
}
543
544
labelAts := r.Form["label"]
545
-
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
546
if err != nil {
547
fail("Failed to unsubscribe to label.", err)
548
return
549
}
550
551
// update repo record to remove the label reference
552
-
newRepo := f.Repo
553
var updated []string
554
for _, l := range newRepo.Labels {
555
if !slices.Contains(labelAts, l) {
···
565
return
566
}
567
568
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
569
if err != nil {
570
fail("Failed to update labels, no record found on PDS.", err)
571
return
···
582
583
err = db.UnsubscribeLabel(
584
rp.db,
585
-
db.FilterEq("repo_at", f.RepoAt()),
586
-
db.FilterIn("label_at", labelAts),
587
)
588
if err != nil {
589
fail("Failed to unsubscribe label.", err)
···
612
613
labelDefs, err := db.GetLabelDefinitions(
614
rp.db,
615
-
db.FilterIn("at_uri", f.Repo.Labels),
616
-
db.FilterContains("scope", subject.Collection().String()),
617
)
618
if err != nil {
619
l.Error("failed to fetch label defs", "err", err)
···
625
defs[l.AtUri().String()] = &l
626
}
627
628
-
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
629
if err != nil {
630
l.Error("failed to build label state", "err", err)
631
return
···
635
user := rp.oauth.GetUser(r)
636
rp.pages.LabelPanel(w, pages.LabelPanelParams{
637
LoggedInUser: user,
638
-
RepoInfo: f.RepoInfo(user),
639
Defs: defs,
640
Subject: subject.String(),
641
State: state,
···
660
661
labelDefs, err := db.GetLabelDefinitions(
662
rp.db,
663
-
db.FilterIn("at_uri", f.Repo.Labels),
664
-
db.FilterContains("scope", subject.Collection().String()),
665
)
666
if err != nil {
667
l.Error("failed to fetch labels", "err", err)
···
673
defs[l.AtUri().String()] = &l
674
}
675
676
-
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
677
if err != nil {
678
l.Error("failed to build label state", "err", err)
679
return
···
683
user := rp.oauth.GetUser(r)
684
rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
685
LoggedInUser: user,
686
-
RepoInfo: f.RepoInfo(user),
687
Defs: defs,
688
Subject: subject.String(),
689
State: state,
···
864
r.Context(),
865
client,
866
&tangled.RepoDelete_Input{
867
-
Did: f.OwnerDid(),
868
Name: f.Name,
869
Rkey: f.Rkey,
870
},
···
902
l.Info("removed collaborators")
903
904
// remove repo RBAC
905
-
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
906
if err != nil {
907
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
908
return
909
}
910
911
// remove repo from db
912
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
913
if err != nil {
914
rp.pages.Notice(w, noticeId, "Failed to update appview")
915
return
···
930
return
931
}
932
933
-
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
934
}
935
936
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
959
return
960
}
961
962
-
repoInfo := f.RepoInfo(user)
963
-
if repoInfo.Source == nil {
964
rp.pages.Notice(w, "repo", "This repository is not a fork.")
965
return
966
}
···
971
&tangled.RepoForkSync_Input{
972
Did: user.Did,
973
Name: f.Name,
974
-
Source: repoInfo.Source.RepoAt().String(),
975
Branch: ref,
976
},
977
)
···
1007
rp.pages.ForkRepo(w, pages.ForkRepoParams{
1008
LoggedInUser: user,
1009
Knots: knots,
1010
-
RepoInfo: f.RepoInfo(user),
1011
})
1012
1013
case http.MethodPost:
···
1037
// in the user's account.
1038
existingRepo, err := db.GetRepo(
1039
rp.db,
1040
-
db.FilterEq("did", user.Did),
1041
-
db.FilterEq("name", forkName),
1042
)
1043
if err != nil {
1044
if !errors.Is(err, sql.ErrNoRows) {
···
1058
uri = "http"
1059
}
1060
1061
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1062
l = l.With("cloneUrl", forkSourceUrl)
1063
1064
sourceAt := f.RepoAt().String()
···
1071
Knot: targetKnot,
1072
Rkey: rkey,
1073
Source: sourceAt,
1074
-
Description: f.Repo.Description,
1075
Created: time.Now(),
1076
Labels: rp.config.Label.DefaultLabelDefs,
1077
}
···
1130
}
1131
defer rollback()
1132
1133
client, err := rp.oauth.ServiceClient(
1134
r,
1135
oauth.WithService(targetKnot),
1136
oauth.WithLxm(tangled.RepoCreateNSID),
1137
oauth.WithDev(rp.config.Core.Dev),
1138
)
1139
if err != nil {
1140
l.Error("could not create service client", "err", err)
···
24
xrpcclient "tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/eventconsumer"
26
"tangled.org/core/idresolver"
27
+
"tangled.org/core/orm"
28
"tangled.org/core/rbac"
29
"tangled.org/core/tid"
30
"tangled.org/core/xrpc/serviceauth"
···
119
}
120
}
121
122
+
newRepo := *f
123
newRepo.Spindle = newSpindle
124
record := newRepo.AsRecord()
125
···
258
l.Info("wrote label record to PDS")
259
260
// update the repo to subscribe to this label
261
+
newRepo := *f
262
newRepo.Labels = append(newRepo.Labels, aturi)
263
repoRecord := newRepo.AsRecord()
264
···
346
// get form values
347
labelId := r.FormValue("label-id")
348
349
+
label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId))
350
if err != nil {
351
fail("Failed to find label definition.", err)
352
return
···
370
}
371
372
// update repo record to remove the label reference
373
+
newRepo := *f
374
var updated []string
375
removedAt := label.AtUri().String()
376
for _, l := range newRepo.Labels {
···
410
411
err = db.UnsubscribeLabel(
412
tx,
413
+
orm.FilterEq("repo_at", f.RepoAt()),
414
+
orm.FilterEq("label_at", removedAt),
415
)
416
if err != nil {
417
fail("Failed to unsubscribe label.", err)
418
return
419
}
420
421
+
err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id))
422
if err != nil {
423
fail("Failed to delete label definition.", err)
424
return
···
457
}
458
459
labelAts := r.Form["label"]
460
+
_, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
461
if err != nil {
462
fail("Failed to subscribe to label.", err)
463
return
464
}
465
466
+
newRepo := *f
467
newRepo.Labels = append(newRepo.Labels, labelAts...)
468
469
// dedup
···
478
return
479
}
480
481
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
482
if err != nil {
483
fail("Failed to update labels, no record found on PDS.", err)
484
return
···
543
}
544
545
labelAts := r.Form["label"]
546
+
_, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
547
if err != nil {
548
fail("Failed to unsubscribe to label.", err)
549
return
550
}
551
552
// update repo record to remove the label reference
553
+
newRepo := *f
554
var updated []string
555
for _, l := range newRepo.Labels {
556
if !slices.Contains(labelAts, l) {
···
566
return
567
}
568
569
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey)
570
if err != nil {
571
fail("Failed to update labels, no record found on PDS.", err)
572
return
···
583
584
err = db.UnsubscribeLabel(
585
rp.db,
586
+
orm.FilterEq("repo_at", f.RepoAt()),
587
+
orm.FilterIn("label_at", labelAts),
588
)
589
if err != nil {
590
fail("Failed to unsubscribe label.", err)
···
613
614
labelDefs, err := db.GetLabelDefinitions(
615
rp.db,
616
+
orm.FilterIn("at_uri", f.Labels),
617
+
orm.FilterContains("scope", subject.Collection().String()),
618
)
619
if err != nil {
620
l.Error("failed to fetch label defs", "err", err)
···
626
defs[l.AtUri().String()] = &l
627
}
628
629
+
states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
630
if err != nil {
631
l.Error("failed to build label state", "err", err)
632
return
···
636
user := rp.oauth.GetUser(r)
637
rp.pages.LabelPanel(w, pages.LabelPanelParams{
638
LoggedInUser: user,
639
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
640
Defs: defs,
641
Subject: subject.String(),
642
State: state,
···
661
662
labelDefs, err := db.GetLabelDefinitions(
663
rp.db,
664
+
orm.FilterIn("at_uri", f.Labels),
665
+
orm.FilterContains("scope", subject.Collection().String()),
666
)
667
if err != nil {
668
l.Error("failed to fetch labels", "err", err)
···
674
defs[l.AtUri().String()] = &l
675
}
676
677
+
states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
678
if err != nil {
679
l.Error("failed to build label state", "err", err)
680
return
···
684
user := rp.oauth.GetUser(r)
685
rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
686
LoggedInUser: user,
687
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
688
Defs: defs,
689
Subject: subject.String(),
690
State: state,
···
865
r.Context(),
866
client,
867
&tangled.RepoDelete_Input{
868
+
Did: f.Did,
869
Name: f.Name,
870
Rkey: f.Rkey,
871
},
···
903
l.Info("removed collaborators")
904
905
// remove repo RBAC
906
+
err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo())
907
if err != nil {
908
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
909
return
910
}
911
912
// remove repo from db
913
+
err = db.RemoveRepo(tx, f.Did, f.Name)
914
if err != nil {
915
rp.pages.Notice(w, noticeId, "Failed to update appview")
916
return
···
931
return
932
}
933
934
+
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did))
935
}
936
937
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
960
return
961
}
962
963
+
if f.Source == "" {
964
rp.pages.Notice(w, "repo", "This repository is not a fork.")
965
return
966
}
···
971
&tangled.RepoForkSync_Input{
972
Did: user.Did,
973
Name: f.Name,
974
+
Source: f.Source,
975
Branch: ref,
976
},
977
)
···
1007
rp.pages.ForkRepo(w, pages.ForkRepoParams{
1008
LoggedInUser: user,
1009
Knots: knots,
1010
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1011
})
1012
1013
case http.MethodPost:
···
1037
// in the user's account.
1038
existingRepo, err := db.GetRepo(
1039
rp.db,
1040
+
orm.FilterEq("did", user.Did),
1041
+
orm.FilterEq("name", forkName),
1042
)
1043
if err != nil {
1044
if !errors.Is(err, sql.ErrNoRows) {
···
1058
uri = "http"
1059
}
1060
1061
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name)
1062
l = l.With("cloneUrl", forkSourceUrl)
1063
1064
sourceAt := f.RepoAt().String()
···
1071
Knot: targetKnot,
1072
Rkey: rkey,
1073
Source: sourceAt,
1074
+
Description: f.Description,
1075
Created: time.Now(),
1076
Labels: rp.config.Label.DefaultLabelDefs,
1077
}
···
1130
}
1131
defer rollback()
1132
1133
+
// TODO: this could coordinate better with the knot to recieve a clone status
1134
client, err := rp.oauth.ServiceClient(
1135
r,
1136
oauth.WithService(targetKnot),
1137
oauth.WithLxm(tangled.RepoCreateNSID),
1138
oauth.WithDev(rp.config.Core.Dev),
1139
+
oauth.WithTimeout(time.Second*20), // big repos take time to clone
1140
)
1141
if err != nil {
1142
l.Error("could not create service client", "err", err)
+17
-19
appview/repo/repo_util.go
+17
-19
appview/repo/repo_util.go
···
1
package repo
2
3
import (
4
"slices"
5
"sort"
6
"strings"
7
8
"tangled.org/core/appview/db"
9
"tangled.org/core/appview/models"
10
-
"tangled.org/core/appview/pages/repoinfo"
11
"tangled.org/core/types"
12
-
13
-
"github.com/go-git/go-git/v5/plumbing/object"
14
)
15
16
func sortFiles(files []types.NiceTree) {
···
43
})
44
}
45
46
-
func uniqueEmails(commits []*object.Commit) []string {
47
emails := make(map[string]struct{})
48
for _, commit := range commits {
49
-
if commit.Author.Email != "" {
50
-
emails[commit.Author.Email] = struct{}{}
51
-
}
52
-
if commit.Committer.Email != "" {
53
-
emails[commit.Committer.Email] = struct{}{}
54
}
55
}
56
-
var uniqueEmails []string
57
-
for email := range emails {
58
-
uniqueEmails = append(uniqueEmails, email)
59
-
}
60
-
return uniqueEmails
61
}
62
63
func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) {
···
93
// golang is so blessed that it requires 35 lines of imperative code for this
94
func getPipelineStatuses(
95
d *db.DB,
96
-
repoInfo repoinfo.RepoInfo,
97
shas []string,
98
) (map[string]models.Pipeline, error) {
99
m := make(map[string]models.Pipeline)
···
105
ps, err := db.GetPipelineStatuses(
106
d,
107
len(shas),
108
-
db.FilterEq("repo_owner", repoInfo.OwnerDid),
109
-
db.FilterEq("repo_name", repoInfo.Name),
110
-
db.FilterEq("knot", repoInfo.Knot),
111
-
db.FilterIn("sha", shas),
112
)
113
if err != nil {
114
return nil, err
···
1
package repo
2
3
import (
4
+
"maps"
5
"slices"
6
"sort"
7
"strings"
8
9
"tangled.org/core/appview/db"
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
12
"tangled.org/core/types"
13
)
14
15
func sortFiles(files []types.NiceTree) {
···
42
})
43
}
44
45
+
func uniqueEmails(commits []types.Commit) []string {
46
emails := make(map[string]struct{})
47
for _, commit := range commits {
48
+
emails[commit.Author.Email] = struct{}{}
49
+
emails[commit.Committer.Email] = struct{}{}
50
+
for _, c := range commit.CoAuthors() {
51
+
emails[c.Email] = struct{}{}
52
}
53
}
54
+
55
+
// delete empty emails if any, from the set
56
+
delete(emails, "")
57
+
58
+
return slices.Collect(maps.Keys(emails))
59
}
60
61
func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) {
···
91
// golang is so blessed that it requires 35 lines of imperative code for this
92
func getPipelineStatuses(
93
d *db.DB,
94
+
repo *models.Repo,
95
shas []string,
96
) (map[string]models.Pipeline, error) {
97
m := make(map[string]models.Pipeline)
···
103
ps, err := db.GetPipelineStatuses(
104
d,
105
len(shas),
106
+
orm.FilterEq("repo_owner", repo.Did),
107
+
orm.FilterEq("repo_name", repo.Name),
108
+
orm.FilterEq("knot", repo.Knot),
109
+
orm.FilterIn("sha", shas),
110
)
111
if err != nil {
112
return nil, err
+40
-11
appview/repo/settings.go
+40
-11
appview/repo/settings.go
···
10
11
"tangled.org/core/api/tangled"
12
"tangled.org/core/appview/db"
13
"tangled.org/core/appview/oauth"
14
"tangled.org/core/appview/pages"
15
xrpcclient "tangled.org/core/appview/xrpcclient"
16
"tangled.org/core/types"
17
18
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
194
Host: host,
195
}
196
197
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
198
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
199
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
200
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
···
209
return
210
}
211
212
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
213
if err != nil {
214
l.Error("failed to fetch labels", "err", err)
215
rp.pages.Error503(w)
216
return
217
}
218
219
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
220
if err != nil {
221
l.Error("failed to fetch labels", "err", err)
222
rp.pages.Error503(w)
···
237
labels = labels[:n]
238
239
subscribedLabels := make(map[string]struct{})
240
-
for _, l := range f.Repo.Labels {
241
subscribedLabels[l] = struct{}{}
242
}
243
···
254
255
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
256
LoggedInUser: user,
257
-
RepoInfo: f.RepoInfo(user),
258
Branches: result.Branches,
259
Labels: labels,
260
DefaultLabels: defaultLabels,
···
271
f, err := rp.repoResolver.Resolve(r)
272
user := rp.oauth.GetUser(r)
273
274
-
repoCollaborators, err := f.Collaborators(r.Context())
275
if err != nil {
276
l.Error("failed to get collaborators", "err", err)
277
}
278
279
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
280
LoggedInUser: user,
281
-
RepoInfo: f.RepoInfo(user),
282
Tabs: settingsTabs,
283
Tab: "access",
284
-
Collaborators: repoCollaborators,
285
})
286
}
287
···
292
user := rp.oauth.GetUser(r)
293
294
// all spindles that the repo owner is a member of
295
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
296
if err != nil {
297
l.Error("failed to fetch spindles", "err", err)
298
return
···
339
340
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
341
LoggedInUser: user,
342
-
RepoInfo: f.RepoInfo(user),
343
Tabs: settingsTabs,
344
Tab: "pipelines",
345
Spindles: spindles,
···
388
}
389
l.Debug("got", "topicsStr", topicStr, "topics", topics)
390
391
-
newRepo := f.Repo
392
newRepo.Description = description
393
newRepo.Website = website
394
newRepo.Topics = topics
···
10
11
"tangled.org/core/api/tangled"
12
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
"tangled.org/core/appview/oauth"
15
"tangled.org/core/appview/pages"
16
xrpcclient "tangled.org/core/appview/xrpcclient"
17
+
"tangled.org/core/orm"
18
"tangled.org/core/types"
19
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
196
Host: host,
197
}
198
199
+
repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
200
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
201
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
202
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
···
211
return
212
}
213
214
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
215
if err != nil {
216
l.Error("failed to fetch labels", "err", err)
217
rp.pages.Error503(w)
218
return
219
}
220
221
+
labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels))
222
if err != nil {
223
l.Error("failed to fetch labels", "err", err)
224
rp.pages.Error503(w)
···
239
labels = labels[:n]
240
241
subscribedLabels := make(map[string]struct{})
242
+
for _, l := range f.Labels {
243
subscribedLabels[l] = struct{}{}
244
}
245
···
256
257
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
258
LoggedInUser: user,
259
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
260
Branches: result.Branches,
261
Labels: labels,
262
DefaultLabels: defaultLabels,
···
273
f, err := rp.repoResolver.Resolve(r)
274
user := rp.oauth.GetUser(r)
275
276
+
collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) {
277
+
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot)
278
+
if err != nil {
279
+
return nil, err
280
+
}
281
+
var collaborators []pages.Collaborator
282
+
for _, item := range repoCollaborators {
283
+
// currently only two roles: owner and member
284
+
var role string
285
+
switch item[3] {
286
+
case "repo:owner":
287
+
role = "owner"
288
+
case "repo:collaborator":
289
+
role = "collaborator"
290
+
default:
291
+
continue
292
+
}
293
+
294
+
did := item[0]
295
+
296
+
c := pages.Collaborator{
297
+
Did: did,
298
+
Role: role,
299
+
}
300
+
collaborators = append(collaborators, c)
301
+
}
302
+
return collaborators, nil
303
+
}(f)
304
if err != nil {
305
l.Error("failed to get collaborators", "err", err)
306
}
307
308
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
309
LoggedInUser: user,
310
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
311
Tabs: settingsTabs,
312
Tab: "access",
313
+
Collaborators: collaborators,
314
})
315
}
316
···
321
user := rp.oauth.GetUser(r)
322
323
// all spindles that the repo owner is a member of
324
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
325
if err != nil {
326
l.Error("failed to fetch spindles", "err", err)
327
return
···
368
369
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
370
LoggedInUser: user,
371
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
372
Tabs: settingsTabs,
373
Tab: "pipelines",
374
Spindles: spindles,
···
417
}
418
l.Debug("got", "topicsStr", topicStr, "topics", topics)
419
420
+
newRepo := *f
421
newRepo.Description = description
422
newRepo.Website = website
423
newRepo.Topics = topics
+6
-4
appview/repo/tree.go
+6
-4
appview/repo/tree.go
···
9
10
"tangled.org/core/api/tangled"
11
"tangled.org/core/appview/pages"
12
xrpcclient "tangled.org/core/appview/xrpcclient"
13
"tangled.org/core/types"
14
···
39
xrpcc := &indigoxrpc.Client{
40
Host: host,
41
}
42
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
43
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
44
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
45
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
···
79
result.ReadmeFileName = xrpcResp.Readme.Filename
80
result.Readme = xrpcResp.Readme.Contents
81
}
82
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
83
// so we can safely redirect to the "parent" (which is the same file).
84
if len(result.Files) == 0 && result.Parent == treePath {
85
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
86
http.Redirect(w, r, redirectTo, http.StatusFound)
87
return
88
}
89
user := rp.oauth.GetUser(r)
90
var breadcrumbs [][]string
91
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
92
if treePath != "" {
93
for idx, elem := range strings.Split(treePath, "/") {
94
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
···
100
LoggedInUser: user,
101
BreadCrumbs: breadcrumbs,
102
TreePath: treePath,
103
-
RepoInfo: f.RepoInfo(user),
104
RepoTreeResponse: result,
105
})
106
}
···
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
···
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)
···
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))})
···
102
LoggedInUser: user,
103
BreadCrumbs: breadcrumbs,
104
TreePath: treePath,
105
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
106
RepoTreeResponse: result,
107
})
108
}
+98
-161
appview/reporesolver/resolver.go
+98
-161
appview/reporesolver/resolver.go
···
1
package reporesolver
2
3
import (
4
-
"context"
5
-
"database/sql"
6
-
"errors"
7
"fmt"
8
"log"
9
"net/http"
···
12
"strings"
13
14
"github.com/bluesky-social/indigo/atproto/identity"
15
-
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/go-chi/chi/v5"
17
"tangled.org/core/appview/config"
18
"tangled.org/core/appview/db"
19
"tangled.org/core/appview/models"
20
"tangled.org/core/appview/oauth"
21
-
"tangled.org/core/appview/pages"
22
"tangled.org/core/appview/pages/repoinfo"
23
-
"tangled.org/core/idresolver"
24
"tangled.org/core/rbac"
25
)
26
27
-
type ResolvedRepo struct {
28
-
models.Repo
29
-
OwnerId identity.Identity
30
-
CurrentDir string
31
-
Ref string
32
-
33
-
rr *RepoResolver
34
}
35
36
-
type RepoResolver struct {
37
-
config *config.Config
38
-
enforcer *rbac.Enforcer
39
-
idResolver *idresolver.Resolver
40
-
execer db.Execer
41
}
42
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}
45
}
46
47
-
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
48
repo, ok := r.Context().Value("repo").(*models.Repo)
49
if !ok {
50
log.Println("malformed middleware: `repo` not exist in context")
51
return nil, fmt.Errorf("malformed middleware")
52
}
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
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()
78
}
79
80
-
func (f *ResolvedRepo) OwnerSlashRepo() string {
81
-
handle := f.OwnerId.Handle
82
-
83
-
var p string
84
-
if handle != "" && !handle.IsInvalidHandle() {
85
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name)
86
-
} else {
87
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
88
}
89
90
-
return p
91
-
}
92
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
97
}
98
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
110
}
111
-
112
-
did := item[0]
113
-
114
-
c := pages.Collaborator{
115
-
Did: did,
116
-
Handle: "",
117
-
Role: role,
118
}
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()
132
}
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)
164
-
}
165
-
166
var sourceRepo *models.Repo
167
-
if source != "" {
168
-
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
169
if err != nil {
170
log.Println("failed to get repo by at uri", err)
171
}
172
}
173
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
-
}
181
182
-
knot := f.Knot
183
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
-
}
205
206
-
if sourceRepo != nil {
207
-
repoInfo.Source = sourceRepo
208
-
repoInfo.SourceHandle = sourceHandle.Handle.String()
209
}
210
211
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
}
222
223
// extractPathAfterRef gets the actual repository path
···
1
package reporesolver
2
3
import (
4
"fmt"
5
"log"
6
"net/http"
···
9
"strings"
10
11
"github.com/bluesky-social/indigo/atproto/identity"
12
"github.com/go-chi/chi/v5"
13
"tangled.org/core/appview/config"
14
"tangled.org/core/appview/db"
15
"tangled.org/core/appview/models"
16
"tangled.org/core/appview/oauth"
17
"tangled.org/core/appview/pages/repoinfo"
18
"tangled.org/core/rbac"
19
)
20
21
+
type RepoResolver struct {
22
+
config *config.Config
23
+
enforcer *rbac.Enforcer
24
+
execer db.Execer
25
}
26
27
+
func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer) *RepoResolver {
28
+
return &RepoResolver{config: config, enforcer: enforcer, execer: execer}
29
}
30
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)
41
}
42
43
+
// TODO: move this out of `RepoResolver` struct
44
+
func (rr *RepoResolver) Resolve(r *http.Request) (*models.Repo, error) {
45
repo, ok := r.Context().Value("repo").(*models.Repo)
46
if !ok {
47
log.Println("malformed middleware: `repo` not exist in context")
48
return nil, fmt.Errorf("malformed middleware")
49
}
50
51
+
return repo, nil
52
}
53
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")
63
}
64
65
+
// get dir/ref
66
+
currentDir := extractCurrentDir(r.URL.EscapedPath())
67
+
ref := chi.URLParam(r, "ref")
68
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())
75
}
76
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)
82
}
83
+
issueCount, err := db.GetIssueCount(rr.execer, repoAt)
84
+
if err != nil {
85
+
log.Println("failed to get issue count for ", repoAt)
86
}
87
+
pullCount, err := db.GetPullCount(rr.execer, repoAt)
88
+
if err != nil {
89
+
log.Println("failed to get pull count for ", repoAt)
90
+
}
91
+
stats = &models.RepoStats{
92
+
StarCount: starCount,
93
+
IssueCount: issueCount,
94
+
PullCount: pullCount,
95
}
96
}
97
98
var sourceRepo *models.Repo
99
+
var err error
100
+
if repo.Source != "" {
101
+
sourceRepo, err = db.GetRepoByAtUri(rr.execer, repo.Source)
102
if err != nil {
103
log.Println("failed to get repo by at uri", err)
104
}
105
}
106
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,
119
120
+
// fork repo upstream
121
+
Source: sourceRepo,
122
123
+
// page context
124
+
CurrentDir: currentDir,
125
+
Ref: ref,
126
127
+
// info related to the session
128
+
IsStarred: isStarred,
129
+
Roles: roles,
130
}
131
132
return repoInfo
133
}
134
135
+
// extractCurrentDir gets the current directory for markdown link resolution.
136
+
// for blob paths, returns the parent dir. for tree paths, returns the path itself.
137
+
//
138
+
// /@user/repo/blob/main/docs/README.md => docs
139
+
// /@user/repo/tree/main/docs => docs
140
+
func extractCurrentDir(fullPath string) string {
141
+
fullPath = strings.TrimPrefix(fullPath, "/")
142
+
143
+
blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`)
144
+
if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 {
145
+
return path.Dir(matches[1])
146
}
147
+
148
+
treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`)
149
+
if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 {
150
+
dir := strings.TrimSuffix(matches[1], "/")
151
+
if dir == "" {
152
+
return "."
153
+
}
154
+
return dir
155
+
}
156
+
157
+
return "."
158
}
159
160
// extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
+22
appview/reporesolver/resolver_test.go
···
···
1
+
package reporesolver
2
+
3
+
import "testing"
4
+
5
+
func TestExtractCurrentDir(t *testing.T) {
6
+
tests := []struct {
7
+
path string
8
+
want string
9
+
}{
10
+
{"/@user/repo/blob/main/docs/README.md", "docs"},
11
+
{"/@user/repo/blob/main/README.md", "."},
12
+
{"/@user/repo/tree/main/docs", "docs"},
13
+
{"/@user/repo/tree/main/docs/", "docs"},
14
+
{"/@user/repo/tree/main", "."},
15
+
}
16
+
17
+
for _, tt := range tests {
18
+
if got := extractCurrentDir(tt.path); got != tt.want {
19
+
t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want)
20
+
}
21
+
}
22
+
}
+5
-4
appview/serververify/verify.go
+5
-4
appview/serververify/verify.go
···
9
"tangled.org/core/api/tangled"
10
"tangled.org/core/appview/db"
11
"tangled.org/core/appview/xrpcclient"
12
"tangled.org/core/rbac"
13
)
14
···
76
// mark this spindle as verified in the db
77
rowId, err := db.VerifySpindle(
78
tx,
79
-
db.FilterEq("owner", owner),
80
-
db.FilterEq("instance", instance),
81
)
82
if err != nil {
83
return 0, fmt.Errorf("failed to write to DB: %w", err)
···
115
// mark as registered
116
err = db.MarkRegistered(
117
tx,
118
-
db.FilterEq("did", owner),
119
-
db.FilterEq("domain", domain),
120
)
121
if err != nil {
122
return fmt.Errorf("failed to register domain: %w", err)
···
9
"tangled.org/core/api/tangled"
10
"tangled.org/core/appview/db"
11
"tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/orm"
13
"tangled.org/core/rbac"
14
)
15
···
77
// mark this spindle as verified in the db
78
rowId, err := db.VerifySpindle(
79
tx,
80
+
orm.FilterEq("owner", owner),
81
+
orm.FilterEq("instance", instance),
82
)
83
if err != nil {
84
return 0, fmt.Errorf("failed to write to DB: %w", err)
···
116
// mark as registered
117
err = db.MarkRegistered(
118
tx,
119
+
orm.FilterEq("did", owner),
120
+
orm.FilterEq("domain", domain),
121
)
122
if err != nil {
123
return fmt.Errorf("failed to register domain: %w", err)
+2
appview/settings/settings.go
+2
appview/settings/settings.go
+44
-31
appview/spindles/spindles.go
+44
-31
appview/spindles/spindles.go
···
20
"tangled.org/core/appview/serververify"
21
"tangled.org/core/appview/xrpcclient"
22
"tangled.org/core/idresolver"
23
"tangled.org/core/rbac"
24
"tangled.org/core/tid"
25
···
38
Logger *slog.Logger
39
}
40
41
func (s *Spindles) Router() http.Handler {
42
r := chi.NewRouter()
43
···
58
user := s.OAuth.GetUser(r)
59
all, err := db.GetSpindles(
60
s.Db,
61
-
db.FilterEq("owner", user.Did),
62
)
63
if err != nil {
64
s.Logger.Error("failed to fetch spindles", "err", err)
···
69
s.Pages.Spindles(w, pages.SpindlesParams{
70
LoggedInUser: user,
71
Spindles: all,
72
})
73
}
74
···
86
87
spindles, err := db.GetSpindles(
88
s.Db,
89
-
db.FilterEq("instance", instance),
90
-
db.FilterEq("owner", user.Did),
91
-
db.FilterIsNot("verified", "null"),
92
)
93
if err != nil || len(spindles) != 1 {
94
l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles))
···
108
repos, err := db.GetRepos(
109
s.Db,
110
0,
111
-
db.FilterEq("spindle", instance),
112
)
113
if err != nil {
114
l.Error("failed to get spindle repos", "err", err)
···
127
Spindle: spindle,
128
Members: members,
129
Repos: repoMap,
130
})
131
}
132
···
273
274
spindles, err := db.GetSpindles(
275
s.Db,
276
-
db.FilterEq("owner", user.Did),
277
-
db.FilterEq("instance", instance),
278
)
279
if err != nil || len(spindles) != 1 {
280
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
302
// remove spindle members first
303
err = db.RemoveSpindleMember(
304
tx,
305
-
db.FilterEq("did", user.Did),
306
-
db.FilterEq("instance", instance),
307
)
308
if err != nil {
309
l.Error("failed to remove spindle members", "err", err)
···
313
314
err = db.DeleteSpindle(
315
tx,
316
-
db.FilterEq("owner", user.Did),
317
-
db.FilterEq("instance", instance),
318
)
319
if err != nil {
320
l.Error("failed to delete spindle", "err", err)
···
365
366
shouldRedirect := r.Header.Get("shouldRedirect")
367
if shouldRedirect == "true" {
368
-
s.Pages.HxRedirect(w, "/spindles")
369
return
370
}
371
···
393
394
spindles, err := db.GetSpindles(
395
s.Db,
396
-
db.FilterEq("owner", user.Did),
397
-
db.FilterEq("instance", instance),
398
)
399
if err != nil || len(spindles) != 1 {
400
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
436
437
verifiedSpindle, err := db.GetSpindles(
438
s.Db,
439
-
db.FilterEq("id", rowId),
440
)
441
if err != nil || len(verifiedSpindle) != 1 {
442
l.Error("failed get new spindle", "err", err)
···
469
470
spindles, err := db.GetSpindles(
471
s.Db,
472
-
db.FilterEq("owner", user.Did),
473
-
db.FilterEq("instance", instance),
474
)
475
if err != nil || len(spindles) != 1 {
476
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
581
}
582
583
// success
584
-
s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance))
585
}
586
587
func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
···
605
606
spindles, err := db.GetSpindles(
607
s.Db,
608
-
db.FilterEq("owner", user.Did),
609
-
db.FilterEq("instance", instance),
610
)
611
if err != nil || len(spindles) != 1 {
612
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
635
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
636
return
637
}
638
-
if memberId.Handle.IsInvalidHandle() {
639
-
l.Error("failed to resolve member identity to handle")
640
-
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
641
-
return
642
-
}
643
644
tx, err := s.Db.Begin()
645
if err != nil {
···
655
// get the record from the DB first:
656
members, err := db.GetSpindleMembers(
657
s.Db,
658
-
db.FilterEq("did", user.Did),
659
-
db.FilterEq("instance", instance),
660
-
db.FilterEq("subject", memberId.DID),
661
)
662
if err != nil || len(members) != 1 {
663
l.Error("failed to get member", "err", err)
···
668
// remove from db
669
if err = db.RemoveSpindleMember(
670
tx,
671
-
db.FilterEq("did", user.Did),
672
-
db.FilterEq("instance", instance),
673
-
db.FilterEq("subject", memberId.DID),
674
); err != nil {
675
l.Error("failed to remove spindle member", "err", err)
676
fail()
···
20
"tangled.org/core/appview/serververify"
21
"tangled.org/core/appview/xrpcclient"
22
"tangled.org/core/idresolver"
23
+
"tangled.org/core/orm"
24
"tangled.org/core/rbac"
25
"tangled.org/core/tid"
26
···
39
Logger *slog.Logger
40
}
41
42
+
type tab = map[string]any
43
+
44
+
var (
45
+
spindlesTabs []tab = []tab{
46
+
{"Name": "profile", "Icon": "user"},
47
+
{"Name": "keys", "Icon": "key"},
48
+
{"Name": "emails", "Icon": "mail"},
49
+
{"Name": "notifications", "Icon": "bell"},
50
+
{"Name": "knots", "Icon": "volleyball"},
51
+
{"Name": "spindles", "Icon": "spool"},
52
+
}
53
+
)
54
+
55
func (s *Spindles) Router() http.Handler {
56
r := chi.NewRouter()
57
···
72
user := s.OAuth.GetUser(r)
73
all, err := db.GetSpindles(
74
s.Db,
75
+
orm.FilterEq("owner", user.Did),
76
)
77
if err != nil {
78
s.Logger.Error("failed to fetch spindles", "err", err)
···
83
s.Pages.Spindles(w, pages.SpindlesParams{
84
LoggedInUser: user,
85
Spindles: all,
86
+
Tabs: spindlesTabs,
87
+
Tab: "spindles",
88
})
89
}
90
···
102
103
spindles, err := db.GetSpindles(
104
s.Db,
105
+
orm.FilterEq("instance", instance),
106
+
orm.FilterEq("owner", user.Did),
107
+
orm.FilterIsNot("verified", "null"),
108
)
109
if err != nil || len(spindles) != 1 {
110
l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles))
···
124
repos, err := db.GetRepos(
125
s.Db,
126
0,
127
+
orm.FilterEq("spindle", instance),
128
)
129
if err != nil {
130
l.Error("failed to get spindle repos", "err", err)
···
143
Spindle: spindle,
144
Members: members,
145
Repos: repoMap,
146
+
Tabs: spindlesTabs,
147
+
Tab: "spindles",
148
})
149
}
150
···
291
292
spindles, err := db.GetSpindles(
293
s.Db,
294
+
orm.FilterEq("owner", user.Did),
295
+
orm.FilterEq("instance", instance),
296
)
297
if err != nil || len(spindles) != 1 {
298
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
320
// remove spindle members first
321
err = db.RemoveSpindleMember(
322
tx,
323
+
orm.FilterEq("did", user.Did),
324
+
orm.FilterEq("instance", instance),
325
)
326
if err != nil {
327
l.Error("failed to remove spindle members", "err", err)
···
331
332
err = db.DeleteSpindle(
333
tx,
334
+
orm.FilterEq("owner", user.Did),
335
+
orm.FilterEq("instance", instance),
336
)
337
if err != nil {
338
l.Error("failed to delete spindle", "err", err)
···
383
384
shouldRedirect := r.Header.Get("shouldRedirect")
385
if shouldRedirect == "true" {
386
+
s.Pages.HxRedirect(w, "/settings/spindles")
387
return
388
}
389
···
411
412
spindles, err := db.GetSpindles(
413
s.Db,
414
+
orm.FilterEq("owner", user.Did),
415
+
orm.FilterEq("instance", instance),
416
)
417
if err != nil || len(spindles) != 1 {
418
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
454
455
verifiedSpindle, err := db.GetSpindles(
456
s.Db,
457
+
orm.FilterEq("id", rowId),
458
)
459
if err != nil || len(verifiedSpindle) != 1 {
460
l.Error("failed get new spindle", "err", err)
···
487
488
spindles, err := db.GetSpindles(
489
s.Db,
490
+
orm.FilterEq("owner", user.Did),
491
+
orm.FilterEq("instance", instance),
492
)
493
if err != nil || len(spindles) != 1 {
494
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
599
}
600
601
// success
602
+
s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance))
603
}
604
605
func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
···
623
624
spindles, err := db.GetSpindles(
625
s.Db,
626
+
orm.FilterEq("owner", user.Did),
627
+
orm.FilterEq("instance", instance),
628
)
629
if err != nil || len(spindles) != 1 {
630
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
653
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
654
return
655
}
656
657
tx, err := s.Db.Begin()
658
if err != nil {
···
668
// get the record from the DB first:
669
members, err := db.GetSpindleMembers(
670
s.Db,
671
+
orm.FilterEq("did", user.Did),
672
+
orm.FilterEq("instance", instance),
673
+
orm.FilterEq("subject", memberId.DID),
674
)
675
if err != nil || len(members) != 1 {
676
l.Error("failed to get member", "err", err)
···
681
// remove from db
682
if err = db.RemoveSpindleMember(
683
tx,
684
+
orm.FilterEq("did", user.Did),
685
+
orm.FilterEq("instance", instance),
686
+
orm.FilterEq("subject", memberId.DID),
687
); err != nil {
688
l.Error("failed to remove spindle member", "err", err)
689
fail()
+6
-5
appview/state/gfi.go
+6
-5
appview/state/gfi.go
···
11
"tangled.org/core/appview/pages"
12
"tangled.org/core/appview/pagination"
13
"tangled.org/core/consts"
14
)
15
16
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
···
20
21
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
22
23
-
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
24
if err != nil {
25
log.Println("failed to get gfi label def", err)
26
s.pages.Error500(w)
27
return
28
}
29
30
-
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
31
if err != nil {
32
log.Println("failed to get repo labels", err)
33
s.pages.Error503(w)
···
55
pagination.Page{
56
Limit: 500,
57
},
58
-
db.FilterIn("repo_at", repoUris),
59
-
db.FilterEq("open", 1),
60
)
61
if err != nil {
62
log.Println("failed to get issues", err)
···
132
}
133
134
if len(uriList) > 0 {
135
-
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
136
if err != nil {
137
log.Println("failed to fetch labels", err)
138
}
···
11
"tangled.org/core/appview/pages"
12
"tangled.org/core/appview/pagination"
13
"tangled.org/core/consts"
14
+
"tangled.org/core/orm"
15
)
16
17
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
···
21
22
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
23
24
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", goodFirstIssueLabel))
25
if err != nil {
26
log.Println("failed to get gfi label def", err)
27
s.pages.Error500(w)
28
return
29
}
30
31
+
repoLabels, err := db.GetRepoLabels(s.db, orm.FilterEq("label_at", goodFirstIssueLabel))
32
if err != nil {
33
log.Println("failed to get repo labels", err)
34
s.pages.Error503(w)
···
56
pagination.Page{
57
Limit: 500,
58
},
59
+
orm.FilterIn("repo_at", repoUris),
60
+
orm.FilterEq("open", 1),
61
)
62
if err != nil {
63
log.Println("failed to get issues", err)
···
133
}
134
135
if len(uriList) > 0 {
136
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList))
137
if err != nil {
138
log.Println("failed to fetch labels", err)
139
}
+17
appview/state/git_http.go
+17
appview/state/git_http.go
···
25
26
}
27
28
+
func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) {
29
+
user, ok := r.Context().Value("resolvedId").(identity.Identity)
30
+
if !ok {
31
+
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
+
return
33
+
}
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
+
36
+
scheme := "https"
37
+
if s.config.Core.Dev {
38
+
scheme = "http"
39
+
}
40
+
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
+
s.proxyRequest(w, r, targetURL)
43
+
}
44
+
45
func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
46
user, ok := r.Context().Value("resolvedId").(identity.Identity)
47
if !ok {
+6
-5
appview/state/knotstream.go
+6
-5
appview/state/knotstream.go
···
16
ec "tangled.org/core/eventconsumer"
17
"tangled.org/core/eventconsumer/cursor"
18
"tangled.org/core/log"
19
"tangled.org/core/rbac"
20
"tangled.org/core/workflow"
21
···
30
31
knots, err := db.GetRegistrations(
32
d,
33
-
db.FilterIsNot("registered", "null"),
34
)
35
if err != nil {
36
return nil, err
···
143
repos, err := db.GetRepos(
144
d,
145
0,
146
-
db.FilterEq("did", record.RepoDid),
147
-
db.FilterEq("name", record.RepoName),
148
)
149
if err != nil {
150
return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err)
···
209
repos, err := db.GetRepos(
210
d,
211
0,
212
-
db.FilterEq("did", record.TriggerMetadata.Repo.Did),
213
-
db.FilterEq("name", record.TriggerMetadata.Repo.Repo),
214
)
215
if err != nil {
216
return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
···
16
ec "tangled.org/core/eventconsumer"
17
"tangled.org/core/eventconsumer/cursor"
18
"tangled.org/core/log"
19
+
"tangled.org/core/orm"
20
"tangled.org/core/rbac"
21
"tangled.org/core/workflow"
22
···
31
32
knots, err := db.GetRegistrations(
33
d,
34
+
orm.FilterIsNot("registered", "null"),
35
)
36
if err != nil {
37
return nil, err
···
144
repos, err := db.GetRepos(
145
d,
146
0,
147
+
orm.FilterEq("did", record.RepoDid),
148
+
orm.FilterEq("name", record.RepoName),
149
)
150
if err != nil {
151
return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err)
···
210
repos, err := db.GetRepos(
211
d,
212
0,
213
+
orm.FilterEq("did", record.TriggerMetadata.Repo.Did),
214
+
orm.FilterEq("name", record.TriggerMetadata.Repo.Repo),
215
)
216
if err != nil {
217
return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
+29
appview/state/manifest.go
+29
appview/state/manifest.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
)
7
+
8
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
9
+
// https://www.w3.org/TR/appmanifest/
10
+
var manifestData = map[string]any{
11
+
"name": "tangled",
12
+
"description": "tightly-knit social coding.",
13
+
"icons": []map[string]string{
14
+
{
15
+
"src": "/static/logos/dolly.svg",
16
+
"sizes": "144x144",
17
+
},
18
+
},
19
+
"start_url": "/",
20
+
"id": "https://tangled.org",
21
+
"display": "standalone",
22
+
"background_color": "#111827",
23
+
"theme_color": "#111827",
24
+
}
25
+
26
+
func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) {
27
+
w.Header().Set("Content-Type", "application/manifest+json")
28
+
json.NewEncoder(w).Encode(manifestData)
29
+
}
+30
-21
appview/state/profile.go
+30
-21
appview/state/profile.go
···
19
"tangled.org/core/appview/db"
20
"tangled.org/core/appview/models"
21
"tangled.org/core/appview/pages"
22
)
23
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
56
return nil, fmt.Errorf("failed to get profile: %w", err)
57
}
58
59
-
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
60
if err != nil {
61
return nil, fmt.Errorf("failed to get repo count: %w", err)
62
}
63
64
-
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
65
if err != nil {
66
return nil, fmt.Errorf("failed to get string count: %w", err)
67
}
68
69
-
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
70
if err != nil {
71
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
}
···
86
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
87
punchcard, err := db.MakePunchcard(
88
s.db,
89
-
db.FilterEq("did", did),
90
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
91
-
db.FilterLte("date", now.Format(time.DateOnly)),
92
)
93
if err != nil {
94
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
···
96
97
return &pages.ProfileCard{
98
UserDid: did,
99
-
UserHandle: ident.Handle.String(),
100
Profile: profile,
101
FollowStatus: followStatus,
102
Stats: pages.ProfileStats{
···
119
s.pages.Error500(w)
120
return
121
}
122
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
123
124
repos, err := db.GetRepos(
125
s.db,
126
0,
127
-
db.FilterEq("did", profile.UserDid),
128
)
129
if err != nil {
130
l.Error("failed to fetch repos", "err", err)
···
162
l.Error("failed to create timeline", "err", err)
163
}
164
165
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
166
LoggedInUser: s.oauth.GetUser(r),
167
Card: profile,
···
180
s.pages.Error500(w)
181
return
182
}
183
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
184
185
repos, err := db.GetRepos(
186
s.db,
187
0,
188
-
db.FilterEq("did", profile.UserDid),
189
)
190
if err != nil {
191
l.Error("failed to get repos", "err", err)
···
209
s.pages.Error500(w)
210
return
211
}
212
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
213
214
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
215
if err != nil {
216
l.Error("failed to get stars", "err", err)
217
s.pages.Error500(w)
···
219
}
220
var repos []models.Repo
221
for _, s := range stars {
222
-
if s.Repo != nil {
223
-
repos = append(repos, *s.Repo)
224
-
}
225
}
226
227
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
240
s.pages.Error500(w)
241
return
242
}
243
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
244
245
-
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
246
if err != nil {
247
l.Error("failed to get strings", "err", err)
248
s.pages.Error500(w)
···
272
if err != nil {
273
return nil, err
274
}
275
-
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
276
277
loggedInUser := s.oauth.GetUser(r)
278
params := FollowsPageParams{
···
294
followDids = append(followDids, extractDid(follow))
295
}
296
297
-
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
298
if err != nil {
299
l.Error("failed to get profiles", "followDids", followDids, "err", err)
300
return ¶ms, err
···
697
log.Printf("getting profile data for %s: %s", user.Did, err)
698
}
699
700
-
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
701
if err != nil {
702
log.Printf("getting repos for %s: %s", user.Did, err)
703
}
···
19
"tangled.org/core/appview/db"
20
"tangled.org/core/appview/models"
21
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/orm"
23
)
24
25
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
57
return nil, fmt.Errorf("failed to get profile: %w", err)
58
}
59
60
+
repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did))
61
if err != nil {
62
return nil, fmt.Errorf("failed to get repo count: %w", err)
63
}
64
65
+
stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did))
66
if err != nil {
67
return nil, fmt.Errorf("failed to get string count: %w", err)
68
}
69
70
+
starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did))
71
if err != nil {
72
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
73
}
···
87
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
88
punchcard, err := db.MakePunchcard(
89
s.db,
90
+
orm.FilterEq("did", did),
91
+
orm.FilterGte("date", startOfYear.Format(time.DateOnly)),
92
+
orm.FilterLte("date", now.Format(time.DateOnly)),
93
)
94
if err != nil {
95
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
···
97
98
return &pages.ProfileCard{
99
UserDid: did,
100
Profile: profile,
101
FollowStatus: followStatus,
102
Stats: pages.ProfileStats{
···
119
s.pages.Error500(w)
120
return
121
}
122
+
l = l.With("profileDid", profile.UserDid)
123
124
repos, err := db.GetRepos(
125
s.db,
126
0,
127
+
orm.FilterEq("did", profile.UserDid),
128
)
129
if err != nil {
130
l.Error("failed to fetch repos", "err", err)
···
162
l.Error("failed to create timeline", "err", err)
163
}
164
165
+
// populate commit counts in the timeline, using the punchcard
166
+
now := time.Now()
167
+
for _, p := range profile.Punchcard.Punches {
168
+
years := now.Year() - p.Date.Year()
169
+
months := int(now.Month() - p.Date.Month())
170
+
monthsAgo := years*12 + months
171
+
if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) {
172
+
timeline.ByMonth[monthsAgo].Commits += p.Count
173
+
}
174
+
}
175
+
176
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
177
LoggedInUser: s.oauth.GetUser(r),
178
Card: profile,
···
191
s.pages.Error500(w)
192
return
193
}
194
+
l = l.With("profileDid", profile.UserDid)
195
196
repos, err := db.GetRepos(
197
s.db,
198
0,
199
+
orm.FilterEq("did", profile.UserDid),
200
)
201
if err != nil {
202
l.Error("failed to get repos", "err", err)
···
220
s.pages.Error500(w)
221
return
222
}
223
+
l = l.With("profileDid", profile.UserDid)
224
225
+
stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid))
226
if err != nil {
227
l.Error("failed to get stars", "err", err)
228
s.pages.Error500(w)
···
230
}
231
var repos []models.Repo
232
for _, s := range stars {
233
+
repos = append(repos, *s.Repo)
234
}
235
236
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
249
s.pages.Error500(w)
250
return
251
}
252
+
l = l.With("profileDid", profile.UserDid)
253
254
+
strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid))
255
if err != nil {
256
l.Error("failed to get strings", "err", err)
257
s.pages.Error500(w)
···
281
if err != nil {
282
return nil, err
283
}
284
+
l = l.With("profileDid", profile.UserDid)
285
286
loggedInUser := s.oauth.GetUser(r)
287
params := FollowsPageParams{
···
303
followDids = append(followDids, extractDid(follow))
304
}
305
306
+
profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids))
307
if err != nil {
308
l.Error("failed to get profiles", "followDids", followDids, "err", err)
309
return ¶ms, err
···
706
log.Printf("getting profile data for %s: %s", user.Did, err)
707
}
708
709
+
repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did))
710
if err != nil {
711
log.Printf("getting repos for %s: %s", user.Did, err)
712
}
+11
-5
appview/state/router.go
+11
-5
appview/state/router.go
···
32
s.pages,
33
)
34
35
-
router.Get("/favicon.svg", s.Favicon)
36
-
router.Get("/favicon.ico", s.Favicon)
37
-
router.Get("/pwa-manifest.json", s.PWAManifest)
38
router.Get("/robots.txt", s.RobotsTxt)
39
40
userRouter := s.UserRouter(&middleware)
···
101
102
// These routes get proxied to the knot
103
r.Get("/info/refs", s.InfoRefs)
104
r.Post("/git-upload-pack", s.UploadPack)
105
r.Post("/git-receive-pack", s.ReceivePack)
106
···
108
})
109
110
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
111
s.pages.Error404(w)
112
})
113
···
166
167
r.Mount("/settings", s.SettingsRouter())
168
r.Mount("/strings", s.StringsRouter(mw))
169
-
r.Mount("/knots", s.KnotsRouter())
170
-
r.Mount("/spindles", s.SpindlesRouter())
171
r.Mount("/notifications", s.NotificationsRouter(mw))
172
173
r.Mount("/signup", s.SignupRouter())
···
179
r.Get("/brand", s.Brand)
180
181
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
182
s.pages.Error404(w)
183
})
184
return r
···
261
issues := issues.New(
262
s.oauth,
263
s.repoResolver,
264
s.pages,
265
s.idResolver,
266
s.db,
267
s.config,
268
s.notifier,
···
279
s.repoResolver,
280
s.pages,
281
s.idResolver,
282
s.db,
283
s.config,
284
s.notifier,
···
32
s.pages,
33
)
34
35
+
router.Get("/pwa-manifest.json", s.WebAppManifest)
36
router.Get("/robots.txt", s.RobotsTxt)
37
38
userRouter := s.UserRouter(&middleware)
···
99
100
// These routes get proxied to the knot
101
r.Get("/info/refs", s.InfoRefs)
102
+
r.Post("/git-upload-archive", s.UploadArchive)
103
r.Post("/git-upload-pack", s.UploadPack)
104
r.Post("/git-receive-pack", s.ReceivePack)
105
···
107
})
108
109
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
110
+
w.WriteHeader(http.StatusNotFound)
111
s.pages.Error404(w)
112
})
113
···
166
167
r.Mount("/settings", s.SettingsRouter())
168
r.Mount("/strings", s.StringsRouter(mw))
169
+
170
+
r.Mount("/settings/knots", s.KnotsRouter())
171
+
r.Mount("/settings/spindles", s.SpindlesRouter())
172
+
173
r.Mount("/notifications", s.NotificationsRouter(mw))
174
175
r.Mount("/signup", s.SignupRouter())
···
181
r.Get("/brand", s.Brand)
182
183
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
184
+
w.WriteHeader(http.StatusNotFound)
185
s.pages.Error404(w)
186
})
187
return r
···
264
issues := issues.New(
265
s.oauth,
266
s.repoResolver,
267
+
s.enforcer,
268
s.pages,
269
s.idResolver,
270
+
s.mentionsResolver,
271
s.db,
272
s.config,
273
s.notifier,
···
284
s.repoResolver,
285
s.pages,
286
s.idResolver,
287
+
s.mentionsResolver,
288
s.db,
289
s.config,
290
s.notifier,
+2
-1
appview/state/spindlestream.go
+2
-1
appview/state/spindlestream.go
···
17
ec "tangled.org/core/eventconsumer"
18
"tangled.org/core/eventconsumer/cursor"
19
"tangled.org/core/log"
20
"tangled.org/core/rbac"
21
spindle "tangled.org/core/spindle/models"
22
)
···
27
28
spindles, err := db.GetSpindles(
29
d,
30
-
db.FilterIsNot("verified", "null"),
31
)
32
if err != nil {
33
return nil, err
···
17
ec "tangled.org/core/eventconsumer"
18
"tangled.org/core/eventconsumer/cursor"
19
"tangled.org/core/log"
20
+
"tangled.org/core/orm"
21
"tangled.org/core/rbac"
22
spindle "tangled.org/core/spindle/models"
23
)
···
28
29
spindles, err := db.GetSpindles(
30
d,
31
+
orm.FilterIsNot("verified", "null"),
32
)
33
if err != nil {
34
return nil, err
+9
-13
appview/state/star.go
+9
-13
appview/state/star.go
···
57
log.Println("created atproto record: ", resp.Uri)
58
59
star := &models.Star{
60
-
StarredByDid: currentUser.Did,
61
-
RepoAt: subjectUri,
62
-
Rkey: rkey,
63
}
64
65
err = db.AddStar(s.db, star)
···
75
76
s.notifier.NewStar(r.Context(), star)
77
78
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
79
IsStarred: true,
80
-
RepoAt: subjectUri,
81
-
Stats: models.RepoStats{
82
-
StarCount: starCount,
83
-
},
84
})
85
86
return
···
117
118
s.notifier.DeleteStar(r.Context(), star)
119
120
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
121
IsStarred: false,
122
-
RepoAt: subjectUri,
123
-
Stats: models.RepoStats{
124
-
StarCount: starCount,
125
-
},
126
})
127
128
return
···
57
log.Println("created atproto record: ", resp.Uri)
58
59
star := &models.Star{
60
+
Did: currentUser.Did,
61
+
RepoAt: subjectUri,
62
+
Rkey: rkey,
63
}
64
65
err = db.AddStar(s.db, star)
···
75
76
s.notifier.NewStar(r.Context(), star)
77
78
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
79
IsStarred: true,
80
+
SubjectAt: subjectUri,
81
+
StarCount: starCount,
82
})
83
84
return
···
115
116
s.notifier.DeleteStar(r.Context(), star)
117
118
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
119
IsStarred: false,
120
+
SubjectAt: subjectUri,
121
+
StarCount: starCount,
122
})
123
124
return
+30
-60
appview/state/state.go
+30
-60
appview/state/state.go
···
15
"tangled.org/core/appview/config"
16
"tangled.org/core/appview/db"
17
"tangled.org/core/appview/indexer"
18
"tangled.org/core/appview/models"
19
"tangled.org/core/appview/notify"
20
dbnotify "tangled.org/core/appview/notify/db"
···
29
"tangled.org/core/jetstream"
30
"tangled.org/core/log"
31
tlog "tangled.org/core/log"
32
"tangled.org/core/rbac"
33
"tangled.org/core/tid"
34
···
42
)
43
44
type State struct {
45
-
db *db.DB
46
-
notifier notify.Notifier
47
-
indexer *indexer.Indexer
48
-
oauth *oauth.OAuth
49
-
enforcer *rbac.Enforcer
50
-
pages *pages.Pages
51
-
idResolver *idresolver.Resolver
52
-
posthog posthog.Client
53
-
jc *jetstream.JetstreamClient
54
-
config *config.Config
55
-
repoResolver *reporesolver.RepoResolver
56
-
knotstream *eventconsumer.Consumer
57
-
spindlestream *eventconsumer.Consumer
58
-
logger *slog.Logger
59
-
validator *validator.Validator
60
}
61
62
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
96
}
97
validator := validator.New(d, res, enforcer)
98
99
-
repoResolver := reporesolver.New(config, enforcer, res, d)
100
101
wrapper := db.DbWrapper{Execer: d}
102
jc, err := jetstream.NewJetstreamClient(
···
178
enforcer,
179
pages,
180
res,
181
posthog,
182
jc,
183
config,
···
196
return s.db.Close()
197
}
198
199
-
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
200
-
w.Header().Set("Content-Type", "image/svg+xml")
201
-
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
202
-
w.Header().Set("ETag", `"favicon-svg-v1"`)
203
-
204
-
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
205
-
w.WriteHeader(http.StatusNotModified)
206
-
return
207
-
}
208
-
209
-
s.pages.Favicon(w)
210
-
}
211
-
212
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
213
w.Header().Set("Content-Type", "text/plain")
214
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
219
w.Write([]byte(robotsTxt))
220
}
221
222
-
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
223
-
const manifestJson = `{
224
-
"name": "tangled",
225
-
"description": "tightly-knit social coding.",
226
-
"icons": [
227
-
{
228
-
"src": "/favicon.svg",
229
-
"sizes": "144x144"
230
-
}
231
-
],
232
-
"start_url": "/",
233
-
"id": "org.tangled",
234
-
235
-
"display": "standalone",
236
-
"background_color": "#111827",
237
-
"theme_color": "#111827"
238
-
}`
239
-
240
-
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
241
-
w.Header().Set("Content-Type", "application/json")
242
-
w.Write([]byte(manifestJson))
243
-
}
244
-
245
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
246
user := s.oauth.GetUser(r)
247
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
294
return
295
}
296
297
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
if err != nil {
299
// non-fatal
300
}
···
318
319
regs, err := db.GetRegistrations(
320
s.db,
321
-
db.FilterEq("did", user.Did),
322
-
db.FilterEq("needs_upgrade", 1),
323
)
324
if err != nil {
325
l.Error("non-fatal: failed to get registrations", "err", err)
···
327
328
spindles, err := db.GetSpindles(
329
s.db,
330
-
db.FilterEq("owner", user.Did),
331
-
db.FilterEq("needs_upgrade", 1),
332
)
333
if err != nil {
334
l.Error("non-fatal: failed to get spindles", "err", err)
···
499
// Check for existing repos
500
existingRepo, err := db.GetRepo(
501
s.db,
502
-
db.FilterEq("did", user.Did),
503
-
db.FilterEq("name", repoName),
504
)
505
if err == nil && existingRepo != nil {
506
l.Info("repo exists")
···
660
}
661
662
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
663
-
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
664
if err != nil {
665
return err
666
}
···
15
"tangled.org/core/appview/config"
16
"tangled.org/core/appview/db"
17
"tangled.org/core/appview/indexer"
18
+
"tangled.org/core/appview/mentions"
19
"tangled.org/core/appview/models"
20
"tangled.org/core/appview/notify"
21
dbnotify "tangled.org/core/appview/notify/db"
···
30
"tangled.org/core/jetstream"
31
"tangled.org/core/log"
32
tlog "tangled.org/core/log"
33
+
"tangled.org/core/orm"
34
"tangled.org/core/rbac"
35
"tangled.org/core/tid"
36
···
44
)
45
46
type State struct {
47
+
db *db.DB
48
+
notifier notify.Notifier
49
+
indexer *indexer.Indexer
50
+
oauth *oauth.OAuth
51
+
enforcer *rbac.Enforcer
52
+
pages *pages.Pages
53
+
idResolver *idresolver.Resolver
54
+
mentionsResolver *mentions.Resolver
55
+
posthog posthog.Client
56
+
jc *jetstream.JetstreamClient
57
+
config *config.Config
58
+
repoResolver *reporesolver.RepoResolver
59
+
knotstream *eventconsumer.Consumer
60
+
spindlestream *eventconsumer.Consumer
61
+
logger *slog.Logger
62
+
validator *validator.Validator
63
}
64
65
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
99
}
100
validator := validator.New(d, res, enforcer)
101
102
+
repoResolver := reporesolver.New(config, enforcer, d)
103
+
104
+
mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver"))
105
106
wrapper := db.DbWrapper{Execer: d}
107
jc, err := jetstream.NewJetstreamClient(
···
183
enforcer,
184
pages,
185
res,
186
+
mentionsResolver,
187
posthog,
188
jc,
189
config,
···
202
return s.db.Close()
203
}
204
205
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
206
w.Header().Set("Content-Type", "text/plain")
207
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
212
w.Write([]byte(robotsTxt))
213
}
214
215
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
216
user := s.oauth.GetUser(r)
217
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
264
return
265
}
266
267
+
gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
268
if err != nil {
269
// non-fatal
270
}
···
288
289
regs, err := db.GetRegistrations(
290
s.db,
291
+
orm.FilterEq("did", user.Did),
292
+
orm.FilterEq("needs_upgrade", 1),
293
)
294
if err != nil {
295
l.Error("non-fatal: failed to get registrations", "err", err)
···
297
298
spindles, err := db.GetSpindles(
299
s.db,
300
+
orm.FilterEq("owner", user.Did),
301
+
orm.FilterEq("needs_upgrade", 1),
302
)
303
if err != nil {
304
l.Error("non-fatal: failed to get spindles", "err", err)
···
469
// Check for existing repos
470
existingRepo, err := db.GetRepo(
471
s.db,
472
+
orm.FilterEq("did", user.Did),
473
+
orm.FilterEq("name", repoName),
474
)
475
if err == nil && existingRepo != nil {
476
l.Info("repo exists")
···
630
}
631
632
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
633
+
defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults))
634
if err != nil {
635
return err
636
}
+21
-8
appview/strings/strings.go
+21
-8
appview/strings/strings.go
···
17
"tangled.org/core/appview/pages"
18
"tangled.org/core/appview/pages/markup"
19
"tangled.org/core/idresolver"
20
"tangled.org/core/tid"
21
22
"github.com/bluesky-social/indigo/api/atproto"
···
108
strings, err := db.GetStrings(
109
s.Db,
110
0,
111
-
db.FilterEq("did", id.DID),
112
-
db.FilterEq("rkey", rkey),
113
)
114
if err != nil {
115
l.Error("failed to fetch string", "err", err)
···
148
showRendered = r.URL.Query().Get("code") != "true"
149
}
150
151
s.Pages.SingleString(w, pages.SingleStringParams{
152
-
LoggedInUser: s.OAuth.GetUser(r),
153
RenderToggle: renderToggle,
154
ShowRendered: showRendered,
155
-
String: string,
156
Stats: string.Stats(),
157
Owner: id,
158
})
159
}
···
187
all, err := db.GetStrings(
188
s.Db,
189
0,
190
-
db.FilterEq("did", id.DID),
191
-
db.FilterEq("rkey", rkey),
192
)
193
if err != nil {
194
l.Error("failed to fetch string", "err", err)
···
396
397
if err := db.DeleteString(
398
s.Db,
399
-
db.FilterEq("did", user.Did),
400
-
db.FilterEq("rkey", rkey),
401
); err != nil {
402
fail("Failed to delete string.", err)
403
return
···
17
"tangled.org/core/appview/pages"
18
"tangled.org/core/appview/pages/markup"
19
"tangled.org/core/idresolver"
20
+
"tangled.org/core/orm"
21
"tangled.org/core/tid"
22
23
"github.com/bluesky-social/indigo/api/atproto"
···
109
strings, err := db.GetStrings(
110
s.Db,
111
0,
112
+
orm.FilterEq("did", id.DID),
113
+
orm.FilterEq("rkey", rkey),
114
)
115
if err != nil {
116
l.Error("failed to fetch string", "err", err)
···
149
showRendered = r.URL.Query().Get("code") != "true"
150
}
151
152
+
starCount, err := db.GetStarCount(s.Db, string.AtUri())
153
+
if err != nil {
154
+
l.Error("failed to get star count", "err", err)
155
+
}
156
+
user := s.OAuth.GetUser(r)
157
+
isStarred := false
158
+
if user != nil {
159
+
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
160
+
}
161
+
162
s.Pages.SingleString(w, pages.SingleStringParams{
163
+
LoggedInUser: user,
164
RenderToggle: renderToggle,
165
ShowRendered: showRendered,
166
+
String: &string,
167
Stats: string.Stats(),
168
+
IsStarred: isStarred,
169
+
StarCount: starCount,
170
Owner: id,
171
})
172
}
···
200
all, err := db.GetStrings(
201
s.Db,
202
0,
203
+
orm.FilterEq("did", id.DID),
204
+
orm.FilterEq("rkey", rkey),
205
)
206
if err != nil {
207
l.Error("failed to fetch string", "err", err)
···
409
410
if err := db.DeleteString(
411
s.Db,
412
+
orm.FilterEq("did", user.Did),
413
+
orm.FilterEq("rkey", rkey),
414
); err != nil {
415
fail("Failed to delete string.", err)
416
return
+2
-1
appview/validator/issue.go
+2
-1
appview/validator/issue.go
···
6
7
"tangled.org/core/appview/db"
8
"tangled.org/core/appview/models"
9
)
10
11
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
12
// if comments have parents, only ingest ones that are 1 level deep
13
if comment.ReplyTo != nil {
14
-
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
15
if err != nil {
16
return fmt.Errorf("failed to fetch parent comment: %w", err)
17
}
···
6
7
"tangled.org/core/appview/db"
8
"tangled.org/core/appview/models"
9
+
"tangled.org/core/orm"
10
)
11
12
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
13
// if comments have parents, only ingest ones that are 1 level deep
14
if comment.ReplyTo != nil {
15
+
parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo))
16
if err != nil {
17
return fmt.Errorf("failed to fetch parent comment: %w", err)
18
}
+182
cmd/dolly/main.go
+182
cmd/dolly/main.go
···
···
1
+
package main
2
+
3
+
import (
4
+
"bytes"
5
+
"flag"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"os"
11
+
"path/filepath"
12
+
"strconv"
13
+
"strings"
14
+
"text/template"
15
+
16
+
"github.com/srwiley/oksvg"
17
+
"github.com/srwiley/rasterx"
18
+
"golang.org/x/image/draw"
19
+
"tangled.org/core/appview/pages"
20
+
"tangled.org/core/ico"
21
+
)
22
+
23
+
func main() {
24
+
var (
25
+
size string
26
+
fillColor string
27
+
output string
28
+
)
29
+
30
+
flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)")
31
+
flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)")
32
+
flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)")
33
+
flag.Parse()
34
+
35
+
width, height, err := parseSize(size)
36
+
if err != nil {
37
+
fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err)
38
+
os.Exit(1)
39
+
}
40
+
41
+
// Detect format from file extension
42
+
ext := strings.ToLower(filepath.Ext(output))
43
+
format := strings.TrimPrefix(ext, ".")
44
+
45
+
if format != "svg" && format != "png" && format != "ico" {
46
+
fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext)
47
+
os.Exit(1)
48
+
}
49
+
50
+
if !isValidHexColor(fillColor) {
51
+
fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor)
52
+
os.Exit(1)
53
+
}
54
+
55
+
svgData, err := dolly(fillColor)
56
+
if err != nil {
57
+
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
58
+
os.Exit(1)
59
+
}
60
+
61
+
// Create output directory if it doesn't exist
62
+
dir := filepath.Dir(output)
63
+
if dir != "" && dir != "." {
64
+
if err := os.MkdirAll(dir, 0755); err != nil {
65
+
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
66
+
os.Exit(1)
67
+
}
68
+
}
69
+
70
+
switch format {
71
+
case "svg":
72
+
err = saveSVG(svgData, output, width, height)
73
+
case "png":
74
+
err = savePNG(svgData, output, width, height)
75
+
case "ico":
76
+
err = saveICO(svgData, output, width, height)
77
+
}
78
+
79
+
if err != nil {
80
+
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
81
+
os.Exit(1)
82
+
}
83
+
84
+
fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height)
85
+
}
86
+
87
+
func dolly(hexColor string) ([]byte, error) {
88
+
tpl, err := template.New("dolly").
89
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
94
+
var svgData bytes.Buffer
95
+
if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{
96
+
FillColor: hexColor,
97
+
}); err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
return svgData.Bytes(), nil
102
+
}
103
+
104
+
func svgToImage(svgData []byte, w, h int) (image.Image, error) {
105
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
106
+
if err != nil {
107
+
return nil, fmt.Errorf("error parsing SVG: %v", err)
108
+
}
109
+
110
+
icon.SetTarget(0, 0, float64(w), float64(h))
111
+
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
112
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
113
+
scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
114
+
raster := rasterx.NewDasher(w, h, scanner)
115
+
icon.Draw(raster, 1.0)
116
+
117
+
return rgba, nil
118
+
}
119
+
120
+
func parseSize(size string) (int, int, error) {
121
+
parts := strings.Split(size, "x")
122
+
if len(parts) != 2 {
123
+
return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT")
124
+
}
125
+
126
+
width, err := strconv.Atoi(parts[0])
127
+
if err != nil {
128
+
return 0, 0, fmt.Errorf("invalid width: %v", err)
129
+
}
130
+
131
+
height, err := strconv.Atoi(parts[1])
132
+
if err != nil {
133
+
return 0, 0, fmt.Errorf("invalid height: %v", err)
134
+
}
135
+
136
+
if width <= 0 || height <= 0 {
137
+
return 0, 0, fmt.Errorf("width and height must be positive")
138
+
}
139
+
140
+
return width, height, nil
141
+
}
142
+
143
+
func isValidHexColor(hex string) bool {
144
+
if len(hex) != 7 || hex[0] != '#' {
145
+
return false
146
+
}
147
+
_, err := strconv.ParseUint(hex[1:], 16, 32)
148
+
return err == nil
149
+
}
150
+
151
+
func saveSVG(svgData []byte, filepath string, _, _ int) error {
152
+
return os.WriteFile(filepath, svgData, 0644)
153
+
}
154
+
155
+
func savePNG(svgData []byte, filepath string, width, height int) error {
156
+
img, err := svgToImage(svgData, width, height)
157
+
if err != nil {
158
+
return err
159
+
}
160
+
161
+
f, err := os.Create(filepath)
162
+
if err != nil {
163
+
return err
164
+
}
165
+
defer f.Close()
166
+
167
+
return png.Encode(f, img)
168
+
}
169
+
170
+
func saveICO(svgData []byte, filepath string, width, height int) error {
171
+
img, err := svgToImage(svgData, width, height)
172
+
if err != nil {
173
+
return err
174
+
}
175
+
176
+
icoData, err := ico.ImageToIco(img)
177
+
if err != nil {
178
+
return err
179
+
}
180
+
181
+
return os.WriteFile(filepath, icoData, 0644)
182
+
}
+1
-34
crypto/verify.go
+1
-34
crypto/verify.go
···
5
"crypto/sha256"
6
"encoding/base64"
7
"fmt"
8
-
"strings"
9
10
"github.com/hiddeco/sshsig"
11
"golang.org/x/crypto/ssh"
12
-
"tangled.org/core/types"
13
)
14
15
func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
···
28
// multiple algorithms but sha-512 is most secure, and git's ssh signing defaults
29
// to sha-512 for all key types anyway.
30
err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git")
31
-
return err, err == nil
32
-
}
33
34
-
// VerifyCommitSignature reconstructs the payload used to sign a commit. This is
35
-
// essentially the git cat-file output but without the gpgsig header.
36
-
//
37
-
// Caveats: signature verification will fail on commits with more than one parent,
38
-
// i.e. merge commits, because types.NiceDiff doesn't carry more than one Parent field
39
-
// and we are unable to reconstruct the payload correctly.
40
-
//
41
-
// Ideally this should directly operate on an *object.Commit.
42
-
func VerifyCommitSignature(pubKey string, commit types.NiceDiff) (error, bool) {
43
-
signature := commit.Commit.PGPSignature
44
-
45
-
author := bytes.NewBuffer([]byte{})
46
-
committer := bytes.NewBuffer([]byte{})
47
-
commit.Commit.Author.Encode(author)
48
-
commit.Commit.Committer.Encode(committer)
49
-
50
-
payload := strings.Builder{}
51
-
52
-
fmt.Fprintf(&payload, "tree %s\n", commit.Commit.Tree)
53
-
if commit.Commit.Parent != "" {
54
-
fmt.Fprintf(&payload, "parent %s\n", commit.Commit.Parent)
55
-
}
56
-
fmt.Fprintf(&payload, "author %s\n", author.String())
57
-
fmt.Fprintf(&payload, "committer %s\n", committer.String())
58
-
if commit.Commit.ChangedId != "" {
59
-
fmt.Fprintf(&payload, "change-id %s\n", commit.Commit.ChangedId)
60
-
}
61
-
fmt.Fprintf(&payload, "\n%s", commit.Commit.Message)
62
-
63
-
return VerifySignature([]byte(pubKey), []byte(signature), []byte(payload.String()))
64
}
65
66
// SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
···
5
"crypto/sha256"
6
"encoding/base64"
7
"fmt"
8
9
"github.com/hiddeco/sshsig"
10
"golang.org/x/crypto/ssh"
11
)
12
13
func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
···
26
// multiple algorithms but sha-512 is most secure, and git's ssh signing defaults
27
// to sha-512 for all key types anyway.
28
err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git")
29
30
+
return err, err == nil
31
}
32
33
// SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
+1527
docs/DOCS.md
+1527
docs/DOCS.md
···
···
1
+
---
2
+
title: Tangled docs
3
+
author: The Tangled Contributors
4
+
date: 21 Sun, Dec 2025
5
+
abstract: |
6
+
Tangled is a decentralized code hosting and collaboration
7
+
platform. Every component of Tangled is open-source and
8
+
self-hostable. [tangled.org](https://tangled.org) also
9
+
provides hosting and CI services that are free to use.
10
+
11
+
There are several models for decentralized code
12
+
collaboration platforms, ranging from ActivityPubโs
13
+
(Forgejo) federated model, to Radicleโs entirely P2P model.
14
+
Our approach attempts to be the best of both worlds by
15
+
adopting the AT Protocolโa protocol for building decentralized
16
+
social applications with a central identity
17
+
18
+
Our approach to this is the idea of โknotsโ. Knots are
19
+
lightweight, headless servers that enable users to host Git
20
+
repositories with ease. Knots are designed for either single
21
+
or multi-tenant use which is perfect for self-hosting on a
22
+
Raspberry Pi at home, or larger โcommunityโ servers. By
23
+
default, Tangled provides managed knots where you can host
24
+
your repositories for free.
25
+
26
+
The appview at tangled.org acts as a consolidated "view"
27
+
into the whole network, allowing users to access, clone and
28
+
contribute to repositories hosted across different knots
29
+
seamlessly.
30
+
---
31
+
32
+
# Quick start guide
33
+
34
+
## Login or sign up
35
+
36
+
You can [login](https://tangled.org) by using your AT Protocol
37
+
account. If you are unclear on what that means, simply head
38
+
to the [signup](https://tangled.org/signup) page and create
39
+
an account. By doing so, you will be choosing Tangled as
40
+
your account provider (you will be granted a handle of the
41
+
form `user.tngl.sh`).
42
+
43
+
In the AT Protocol network, users are free to choose their account
44
+
provider (known as a "Personal Data Service", or PDS), and
45
+
login to applications that support AT accounts.
46
+
47
+
You can think of it as "one account for all of the atmosphere"!
48
+
49
+
If you already have an AT account (you may have one if you
50
+
signed up to Bluesky, for example), you can login with the
51
+
same handle on Tangled (so just use `user.bsky.social` on
52
+
the login page).
53
+
54
+
## Add an SSH key
55
+
56
+
Once you are logged in, you can start creating repositories
57
+
and pushing code. Tangled supports pushing git repositories
58
+
over SSH.
59
+
60
+
First, you'll need to generate an SSH key if you don't
61
+
already have one:
62
+
63
+
```bash
64
+
ssh-keygen -t ed25519 -C "foo@bar.com"
65
+
```
66
+
67
+
When prompted, save the key to the default location
68
+
(`~/.ssh/id_ed25519`) and optionally set a passphrase.
69
+
70
+
Copy your public key to your clipboard:
71
+
72
+
```bash
73
+
# on X11
74
+
cat ~/.ssh/id_ed25519.pub | xclip -sel c
75
+
76
+
# on wayland
77
+
cat ~/.ssh/id_ed25519.pub | wl-copy
78
+
79
+
# on macos
80
+
cat ~/.ssh/id_ed25519.pub | pbcopy
81
+
```
82
+
83
+
Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
84
+
paste your public key, give it a descriptive name, and hit
85
+
save.
86
+
87
+
## Create a repository
88
+
89
+
Once your SSH key is added, create your first repository:
90
+
91
+
1. Hit the green `+` icon on the topbar, and select
92
+
repository
93
+
2. Enter a repository name
94
+
3. Add a description
95
+
4. Choose a knotserver to host this repository on
96
+
5. Hit create
97
+
98
+
Knots are self-hostable, lightweight Git servers that can
99
+
host your repository. Unlike traditional code forges, your
100
+
code can live on any server. Read the [Knots](TODO) section
101
+
for more.
102
+
103
+
## Configure SSH
104
+
105
+
To ensure Git uses the correct SSH key and connects smoothly
106
+
to Tangled, add this configuration to your `~/.ssh/config`
107
+
file:
108
+
109
+
```
110
+
Host tangled.org
111
+
Hostname tangled.org
112
+
User git
113
+
IdentityFile ~/.ssh/id_ed25519
114
+
AddressFamily inet
115
+
```
116
+
117
+
This tells SSH to use your specific key when connecting to
118
+
Tangled and prevents authentication issues if you have
119
+
multiple SSH keys.
120
+
121
+
Note that this configuration only works for knotservers that
122
+
are hosted by tangled.org. If you use a custom knot, refer
123
+
to the [Knots](TODO) section.
124
+
125
+
## Push your first repository
126
+
127
+
Initialize a new Git repository:
128
+
129
+
```bash
130
+
mkdir my-project
131
+
cd my-project
132
+
133
+
git init
134
+
echo "# My Project" > README.md
135
+
```
136
+
137
+
Add some content and push!
138
+
139
+
```bash
140
+
git add README.md
141
+
git commit -m "Initial commit"
142
+
git remote add origin git@tangled.org:user.tngl.sh/my-project
143
+
git push -u origin main
144
+
```
145
+
146
+
That's it! Your code is now hosted on Tangled.
147
+
148
+
## Migrating an existing repository
149
+
150
+
Moving your repositories from GitHub, GitLab, Bitbucket, or
151
+
any other Git forge to Tangled is straightforward. You'll
152
+
simply change your repository's remote URL. At the moment,
153
+
Tangled does not have any tooling to migrate data such as
154
+
GitHub issues or pull requests.
155
+
156
+
First, create a new repository on tangled.org as described
157
+
in the [Quick Start Guide](#create-a-repository).
158
+
159
+
Navigate to your existing local repository:
160
+
161
+
```bash
162
+
cd /path/to/your/existing/repo
163
+
```
164
+
165
+
You can inspect your existing Git remote like so:
166
+
167
+
```bash
168
+
git remote -v
169
+
```
170
+
171
+
You'll see something like:
172
+
173
+
```
174
+
origin git@github.com:username/my-project (fetch)
175
+
origin git@github.com:username/my-project (push)
176
+
```
177
+
178
+
Update the remote URL to point to tangled:
179
+
180
+
```bash
181
+
git remote set-url origin git@tangled.org:user.tngl.sh/my-project
182
+
```
183
+
184
+
Verify the change:
185
+
186
+
```bash
187
+
git remote -v
188
+
```
189
+
190
+
You should now see:
191
+
192
+
```
193
+
origin git@tangled.org:user.tngl.sh/my-project (fetch)
194
+
origin git@tangled.org:user.tngl.sh/my-project (push)
195
+
```
196
+
197
+
Push all your branches and tags to Tangled:
198
+
199
+
```bash
200
+
git push -u origin --all
201
+
git push -u origin --tags
202
+
```
203
+
204
+
Your repository is now migrated to Tangled! All commit
205
+
history, branches, and tags have been preserved.
206
+
207
+
## Mirroring a repository to Tangled
208
+
209
+
If you want to maintain your repository on multiple forges
210
+
simultaneously, for example, keeping your primary repository
211
+
on GitHub while mirroring to Tangled for backup or
212
+
redundancy, you can do so by adding multiple remotes.
213
+
214
+
You can configure your local repository to push to both
215
+
Tangled and, say, GitHub. You may already have the following
216
+
setup:
217
+
218
+
```
219
+
$ git remote -v
220
+
origin git@github.com:username/my-project (fetch)
221
+
origin git@github.com:username/my-project (push)
222
+
```
223
+
224
+
Now add Tangled as an additional push URL to the same
225
+
remote:
226
+
227
+
```bash
228
+
git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
229
+
```
230
+
231
+
You also need to re-add the original URL as a push
232
+
destination (Git replaces the push URL when you use `--add`
233
+
the first time):
234
+
235
+
```bash
236
+
git remote set-url --add --push origin git@github.com:username/my-project
237
+
```
238
+
239
+
Verify your configuration:
240
+
241
+
```
242
+
$ git remote -v
243
+
origin git@github.com:username/repo (fetch)
244
+
origin git@tangled.org:username/my-project (push)
245
+
origin git@github.com:username/repo (push)
246
+
```
247
+
248
+
Notice that there's one fetch URL (the primary remote) and
249
+
two push URLs. Now, whenever you push, Git will
250
+
automatically push to both remotes:
251
+
252
+
```bash
253
+
git push origin main
254
+
```
255
+
256
+
This single command pushes your `main` branch to both GitHub
257
+
and Tangled simultaneously.
258
+
259
+
To push all branches and tags:
260
+
261
+
```bash
262
+
git push origin --all
263
+
git push origin --tags
264
+
```
265
+
266
+
If you prefer more control over which remote you push to,
267
+
you can maintain separate remotes:
268
+
269
+
```bash
270
+
git remote add github git@github.com:username/my-project
271
+
git remote add tangled git@tangled.org:username/my-project
272
+
```
273
+
274
+
Then push to each explicitly:
275
+
276
+
```bash
277
+
git push github main
278
+
git push tangled main
279
+
```
280
+
281
+
# Knot self-hosting guide
282
+
283
+
So you want to run your own knot server? Great! Here are a few prerequisites:
284
+
285
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
286
+
2. A (sub)domain name. People generally use `knot.example.com`.
287
+
3. A valid SSL certificate for your domain.
288
+
289
+
## NixOS
290
+
291
+
Refer to the [knot
292
+
module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
293
+
for a full list of options. Sample configurations:
294
+
295
+
- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
296
+
- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25)
297
+
298
+
## Docker
299
+
300
+
Refer to
301
+
[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
302
+
Note that this is community maintained.
303
+
304
+
## Manual setup
305
+
306
+
First, clone this repository:
307
+
308
+
```
309
+
git clone https://tangled.org/@tangled.org/core
310
+
```
311
+
312
+
Then, build the `knot` CLI. This is the knot administration
313
+
and operation tool. For the purpose of this guide, we're
314
+
only concerned with these subcommands:
315
+
316
+
* `knot server`: the main knot server process, typically
317
+
run as a supervised service
318
+
* `knot guard`: handles role-based access control for git
319
+
over SSH (you'll never have to run this yourself)
320
+
* `knot keys`: fetches SSH keys associated with your knot;
321
+
we'll use this to generate the SSH
322
+
`AuthorizedKeysCommand`
323
+
324
+
```
325
+
cd core
326
+
export CGO_ENABLED=1
327
+
go build -o knot ./cmd/knot
328
+
```
329
+
330
+
Next, move the `knot` binary to a location owned by `root` --
331
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
332
+
333
+
```
334
+
sudo mv knot /usr/local/bin/knot
335
+
sudo chown root:root /usr/local/bin/knot
336
+
```
337
+
338
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really
339
+
specific permissions](https://stackoverflow.com/a/27638306). The
340
+
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
341
+
retrieve a user's public SSH keys dynamically for authentication. Let's
342
+
set that up.
343
+
344
+
```
345
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
346
+
Match User git
347
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
348
+
AuthorizedKeysCommandUser nobody
349
+
EOF
350
+
```
351
+
352
+
Then, reload `sshd`:
353
+
354
+
```
355
+
sudo systemctl reload ssh
356
+
```
357
+
358
+
Next, create the `git` user. We'll use the `git` user's home directory
359
+
to store repositories:
360
+
361
+
```
362
+
sudo adduser git
363
+
```
364
+
365
+
Create `/home/git/.knot.env` with the following, updating the values as
366
+
necessary. The `KNOT_SERVER_OWNER` should be set to your
367
+
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
368
+
369
+
```
370
+
KNOT_REPO_SCAN_PATH=/home/git
371
+
KNOT_SERVER_HOSTNAME=knot.example.com
372
+
APPVIEW_ENDPOINT=https://tangled.org
373
+
KNOT_SERVER_OWNER=did:plc:foobar
374
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
375
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376
+
```
377
+
378
+
If you run a Linux distribution that uses systemd, you can use the provided
379
+
service file to run the server. Copy
380
+
[`knotserver.service`](/systemd/knotserver.service)
381
+
to `/etc/systemd/system/`. Then, run:
382
+
383
+
```
384
+
systemctl enable knotserver
385
+
systemctl start knotserver
386
+
```
387
+
388
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
389
+
knot. Here's an example configuration for Nginx:
390
+
391
+
```
392
+
server {
393
+
listen 80;
394
+
listen [::]:80;
395
+
server_name knot.example.com;
396
+
397
+
location / {
398
+
proxy_pass http://localhost:5555;
399
+
proxy_set_header Host $host;
400
+
proxy_set_header X-Real-IP $remote_addr;
401
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
402
+
proxy_set_header X-Forwarded-Proto $scheme;
403
+
}
404
+
405
+
# wss endpoint for git events
406
+
location /events {
407
+
proxy_set_header X-Forwarded-For $remote_addr;
408
+
proxy_set_header Host $http_host;
409
+
proxy_set_header Upgrade websocket;
410
+
proxy_set_header Connection Upgrade;
411
+
proxy_pass http://localhost:5555;
412
+
}
413
+
# additional config for SSL/TLS go here.
414
+
}
415
+
416
+
```
417
+
418
+
Remember to use Let's Encrypt or similar to procure a certificate for your
419
+
knot domain.
420
+
421
+
You should now have a running knot server! You can finalize
422
+
your registration by hitting the `verify` button on the
423
+
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
424
+
a record on your PDS to announce the existence of the knot.
425
+
426
+
### Custom paths
427
+
428
+
(This section applies to manual setup only. Docker users should edit the mounts
429
+
in `docker-compose.yml` instead.)
430
+
431
+
Right now, the database and repositories of your knot lives in `/home/git`. You
432
+
can move these paths if you'd like to store them in another folder. Be careful
433
+
when adjusting these paths:
434
+
435
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
436
+
any possible side effects. Remember to restart it once you're done.
437
+
* Make backups before moving in case something goes wrong.
438
+
* Make sure the `git` user can read and write from the new paths.
439
+
440
+
#### Database
441
+
442
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
443
+
and we want to move it to `/home/git/database/knotserver.db`.
444
+
445
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
446
+
and `.db-wal` files if they exist.
447
+
448
+
```
449
+
mkdir /home/git/database
450
+
cp /home/git/knotserver.db* /home/git/database
451
+
```
452
+
453
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
454
+
the new file path (_not_ the directory):
455
+
456
+
```
457
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
458
+
```
459
+
460
+
#### Repositories
461
+
462
+
As an example, let's say the repositories are currently in `/home/git`, and we
463
+
want to move them into `/home/git/repositories`.
464
+
465
+
Create the new folder, then move the existing repositories (if there are any):
466
+
467
+
```
468
+
mkdir /home/git/repositories
469
+
# move all DIDs into the new folder; these will vary for you!
470
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
471
+
```
472
+
473
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
474
+
to the new directory:
475
+
476
+
```
477
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
478
+
```
479
+
480
+
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
481
+
repository path:
482
+
483
+
```
484
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
485
+
Match User git
486
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
487
+
AuthorizedKeysCommandUser nobody
488
+
EOF
489
+
```
490
+
491
+
Make sure to restart your SSH server!
492
+
493
+
#### MOTD (message of the day)
494
+
495
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
496
+
`/home/git/motd` file:
497
+
498
+
```
499
+
printf "Hi from this knot!\n" > /home/git/motd
500
+
```
501
+
502
+
Note that you should add a newline at the end if setting a non-empty message
503
+
since the knot won't do this for you.
504
+
505
+
# Spindles
506
+
507
+
## Pipelines
508
+
509
+
Spindle workflows allow you to write CI/CD pipelines in a
510
+
simple format. They're located in the `.tangled/workflows`
511
+
directory at the root of your repository, and are defined
512
+
using YAML.
513
+
514
+
The fields are:
515
+
516
+
- [Trigger](#trigger): A **required** field that defines
517
+
when a workflow should be triggered.
518
+
- [Engine](#engine): A **required** field that defines which
519
+
engine a workflow should run on.
520
+
- [Clone options](#clone-options): An **optional** field
521
+
that defines how the repository should be cloned.
522
+
- [Dependencies](#dependencies): An **optional** field that
523
+
allows you to list dependencies you may need.
524
+
- [Environment](#environment): An **optional** field that
525
+
allows you to define environment variables.
526
+
- [Steps](#steps): An **optional** field that allows you to
527
+
define what steps should run in the workflow.
528
+
529
+
### Trigger
530
+
531
+
The first thing to add to a workflow is the trigger, which
532
+
defines when a workflow runs. This is defined using a `when`
533
+
field, which takes in a list of conditions. Each condition
534
+
has the following fields:
535
+
536
+
- `event`: This is a **required** field that defines when
537
+
your workflow should run. It's a list that can take one or
538
+
more of the following values:
539
+
- `push`: The workflow should run every time a commit is
540
+
pushed to the repository.
541
+
- `pull_request`: The workflow should run every time a
542
+
pull request is made or updated.
543
+
- `manual`: The workflow can be triggered manually.
544
+
- `branch`: Defines which branches the workflow should run
545
+
for. If used with the `push` event, commits to the
546
+
branch(es) listed here will trigger the workflow. If used
547
+
with the `pull_request` event, updates to pull requests
548
+
targeting the branch(es) listed here will trigger the
549
+
workflow. This field has no effect with the `manual`
550
+
event. Supports glob patterns using `*` and `**` (e.g.,
551
+
`main`, `develop`, `release-*`). Either `branch` or `tag`
552
+
(or both) must be specified for `push` events.
553
+
- `tag`: Defines which tags the workflow should run for.
554
+
Only used with the `push` event - when tags matching the
555
+
pattern(s) listed here are pushed, the workflow will
556
+
trigger. This field has no effect with `pull_request` or
557
+
`manual` events. Supports glob patterns using `*` and `**`
558
+
(e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
559
+
`tag` (or both) must be specified for `push` events.
560
+
561
+
For example, if you'd like to define a workflow that runs
562
+
when commits are pushed to the `main` and `develop`
563
+
branches, or when pull requests that target the `main`
564
+
branch are updated, or manually, you can do so with:
565
+
566
+
```yaml
567
+
when:
568
+
- event: ["push", "manual"]
569
+
branch: ["main", "develop"]
570
+
- event: ["pull_request"]
571
+
branch: ["main"]
572
+
```
573
+
574
+
You can also trigger workflows on tag pushes. For instance,
575
+
to run a deployment workflow when tags matching `v*` are
576
+
pushed:
577
+
578
+
```yaml
579
+
when:
580
+
- event: ["push"]
581
+
tag: ["v*"]
582
+
```
583
+
584
+
You can even combine branch and tag patterns in a single
585
+
constraint (the workflow triggers if either matches):
586
+
587
+
```yaml
588
+
when:
589
+
- event: ["push"]
590
+
branch: ["main", "release-*"]
591
+
tag: ["v*", "stable"]
592
+
```
593
+
594
+
### Engine
595
+
596
+
Next is the engine on which the workflow should run, defined
597
+
using the **required** `engine` field. The currently
598
+
supported engines are:
599
+
600
+
- `nixery`: This uses an instance of
601
+
[Nixery](https://nixery.dev) to run steps, which allows
602
+
you to add [dependencies](#dependencies) from
603
+
Nixpkgs (https://github.com/NixOS/nixpkgs). You can
604
+
search for packages on https://search.nixos.org, and
605
+
there's a pretty good chance the package(s) you're looking
606
+
for will be there.
607
+
608
+
Example:
609
+
610
+
```yaml
611
+
engine: "nixery"
612
+
```
613
+
614
+
### Clone options
615
+
616
+
When a workflow starts, the first step is to clone the
617
+
repository. You can customize this behavior using the
618
+
**optional** `clone` field. It has the following fields:
619
+
620
+
- `skip`: Setting this to `true` will skip cloning the
621
+
repository. This can be useful if your workflow is doing
622
+
something that doesn't require anything from the
623
+
repository itself. This is `false` by default.
624
+
- `depth`: This sets the number of commits, or the "clone
625
+
depth", to fetch from the repository. For example, if you
626
+
set this to 2, the last 2 commits will be fetched. By
627
+
default, the depth is set to 1, meaning only the most
628
+
recent commit will be fetched, which is the commit that
629
+
triggered the workflow.
630
+
- `submodules`: If you use Git submodules
631
+
(https://git-scm.com/book/en/v2/Git-Tools-Submodules)
632
+
in your repository, setting this field to `true` will
633
+
recursively fetch all submodules. This is `false` by
634
+
default.
635
+
636
+
The default settings are:
637
+
638
+
```yaml
639
+
clone:
640
+
skip: false
641
+
depth: 1
642
+
submodules: false
643
+
```
644
+
645
+
### Dependencies
646
+
647
+
Usually when you're running a workflow, you'll need
648
+
additional dependencies. The `dependencies` field lets you
649
+
define which dependencies to get, and from where. It's a
650
+
key-value map, with the key being the registry to fetch
651
+
dependencies from, and the value being the list of
652
+
dependencies to fetch.
653
+
654
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a
655
+
package called `my_pkg` you've made from your own registry
656
+
at your repository at
657
+
`https://tangled.org/@example.com/my_pkg`. You can define
658
+
those dependencies like so:
659
+
660
+
```yaml
661
+
dependencies:
662
+
# nixpkgs
663
+
nixpkgs:
664
+
- nodejs
665
+
- go
666
+
# custom registry
667
+
git+https://tangled.org/@example.com/my_pkg:
668
+
- my_pkg
669
+
```
670
+
671
+
Now these dependencies are available to use in your
672
+
workflow!
673
+
674
+
### Environment
675
+
676
+
The `environment` field allows you define environment
677
+
variables that will be available throughout the entire
678
+
workflow. **Do not put secrets here, these environment
679
+
variables are visible to anyone viewing the repository. You
680
+
can add secrets for pipelines in your repository's
681
+
settings.**
682
+
683
+
Example:
684
+
685
+
```yaml
686
+
environment:
687
+
GOOS: "linux"
688
+
GOARCH: "arm64"
689
+
NODE_ENV: "production"
690
+
MY_ENV_VAR: "MY_ENV_VALUE"
691
+
```
692
+
693
+
### Steps
694
+
695
+
The `steps` field allows you to define what steps should run
696
+
in the workflow. It's a list of step objects, each with the
697
+
following fields:
698
+
699
+
- `name`: This field allows you to give your step a name.
700
+
This name is visible in your workflow runs, and is used to
701
+
describe what the step is doing.
702
+
- `command`: This field allows you to define a command to
703
+
run in that step. The step is run in a Bash shell, and the
704
+
logs from the command will be visible in the pipelines
705
+
page on the Tangled website. The
706
+
[dependencies](#dependencies) you added will be available
707
+
to use here.
708
+
- `environment`: Similar to the global
709
+
[environment](#environment) config, this **optional**
710
+
field is a key-value map that allows you to set
711
+
environment variables for the step. **Do not put secrets
712
+
here, these environment variables are visible to anyone
713
+
viewing the repository. You can add secrets for pipelines
714
+
in your repository's settings.**
715
+
716
+
Example:
717
+
718
+
```yaml
719
+
steps:
720
+
- name: "Build backend"
721
+
command: "go build"
722
+
environment:
723
+
GOOS: "darwin"
724
+
GOARCH: "arm64"
725
+
- name: "Build frontend"
726
+
command: "npm run build"
727
+
environment:
728
+
NODE_ENV: "production"
729
+
```
730
+
731
+
### Complete workflow
732
+
733
+
```yaml
734
+
# .tangled/workflows/build.yml
735
+
736
+
when:
737
+
- event: ["push", "manual"]
738
+
branch: ["main", "develop"]
739
+
- event: ["pull_request"]
740
+
branch: ["main"]
741
+
742
+
engine: "nixery"
743
+
744
+
# using the default values
745
+
clone:
746
+
skip: false
747
+
depth: 1
748
+
submodules: false
749
+
750
+
dependencies:
751
+
# nixpkgs
752
+
nixpkgs:
753
+
- nodejs
754
+
- go
755
+
# custom registry
756
+
git+https://tangled.org/@example.com/my_pkg:
757
+
- my_pkg
758
+
759
+
environment:
760
+
GOOS: "linux"
761
+
GOARCH: "arm64"
762
+
NODE_ENV: "production"
763
+
MY_ENV_VAR: "MY_ENV_VALUE"
764
+
765
+
steps:
766
+
- name: "Build backend"
767
+
command: "go build"
768
+
environment:
769
+
GOOS: "darwin"
770
+
GOARCH: "arm64"
771
+
- name: "Build frontend"
772
+
command: "npm run build"
773
+
environment:
774
+
NODE_ENV: "production"
775
+
```
776
+
777
+
If you want another example of a workflow, you can look at
778
+
the one [Tangled uses to build the
779
+
project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
780
+
781
+
## Self-hosting guide
782
+
783
+
### Prerequisites
784
+
785
+
* Go
786
+
* Docker (the only supported backend currently)
787
+
788
+
### Configuration
789
+
790
+
Spindle is configured using environment variables. The following environment variables are available:
791
+
792
+
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
793
+
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
794
+
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
795
+
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
796
+
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
797
+
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
798
+
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
799
+
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
800
+
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
801
+
802
+
### Running spindle
803
+
804
+
1. **Set the environment variables.** For example:
805
+
806
+
```shell
807
+
export SPINDLE_SERVER_HOSTNAME="your-hostname"
808
+
export SPINDLE_SERVER_OWNER="your-did"
809
+
```
810
+
811
+
2. **Build the Spindle binary.**
812
+
813
+
```shell
814
+
cd core
815
+
go mod download
816
+
go build -o cmd/spindle/spindle cmd/spindle/main.go
817
+
```
818
+
819
+
3. **Create the log directory.**
820
+
821
+
```shell
822
+
sudo mkdir -p /var/log/spindle
823
+
sudo chown $USER:$USER -R /var/log/spindle
824
+
```
825
+
826
+
4. **Run the Spindle binary.**
827
+
828
+
```shell
829
+
./cmd/spindle/spindle
830
+
```
831
+
832
+
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
833
+
834
+
## Architecture
835
+
836
+
Spindle is a small CI runner service. Here's a high-level overview of how it operates:
837
+
838
+
* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
839
+
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
840
+
* When a new repo record comes through (typically when you add a spindle to a
841
+
repo from the settings), spindle then resolves the underlying knot and
842
+
subscribes to repo events (see:
843
+
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
844
+
* The spindle engine then handles execution of the pipeline, with results and
845
+
logs beamed on the spindle event stream over WebSocket
846
+
847
+
### The engine
848
+
849
+
At present, the only supported backend is Docker (and Podman, if Docker
850
+
compatibility is enabled, so that `/run/docker.sock` is created). spindle
851
+
executes each step in the pipeline in a fresh container, with state persisted
852
+
across steps within the `/tangled/workspace` directory.
853
+
854
+
The base image for the container is constructed on the fly using
855
+
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
856
+
used packages.
857
+
858
+
The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
859
+
860
+
## Secrets with openbao
861
+
862
+
This document covers setting up spindle to use OpenBao for secrets
863
+
management via OpenBao Proxy instead of the default SQLite backend.
864
+
865
+
### Overview
866
+
867
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
868
+
authentication automatically using AppRole credentials, while spindle
869
+
connects to the local proxy instead of directly to the OpenBao server.
870
+
871
+
This approach provides better security, automatic token renewal, and
872
+
simplified application code.
873
+
874
+
### Installation
875
+
876
+
Install OpenBao from Nixpkgs:
877
+
878
+
```bash
879
+
nix shell nixpkgs#openbao # for a local server
880
+
```
881
+
882
+
### Setup
883
+
884
+
The setup process can is documented for both local development and production.
885
+
886
+
#### Local development
887
+
888
+
Start OpenBao in dev mode:
889
+
890
+
```bash
891
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
892
+
```
893
+
894
+
This starts OpenBao on `http://localhost:8201` with a root token.
895
+
896
+
Set up environment for bao CLI:
897
+
898
+
```bash
899
+
export BAO_ADDR=http://localhost:8200
900
+
export BAO_TOKEN=root
901
+
```
902
+
903
+
#### Production
904
+
905
+
You would typically use a systemd service with a
906
+
configuration file. Refer to
907
+
[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
908
+
for how this can be achieved using Nix.
909
+
910
+
Then, initialize the bao server:
911
+
912
+
```bash
913
+
bao operator init -key-shares=1 -key-threshold=1
914
+
```
915
+
916
+
This will print out an unseal key and a root key. Save them
917
+
somewhere (like a password manager). Then unseal the vault
918
+
to begin setting it up:
919
+
920
+
```bash
921
+
bao operator unseal <unseal_key>
922
+
```
923
+
924
+
All steps below remain the same across both dev and
925
+
production setups.
926
+
927
+
#### Configure openbao server
928
+
929
+
Create the spindle KV mount:
930
+
931
+
```bash
932
+
bao secrets enable -path=spindle -version=2 kv
933
+
```
934
+
935
+
Set up AppRole authentication and policy:
936
+
937
+
Create a policy file `spindle-policy.hcl`:
938
+
939
+
```hcl
940
+
# Full access to spindle KV v2 data
941
+
path "spindle/data/*" {
942
+
capabilities = ["create", "read", "update", "delete"]
943
+
}
944
+
945
+
# Access to metadata for listing and management
946
+
path "spindle/metadata/*" {
947
+
capabilities = ["list", "read", "delete", "update"]
948
+
}
949
+
950
+
# Allow listing at root level
951
+
path "spindle/" {
952
+
capabilities = ["list"]
953
+
}
954
+
955
+
# Required for connection testing and health checks
956
+
path "auth/token/lookup-self" {
957
+
capabilities = ["read"]
958
+
}
959
+
```
960
+
961
+
Apply the policy and create an AppRole:
962
+
963
+
```bash
964
+
bao policy write spindle-policy spindle-policy.hcl
965
+
bao auth enable approle
966
+
bao write auth/approle/role/spindle \
967
+
token_policies="spindle-policy" \
968
+
token_ttl=1h \
969
+
token_max_ttl=4h \
970
+
bind_secret_id=true \
971
+
secret_id_ttl=0 \
972
+
secret_id_num_uses=0
973
+
```
974
+
975
+
Get the credentials:
976
+
977
+
```bash
978
+
# Get role ID (static)
979
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
980
+
981
+
# Generate secret ID
982
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
983
+
984
+
echo "Role ID: $ROLE_ID"
985
+
echo "Secret ID: $SECRET_ID"
986
+
```
987
+
988
+
#### Create proxy configuration
989
+
990
+
Create the credential files:
991
+
992
+
```bash
993
+
# Create directory for OpenBao files
994
+
mkdir -p /tmp/openbao
995
+
996
+
# Save credentials
997
+
echo "$ROLE_ID" > /tmp/openbao/role-id
998
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
999
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
1000
+
```
1001
+
1002
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
1003
+
1004
+
```hcl
1005
+
# OpenBao server connection
1006
+
vault {
1007
+
address = "http://localhost:8200"
1008
+
}
1009
+
1010
+
# Auto-Auth using AppRole
1011
+
auto_auth {
1012
+
method "approle" {
1013
+
mount_path = "auth/approle"
1014
+
config = {
1015
+
role_id_file_path = "/tmp/openbao/role-id"
1016
+
secret_id_file_path = "/tmp/openbao/secret-id"
1017
+
}
1018
+
}
1019
+
1020
+
# Optional: write token to file for debugging
1021
+
sink "file" {
1022
+
config = {
1023
+
path = "/tmp/openbao/token"
1024
+
mode = 0640
1025
+
}
1026
+
}
1027
+
}
1028
+
1029
+
# Proxy listener for spindle
1030
+
listener "tcp" {
1031
+
address = "127.0.0.1:8201"
1032
+
tls_disable = true
1033
+
}
1034
+
1035
+
# Enable API proxy with auto-auth token
1036
+
api_proxy {
1037
+
use_auto_auth_token = true
1038
+
}
1039
+
1040
+
# Enable response caching
1041
+
cache {
1042
+
use_auto_auth_token = true
1043
+
}
1044
+
1045
+
# Logging
1046
+
log_level = "info"
1047
+
```
1048
+
1049
+
#### Start the proxy
1050
+
1051
+
Start OpenBao Proxy:
1052
+
1053
+
```bash
1054
+
bao proxy -config=/tmp/openbao/proxy.hcl
1055
+
```
1056
+
1057
+
The proxy will authenticate with OpenBao and start listening on
1058
+
`127.0.0.1:8201`.
1059
+
1060
+
#### Configure spindle
1061
+
1062
+
Set these environment variables for spindle:
1063
+
1064
+
```bash
1065
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
1066
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
1067
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1068
+
```
1069
+
1070
+
On startup, spindle will now connect to the local proxy,
1071
+
which handles all authentication automatically.
1072
+
1073
+
### Production setup for proxy
1074
+
1075
+
For production, you'll want to run the proxy as a service:
1076
+
1077
+
Place your production configuration in
1078
+
`/etc/openbao/proxy.hcl` with proper TLS settings for the
1079
+
vault connection.
1080
+
1081
+
### Verifying setup
1082
+
1083
+
Test the proxy directly:
1084
+
1085
+
```bash
1086
+
# Check proxy health
1087
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
1088
+
1089
+
# Test token lookup through proxy
1090
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
1091
+
```
1092
+
1093
+
Test OpenBao operations through the server:
1094
+
1095
+
```bash
1096
+
# List all secrets
1097
+
bao kv list spindle/
1098
+
1099
+
# Add a test secret via the spindle API, then check it exists
1100
+
bao kv list spindle/repos/
1101
+
1102
+
# Get a specific secret
1103
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
1104
+
```
1105
+
1106
+
### How it works
1107
+
1108
+
- Spindle connects to OpenBao Proxy on localhost (typically
1109
+
port 8200 or 8201)
1110
+
- The proxy authenticates with OpenBao using AppRole
1111
+
credentials
1112
+
- All spindle requests go through the proxy, which injects
1113
+
authentication tokens
1114
+
- Secrets are stored at
1115
+
`spindle/repos/{sanitized_repo_path}/{secret_key}`
1116
+
- Repository paths like `did:plc:alice/myrepo` become
1117
+
`did_plc_alice_myrepo`
1118
+
- The proxy handles all token renewal automatically
1119
+
- Spindle no longer manages tokens or authentication
1120
+
directly
1121
+
1122
+
### Troubleshooting
1123
+
1124
+
**Connection refused**: Check that the OpenBao Proxy is
1125
+
running and listening on the configured address.
1126
+
1127
+
**403 errors**: Verify the AppRole credentials are correct
1128
+
and the policy has the necessary permissions.
1129
+
1130
+
**404 route errors**: The spindle KV mount probably doesn't
1131
+
existโrun the mount creation step again.
1132
+
1133
+
**Proxy authentication failures**: Check the proxy logs and
1134
+
verify the role-id and secret-id files are readable and
1135
+
contain valid credentials.
1136
+
1137
+
**Secret not found after writing**: This can indicate policy
1138
+
permission issues. Verify the policy includes both
1139
+
`spindle/data/*` and `spindle/metadata/*` paths with
1140
+
appropriate capabilities.
1141
+
1142
+
Check proxy logs:
1143
+
1144
+
```bash
1145
+
# If running as systemd service
1146
+
journalctl -u openbao-proxy -f
1147
+
1148
+
# If running directly, check the console output
1149
+
```
1150
+
1151
+
Test AppRole authentication manually:
1152
+
1153
+
```bash
1154
+
bao write auth/approle/login \
1155
+
role_id="$(cat /tmp/openbao/role-id)" \
1156
+
secret_id="$(cat /tmp/openbao/secret-id)"
1157
+
```
1158
+
1159
+
# Migrating knots and spindles
1160
+
1161
+
Sometimes, non-backwards compatible changes are made to the
1162
+
knot/spindle XRPC APIs. If you host a knot or a spindle, you
1163
+
will need to follow this guide to upgrade. Typically, this
1164
+
only requires you to deploy the newest version.
1165
+
1166
+
This document is laid out in reverse-chronological order.
1167
+
Newer migration guides are listed first, and older guides
1168
+
are further down the page.
1169
+
1170
+
## Upgrading from v1.8.x
1171
+
1172
+
After v1.8.2, the HTTP API for knots and spindles has been
1173
+
deprecated and replaced with XRPC. Repositories on outdated
1174
+
knots will not be viewable from the appview. Upgrading is
1175
+
straightforward however.
1176
+
1177
+
For knots:
1178
+
1179
+
- Upgrade to the latest tag (v1.9.0 or above)
1180
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1181
+
hit the "retry" button to verify your knot
1182
+
1183
+
For spindles:
1184
+
1185
+
- Upgrade to the latest tag (v1.9.0 or above)
1186
+
- Head to the [spindle
1187
+
dashboard](https://tangled.org/settings/spindles) and hit the
1188
+
"retry" button to verify your spindle
1189
+
1190
+
## Upgrading from v1.7.x
1191
+
1192
+
After v1.7.0, knot secrets have been deprecated. You no
1193
+
longer need a secret from the appview to run a knot. All
1194
+
authorized commands to knots are managed via [Inter-Service
1195
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
1196
+
Knots will be read-only until upgraded.
1197
+
1198
+
Upgrading is quite easy, in essence:
1199
+
1200
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
1201
+
environment variable entirely
1202
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
1203
+
your DID. You can find your DID in the
1204
+
[settings](https://tangled.org/settings) page.
1205
+
- Restart your knot once you have replaced the environment
1206
+
variable
1207
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1208
+
hit the "retry" button to verify your knot. This simply
1209
+
writes a `sh.tangled.knot` record to your PDS.
1210
+
1211
+
If you use the nix module, simply bump the flake to the
1212
+
latest revision, and change your config block like so:
1213
+
1214
+
```diff
1215
+
services.tangled.knot = {
1216
+
enable = true;
1217
+
server = {
1218
+
- secretFile = /path/to/secret;
1219
+
+ owner = "did:plc:foo";
1220
+
};
1221
+
};
1222
+
```
1223
+
1224
+
# Hacking on Tangled
1225
+
1226
+
We highly recommend [installing
1227
+
Nix](https://nixos.org/download/) (the package manager)
1228
+
before working on the codebase. The Nix flake provides a lot
1229
+
of helpers to get started and most importantly, builds and
1230
+
dev shells are entirely deterministic.
1231
+
1232
+
To set up your dev environment:
1233
+
1234
+
```bash
1235
+
nix develop
1236
+
```
1237
+
1238
+
Non-Nix users can look at the `devShell` attribute in the
1239
+
`flake.nix` file to determine necessary dependencies.
1240
+
1241
+
## Running the appview
1242
+
1243
+
The Nix flake also exposes a few `app` attributes (run `nix
1244
+
flake show` to see a full list of what the flake provides),
1245
+
one of the apps runs the appview with the `air`
1246
+
live-reloader:
1247
+
1248
+
```bash
1249
+
TANGLED_DEV=true nix run .#watch-appview
1250
+
1251
+
# TANGLED_DB_PATH might be of interest to point to
1252
+
# different sqlite DBs
1253
+
1254
+
# in a separate shell, you can live-reload tailwind
1255
+
nix run .#watch-tailwind
1256
+
```
1257
+
1258
+
To authenticate with the appview, you will need Redis and
1259
+
OAuth JWKs to be set up:
1260
+
1261
+
```
1262
+
# OAuth JWKs should already be set up by the Nix devshell:
1263
+
echo $TANGLED_OAUTH_CLIENT_SECRET
1264
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1265
+
1266
+
echo $TANGLED_OAUTH_CLIENT_KID
1267
+
1761667908
1268
+
1269
+
# if not, you can set it up yourself:
1270
+
goat key generate -t P-256
1271
+
Key Type: P-256 / secp256r1 / ES256 private key
1272
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
1273
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
1274
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
1275
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
1276
+
1277
+
# the secret key from above
1278
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1279
+
1280
+
# Run Redis in a new shell to store OAuth sessions
1281
+
redis-server
1282
+
```
1283
+
1284
+
## Running knots and spindles
1285
+
1286
+
An end-to-end knot setup requires setting up a machine with
1287
+
`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1288
+
quite cumbersome. So the Nix flake provides a
1289
+
`nixosConfiguration` to do so.
1290
+
1291
+
<details>
1292
+
<summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1293
+
1294
+
In order to build Tangled's dev VM on macOS, you will
1295
+
first need to set up a Linux Nix builder. The recommended
1296
+
way to do so is to run a [`darwin.linux-builder`
1297
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
1298
+
and to register it in `nix.conf` as a builder for Linux
1299
+
with the same architecture as your Mac (`linux-aarch64` if
1300
+
you are using Apple Silicon).
1301
+
1302
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1303
+
> the Tangled repo so that it doesn't conflict with the other VM. For example,
1304
+
> you can do
1305
+
>
1306
+
> ```shell
1307
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
1308
+
> ```
1309
+
>
1310
+
> to store the builder VM in a temporary dir.
1311
+
>
1312
+
> You should read and follow [all the other intructions][darwin builder vm] to
1313
+
> avoid subtle problems.
1314
+
1315
+
Alternatively, you can use any other method to set up a
1316
+
Linux machine with Nix installed that you can `sudo ssh`
1317
+
into (in other words, root user on your Mac has to be able
1318
+
to ssh into the Linux machine without entering a password)
1319
+
and that has the same architecture as your Mac. See
1320
+
[remote builder
1321
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
1322
+
for how to register such a builder in `nix.conf`.
1323
+
1324
+
> WARNING: If you'd like to use
1325
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
1326
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
1327
+
> ssh` works can be tricky. It seems to be [possible with
1328
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1329
+
1330
+
</details>
1331
+
1332
+
To begin, grab your DID from http://localhost:3000/settings.
1333
+
Then, set `TANGLED_VM_KNOT_OWNER` and
1334
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
1335
+
lightweight NixOS VM like so:
1336
+
1337
+
```bash
1338
+
nix run --impure .#vm
1339
+
1340
+
# type `poweroff` at the shell to exit the VM
1341
+
```
1342
+
1343
+
This starts a knot on port 6444, a spindle on port 6555
1344
+
with `ssh` exposed on port 2222.
1345
+
1346
+
Once the services are running, head to
1347
+
http://localhost:3000/settings/knots and hit "Verify". It should
1348
+
verify the ownership of the services instantly if everything
1349
+
went smoothly.
1350
+
1351
+
You can push repositories to this VM with this ssh config
1352
+
block on your main machine:
1353
+
1354
+
```bash
1355
+
Host nixos-shell
1356
+
Hostname localhost
1357
+
Port 2222
1358
+
User git
1359
+
IdentityFile ~/.ssh/my_tangled_key
1360
+
```
1361
+
1362
+
Set up a remote called `local-dev` on a git repo:
1363
+
1364
+
```bash
1365
+
git remote add local-dev git@nixos-shell:user/repo
1366
+
git push local-dev main
1367
+
```
1368
+
1369
+
The above VM should already be running a spindle on
1370
+
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1371
+
hit "Verify". You can then configure each repository to use
1372
+
this spindle and run CI jobs.
1373
+
1374
+
Of interest when debugging spindles:
1375
+
1376
+
```
1377
+
# Service logs from journald:
1378
+
journalctl -xeu spindle
1379
+
1380
+
# CI job logs from disk:
1381
+
ls /var/log/spindle
1382
+
1383
+
# Debugging spindle database:
1384
+
sqlite3 /var/lib/spindle/spindle.db
1385
+
1386
+
# litecli has a nicer REPL interface:
1387
+
litecli /var/lib/spindle/spindle.db
1388
+
```
1389
+
1390
+
If for any reason you wish to disable either one of the
1391
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
1392
+
`services.tangled.spindle.enable` (or
1393
+
`services.tangled.knot.enable`) to `false`.
1394
+
1395
+
# Contribution guide
1396
+
1397
+
## Commit guidelines
1398
+
1399
+
We follow a commit style similar to the Go project. Please keep commits:
1400
+
1401
+
* **atomic**: each commit should represent one logical change
1402
+
* **descriptive**: the commit message should clearly describe what the
1403
+
change does and why it's needed
1404
+
1405
+
### Message format
1406
+
1407
+
```
1408
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
1409
+
1410
+
Optional longer description can go here, if necessary. Explain what the
1411
+
change does and why, especially if not obvious. Reference relevant
1412
+
issues or PRs when applicable. These can be links for now since we don't
1413
+
auto-link issues/PRs yet.
1414
+
```
1415
+
1416
+
Here are some examples:
1417
+
1418
+
```
1419
+
appview/state: fix token expiry check in middleware
1420
+
1421
+
The previous check did not account for clock drift, leading to premature
1422
+
token invalidation.
1423
+
```
1424
+
1425
+
```
1426
+
knotserver/git/service: improve error checking in upload-pack
1427
+
```
1428
+
1429
+
1430
+
### General notes
1431
+
1432
+
- PRs get merged "as-is" (fast-forward)โlike applying a patch-series
1433
+
using `git am`. At present, there is no squashingโso please author
1434
+
your commits as they would appear on `master`, following the above
1435
+
guidelines.
1436
+
- If there is a lot of nesting, for example "appview:
1437
+
pages/templates/repo/fragments: ...", these can be truncated down to
1438
+
just "appview: repo/fragments: ...". If the change affects a lot of
1439
+
subdirectories, you may abbreviate to just the top-level names, e.g.
1440
+
"appview: ..." or "knotserver: ...".
1441
+
- Keep commits lowercased with no trailing period.
1442
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
1443
+
"fixed bug" or "fixes bug").
1444
+
- Try to keep the summary line under 72 characters, but we aren't too
1445
+
fussed about this.
1446
+
- Follow the same formatting for PR titles if filled manually.
1447
+
- Don't include unrelated changes in the same commit.
1448
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
1449
+
before submitting if necessary.
1450
+
1451
+
## Code formatting
1452
+
1453
+
We use a variety of tools to format our code, and multiplex them with
1454
+
[`treefmt`](https://treefmt.com). All you need to do to format your changes
1455
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1456
+
1457
+
## Proposals for bigger changes
1458
+
1459
+
Small fixes like typos, minor bugs, or trivial refactors can be
1460
+
submitted directly as PRs.
1461
+
1462
+
For larger changesโespecially those introducing new features, significant
1463
+
refactoring, or altering system behaviorโplease open a proposal first. This
1464
+
helps us evaluate the scope, design, and potential impact before implementation.
1465
+
1466
+
Create a new issue titled:
1467
+
1468
+
```
1469
+
proposal: <affected scope>: <summary of change>
1470
+
```
1471
+
1472
+
In the description, explain:
1473
+
1474
+
- What the change is
1475
+
- Why it's needed
1476
+
- How you plan to implement it (roughly)
1477
+
- Any open questions or tradeoffs
1478
+
1479
+
We'll use the issue thread to discuss and refine the idea before moving
1480
+
forward.
1481
+
1482
+
## Developer Certificate of Origin (DCO)
1483
+
1484
+
We require all contributors to certify that they have the right to
1485
+
submit the code they're contributing. To do this, we follow the
1486
+
[Developer Certificate of Origin
1487
+
(DCO)](https://developercertificate.org/).
1488
+
1489
+
By signing your commits, you're stating that the contribution is your
1490
+
own work, or that you have the right to submit it under the project's
1491
+
license. This helps us keep things clean and legally sound.
1492
+
1493
+
To sign your commit, just add the `-s` flag when committing:
1494
+
1495
+
```sh
1496
+
git commit -s -m "your commit message"
1497
+
```
1498
+
1499
+
This appends a line like:
1500
+
1501
+
```
1502
+
Signed-off-by: Your Name <your.email@example.com>
1503
+
```
1504
+
1505
+
We won't merge commits if they aren't signed off. If you forget, you can
1506
+
amend the last commit like this:
1507
+
1508
+
```sh
1509
+
git commit --amend -s
1510
+
```
1511
+
1512
+
If you're submitting a PR with multiple commits, make sure each one is
1513
+
signed.
1514
+
1515
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
1516
+
to make it sign off commits in the tangled repo:
1517
+
1518
+
```shell
1519
+
# Safety check, should say "No matching config key..."
1520
+
jj config list templates.commit_trailers
1521
+
# The command below may need to be adjusted if the command above returned something.
1522
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
1523
+
```
1524
+
1525
+
Refer to the [jujutsu
1526
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1527
+
for more information.
-136
docs/contributing.md
-136
docs/contributing.md
···
1
-
# tangled contributing guide
2
-
3
-
## commit guidelines
4
-
5
-
We follow a commit style similar to the Go project. Please keep commits:
6
-
7
-
* **atomic**: each commit should represent one logical change
8
-
* **descriptive**: the commit message should clearly describe what the
9
-
change does and why it's needed
10
-
11
-
### message format
12
-
13
-
```
14
-
<service/top-level directory>/<affected package/directory>: <short summary of change>
15
-
16
-
17
-
Optional longer description can go here, if necessary. Explain what the
18
-
change does and why, especially if not obvious. Reference relevant
19
-
issues or PRs when applicable. These can be links for now since we don't
20
-
auto-link issues/PRs yet.
21
-
```
22
-
23
-
Here are some examples:
24
-
25
-
```
26
-
appview/state: fix token expiry check in middleware
27
-
28
-
The previous check did not account for clock drift, leading to premature
29
-
token invalidation.
30
-
```
31
-
32
-
```
33
-
knotserver/git/service: improve error checking in upload-pack
34
-
```
35
-
36
-
37
-
### general notes
38
-
39
-
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
40
-
using `git am`. At present, there is no squashing -- so please author
41
-
your commits as they would appear on `master`, following the above
42
-
guidelines.
43
-
- If there is a lot of nesting, for example "appview:
44
-
pages/templates/repo/fragments: ...", these can be truncated down to
45
-
just "appview: repo/fragments: ...". If the change affects a lot of
46
-
subdirectories, you may abbreviate to just the top-level names, e.g.
47
-
"appview: ..." or "knotserver: ...".
48
-
- Keep commits lowercased with no trailing period.
49
-
- Use the imperative mood in the summary line (e.g., "fix bug" not
50
-
"fixed bug" or "fixes bug").
51
-
- Try to keep the summary line under 72 characters, but we aren't too
52
-
fussed about this.
53
-
- Follow the same formatting for PR titles if filled manually.
54
-
- Don't include unrelated changes in the same commit.
55
-
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
56
-
before submitting if necessary.
57
-
58
-
## code formatting
59
-
60
-
We use a variety of tools to format our code, and multiplex them with
61
-
[`treefmt`](https://treefmt.com): all you need to do to format your changes
62
-
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
63
-
64
-
## proposals for bigger changes
65
-
66
-
Small fixes like typos, minor bugs, or trivial refactors can be
67
-
submitted directly as PRs.
68
-
69
-
For larger changesโespecially those introducing new features, significant
70
-
refactoring, or altering system behaviorโplease open a proposal first. This
71
-
helps us evaluate the scope, design, and potential impact before implementation.
72
-
73
-
### proposal format
74
-
75
-
Create a new issue titled:
76
-
77
-
```
78
-
proposal: <affected scope>: <summary of change>
79
-
```
80
-
81
-
In the description, explain:
82
-
83
-
- What the change is
84
-
- Why it's needed
85
-
- How you plan to implement it (roughly)
86
-
- Any open questions or tradeoffs
87
-
88
-
We'll use the issue thread to discuss and refine the idea before moving
89
-
forward.
90
-
91
-
## developer certificate of origin (DCO)
92
-
93
-
We require all contributors to certify that they have the right to
94
-
submit the code they're contributing. To do this, we follow the
95
-
[Developer Certificate of Origin
96
-
(DCO)](https://developercertificate.org/).
97
-
98
-
By signing your commits, you're stating that the contribution is your
99
-
own work, or that you have the right to submit it under the project's
100
-
license. This helps us keep things clean and legally sound.
101
-
102
-
To sign your commit, just add the `-s` flag when committing:
103
-
104
-
```sh
105
-
git commit -s -m "your commit message"
106
-
```
107
-
108
-
This appends a line like:
109
-
110
-
```
111
-
Signed-off-by: Your Name <your.email@example.com>
112
-
```
113
-
114
-
We won't merge commits if they aren't signed off. If you forget, you can
115
-
amend the last commit like this:
116
-
117
-
```sh
118
-
git commit --amend -s
119
-
```
120
-
121
-
If you're submitting a PR with multiple commits, make sure each one is
122
-
signed.
123
-
124
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
125
-
to make it sign off commits in the tangled repo:
126
-
127
-
```shell
128
-
# Safety check, should say "No matching config key..."
129
-
jj config list templates.commit_trailers
130
-
# The command below may need to be adjusted if the command above returned something.
131
-
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
132
-
```
133
-
134
-
Refer to the [jj
135
-
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
136
-
for more information.
···
-172
docs/hacking.md
-172
docs/hacking.md
···
1
-
# hacking on tangled
2
-
3
-
We highly recommend [installing
4
-
nix](https://nixos.org/download/) (the package manager)
5
-
before working on the codebase. The nix flake provides a lot
6
-
of helpers to get started and most importantly, builds and
7
-
dev shells are entirely deterministic.
8
-
9
-
To set up your dev environment:
10
-
11
-
```bash
12
-
nix develop
13
-
```
14
-
15
-
Non-nix users can look at the `devShell` attribute in the
16
-
`flake.nix` file to determine necessary dependencies.
17
-
18
-
## running the appview
19
-
20
-
The nix flake also exposes a few `app` attributes (run `nix
21
-
flake show` to see a full list of what the flake provides),
22
-
one of the apps runs the appview with the `air`
23
-
live-reloader:
24
-
25
-
```bash
26
-
TANGLED_DEV=true nix run .#watch-appview
27
-
28
-
# TANGLED_DB_PATH might be of interest to point to
29
-
# different sqlite DBs
30
-
31
-
# in a separate shell, you can live-reload tailwind
32
-
nix run .#watch-tailwind
33
-
```
34
-
35
-
To authenticate with the appview, you will need redis and
36
-
OAUTH JWKs to be setup:
37
-
38
-
```
39
-
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_CLIENT_SECRET
41
-
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
-
43
-
echo $TANGLED_OAUTH_CLIENT_KID
44
-
1761667908
45
-
46
-
# if not, you can set it up yourself:
47
-
goat key generate -t P-256
48
-
Key Type: P-256 / secp256r1 / ES256 private key
49
-
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
-
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
-
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
52
-
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
-
54
-
# the secret key from above
55
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
56
-
57
-
# run redis in at a new shell to store oauth sessions
58
-
redis-server
59
-
```
60
-
61
-
## running knots and spindles
62
-
63
-
An end-to-end knot setup requires setting up a machine with
64
-
`sshd`, `AuthorizedKeysCommand`, and git user, which is
65
-
quite cumbersome. So the nix flake provides a
66
-
`nixosConfiguration` to do so.
67
-
68
-
<details>
69
-
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
70
-
71
-
In order to build Tangled's dev VM on macOS, you will
72
-
first need to set up a Linux Nix builder. The recommended
73
-
way to do so is to run a [`darwin.linux-builder`
74
-
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
75
-
and to register it in `nix.conf` as a builder for Linux
76
-
with the same architecture as your Mac (`linux-aarch64` if
77
-
you are using Apple Silicon).
78
-
79
-
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
80
-
> the tangled repo so that it doesn't conflict with the other VM. For example,
81
-
> you can do
82
-
>
83
-
> ```shell
84
-
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
85
-
> ```
86
-
>
87
-
> to store the builder VM in a temporary dir.
88
-
>
89
-
> You should read and follow [all the other intructions][darwin builder vm] to
90
-
> avoid subtle problems.
91
-
92
-
Alternatively, you can use any other method to set up a
93
-
Linux machine with `nix` installed that you can `sudo ssh`
94
-
into (in other words, root user on your Mac has to be able
95
-
to ssh into the Linux machine without entering a password)
96
-
and that has the same architecture as your Mac. See
97
-
[remote builder
98
-
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
99
-
for how to register such a builder in `nix.conf`.
100
-
101
-
> WARNING: If you'd like to use
102
-
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
103
-
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
104
-
> ssh` works can be tricky. It seems to be [possible with
105
-
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
106
-
107
-
</details>
108
-
109
-
To begin, grab your DID from http://localhost:3000/settings.
110
-
Then, set `TANGLED_VM_KNOT_OWNER` and
111
-
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
112
-
lightweight NixOS VM like so:
113
-
114
-
```bash
115
-
nix run --impure .#vm
116
-
117
-
# type `poweroff` at the shell to exit the VM
118
-
```
119
-
120
-
This starts a knot on port 6000, a spindle on port 6555
121
-
with `ssh` exposed on port 2222.
122
-
123
-
Once the services are running, head to
124
-
http://localhost:3000/knots and hit verify. It should
125
-
verify the ownership of the services instantly if everything
126
-
went smoothly.
127
-
128
-
You can push repositories to this VM with this ssh config
129
-
block on your main machine:
130
-
131
-
```bash
132
-
Host nixos-shell
133
-
Hostname localhost
134
-
Port 2222
135
-
User git
136
-
IdentityFile ~/.ssh/my_tangled_key
137
-
```
138
-
139
-
Set up a remote called `local-dev` on a git repo:
140
-
141
-
```bash
142
-
git remote add local-dev git@nixos-shell:user/repo
143
-
git push local-dev main
144
-
```
145
-
146
-
### running a spindle
147
-
148
-
The above VM should already be running a spindle on
149
-
`localhost:6555`. Head to http://localhost:3000/spindles and
150
-
hit verify. You can then configure each repository to use
151
-
this spindle and run CI jobs.
152
-
153
-
Of interest when debugging spindles:
154
-
155
-
```
156
-
# service logs from journald:
157
-
journalctl -xeu spindle
158
-
159
-
# CI job logs from disk:
160
-
ls /var/log/spindle
161
-
162
-
# debugging spindle db:
163
-
sqlite3 /var/lib/spindle/spindle.db
164
-
165
-
# litecli has a nicer REPL interface:
166
-
litecli /var/lib/spindle/spindle.db
167
-
```
168
-
169
-
If for any reason you wish to disable either one of the
170
-
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
-
`services.tangled.spindle.enable` (or
172
-
`services.tangled.knot.enable`) to `false`.
···
+93
docs/highlight.theme
+93
docs/highlight.theme
···
···
1
+
{
2
+
"text-color": null,
3
+
"background-color": null,
4
+
"line-number-color": null,
5
+
"line-number-background-color": null,
6
+
"text-styles": {
7
+
"Annotation": {
8
+
"text-color": null,
9
+
"background-color": null,
10
+
"bold": false,
11
+
"italic": true,
12
+
"underline": false
13
+
},
14
+
"ControlFlow": {
15
+
"text-color": null,
16
+
"background-color": null,
17
+
"bold": true,
18
+
"italic": false,
19
+
"underline": false
20
+
},
21
+
"Error": {
22
+
"text-color": null,
23
+
"background-color": null,
24
+
"bold": true,
25
+
"italic": false,
26
+
"underline": false
27
+
},
28
+
"Alert": {
29
+
"text-color": null,
30
+
"background-color": null,
31
+
"bold": true,
32
+
"italic": false,
33
+
"underline": false
34
+
},
35
+
"Preprocessor": {
36
+
"text-color": null,
37
+
"background-color": null,
38
+
"bold": true,
39
+
"italic": false,
40
+
"underline": false
41
+
},
42
+
"Information": {
43
+
"text-color": null,
44
+
"background-color": null,
45
+
"bold": false,
46
+
"italic": true,
47
+
"underline": false
48
+
},
49
+
"Warning": {
50
+
"text-color": null,
51
+
"background-color": null,
52
+
"bold": false,
53
+
"italic": true,
54
+
"underline": false
55
+
},
56
+
"Documentation": {
57
+
"text-color": null,
58
+
"background-color": null,
59
+
"bold": false,
60
+
"italic": true,
61
+
"underline": false
62
+
},
63
+
"DataType": {
64
+
"text-color": "#8f4e8b",
65
+
"background-color": null,
66
+
"bold": false,
67
+
"italic": false,
68
+
"underline": false
69
+
},
70
+
"Comment": {
71
+
"text-color": null,
72
+
"background-color": null,
73
+
"bold": false,
74
+
"italic": true,
75
+
"underline": false
76
+
},
77
+
"CommentVar": {
78
+
"text-color": null,
79
+
"background-color": null,
80
+
"bold": false,
81
+
"italic": true,
82
+
"underline": false
83
+
},
84
+
"Keyword": {
85
+
"text-color": null,
86
+
"background-color": null,
87
+
"bold": true,
88
+
"italic": false,
89
+
"underline": false
90
+
}
91
+
}
92
+
}
93
+
-214
docs/knot-hosting.md
-214
docs/knot-hosting.md
···
1
-
# knot self-hosting guide
2
-
3
-
So you want to run your own knot server? Great! Here are a few prerequisites:
4
-
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
-
2. A (sub)domain name. People generally use `knot.example.com`.
7
-
3. A valid SSL certificate for your domain.
8
-
9
-
There's a couple of ways to get started:
10
-
* NixOS: refer to
11
-
[flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
12
-
* Docker: Documented at
13
-
[@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker)
14
-
(community maintained: support is not guaranteed!)
15
-
* Manual: Documented below.
16
-
17
-
## manual setup
18
-
19
-
First, clone this repository:
20
-
21
-
```
22
-
git clone https://tangled.org/@tangled.org/core
23
-
```
24
-
25
-
Then, build the `knot` CLI. This is the knot administration and operation tool.
26
-
For the purpose of this guide, we're only concerned with these subcommands:
27
-
28
-
* `knot server`: the main knot server process, typically run as a
29
-
supervised service
30
-
* `knot guard`: handles role-based access control for git over SSH
31
-
(you'll never have to run this yourself)
32
-
* `knot keys`: fetches SSH keys associated with your knot; we'll use
33
-
this to generate the SSH `AuthorizedKeysCommand`
34
-
35
-
```
36
-
cd core
37
-
export CGO_ENABLED=1
38
-
go build -o knot ./cmd/knot
39
-
```
40
-
41
-
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
-
44
-
```
45
-
sudo mv knot /usr/local/bin/knot
46
-
sudo chown root:root /usr/local/bin/knot
47
-
```
48
-
49
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really
50
-
specific permissions](https://stackoverflow.com/a/27638306). The
51
-
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
52
-
retrieve a user's public SSH keys dynamically for authentication. Let's
53
-
set that up.
54
-
55
-
```
56
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
57
-
Match User git
58
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
59
-
AuthorizedKeysCommandUser nobody
60
-
EOF
61
-
```
62
-
63
-
Then, reload `sshd`:
64
-
65
-
```
66
-
sudo systemctl reload ssh
67
-
```
68
-
69
-
Next, create the `git` user. We'll use the `git` user's home directory
70
-
to store repositories:
71
-
72
-
```
73
-
sudo adduser git
74
-
```
75
-
76
-
Create `/home/git/.knot.env` with the following, updating the values as
77
-
necessary. The `KNOT_SERVER_OWNER` should be set to your
78
-
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
79
-
80
-
```
81
-
KNOT_REPO_SCAN_PATH=/home/git
82
-
KNOT_SERVER_HOSTNAME=knot.example.com
83
-
APPVIEW_ENDPOINT=https://tangled.sh
84
-
KNOT_SERVER_OWNER=did:plc:foobar
85
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
86
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
87
-
```
88
-
89
-
If you run a Linux distribution that uses systemd, you can use the provided
90
-
service file to run the server. Copy
91
-
[`knotserver.service`](/systemd/knotserver.service)
92
-
to `/etc/systemd/system/`. Then, run:
93
-
94
-
```
95
-
systemctl enable knotserver
96
-
systemctl start knotserver
97
-
```
98
-
99
-
The last step is to configure a reverse proxy like Nginx or Caddy to front your
100
-
knot. Here's an example configuration for Nginx:
101
-
102
-
```
103
-
server {
104
-
listen 80;
105
-
listen [::]:80;
106
-
server_name knot.example.com;
107
-
108
-
location / {
109
-
proxy_pass http://localhost:5555;
110
-
proxy_set_header Host $host;
111
-
proxy_set_header X-Real-IP $remote_addr;
112
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
113
-
proxy_set_header X-Forwarded-Proto $scheme;
114
-
}
115
-
116
-
# wss endpoint for git events
117
-
location /events {
118
-
proxy_set_header X-Forwarded-For $remote_addr;
119
-
proxy_set_header Host $http_host;
120
-
proxy_set_header Upgrade websocket;
121
-
proxy_set_header Connection Upgrade;
122
-
proxy_pass http://localhost:5555;
123
-
}
124
-
# additional config for SSL/TLS go here.
125
-
}
126
-
127
-
```
128
-
129
-
Remember to use Let's Encrypt or similar to procure a certificate for your
130
-
knot domain.
131
-
132
-
You should now have a running knot server! You can finalize
133
-
your registration by hitting the `verify` button on the
134
-
[/knots](https://tangled.org/knots) page. This simply creates
135
-
a record on your PDS to announce the existence of the knot.
136
-
137
-
### custom paths
138
-
139
-
(This section applies to manual setup only. Docker users should edit the mounts
140
-
in `docker-compose.yml` instead.)
141
-
142
-
Right now, the database and repositories of your knot lives in `/home/git`. You
143
-
can move these paths if you'd like to store them in another folder. Be careful
144
-
when adjusting these paths:
145
-
146
-
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
147
-
any possible side effects. Remember to restart it once you're done.
148
-
* Make backups before moving in case something goes wrong.
149
-
* Make sure the `git` user can read and write from the new paths.
150
-
151
-
#### database
152
-
153
-
As an example, let's say the current database is at `/home/git/knotserver.db`,
154
-
and we want to move it to `/home/git/database/knotserver.db`.
155
-
156
-
Copy the current database to the new location. Make sure to copy the `.db-shm`
157
-
and `.db-wal` files if they exist.
158
-
159
-
```
160
-
mkdir /home/git/database
161
-
cp /home/git/knotserver.db* /home/git/database
162
-
```
163
-
164
-
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
165
-
the new file path (_not_ the directory):
166
-
167
-
```
168
-
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
169
-
```
170
-
171
-
#### repositories
172
-
173
-
As an example, let's say the repositories are currently in `/home/git`, and we
174
-
want to move them into `/home/git/repositories`.
175
-
176
-
Create the new folder, then move the existing repositories (if there are any):
177
-
178
-
```
179
-
mkdir /home/git/repositories
180
-
# move all DIDs into the new folder; these will vary for you!
181
-
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
182
-
```
183
-
184
-
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
185
-
to the new directory:
186
-
187
-
```
188
-
KNOT_REPO_SCAN_PATH=/home/git/repositories
189
-
```
190
-
191
-
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
192
-
repository path:
193
-
194
-
```
195
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
196
-
Match User git
197
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
198
-
AuthorizedKeysCommandUser nobody
199
-
EOF
200
-
```
201
-
202
-
Make sure to restart your SSH server!
203
-
204
-
#### MOTD (message of the day)
205
-
206
-
To configure the MOTD used ("Welcome to this knot!" by default), edit the
207
-
`/home/git/motd` file:
208
-
209
-
```
210
-
printf "Hi from this knot!\n" > /home/git/motd
211
-
```
212
-
213
-
Note that you should add a newline at the end if setting a non-empty message
214
-
since the knot won't do this for you.
···
-59
docs/migrations.md
-59
docs/migrations.md
···
1
-
# Migrations
2
-
3
-
This document is laid out in reverse-chronological order.
4
-
Newer migration guides are listed first, and older guides
5
-
are further down the page.
6
-
7
-
## Upgrading from v1.8.x
8
-
9
-
After v1.8.2, the HTTP API for knot and spindles have been
10
-
deprecated and replaced with XRPC. Repositories on outdated
11
-
knots will not be viewable from the appview. Upgrading is
12
-
straightforward however.
13
-
14
-
For knots:
15
-
16
-
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.org/knots) and
18
-
hit the "retry" button to verify your knot
19
-
20
-
For spindles:
21
-
22
-
- Upgrade to latest tag (v1.9.0 or above)
23
-
- Head to the [spindle
24
-
dashboard](https://tangled.org/spindles) and hit the
25
-
"retry" button to verify your spindle
26
-
27
-
## Upgrading from v1.7.x
28
-
29
-
After v1.7.0, knot secrets have been deprecated. You no
30
-
longer need a secret from the appview to run a knot. All
31
-
authorized commands to knots are managed via [Inter-Service
32
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
-
Knots will be read-only until upgraded.
34
-
35
-
Upgrading is quite easy, in essence:
36
-
37
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
-
environment variable entirely
39
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
-
your DID. You can find your DID in the
41
-
[settings](https://tangled.org/settings) page.
42
-
- Restart your knot once you have replaced the environment
43
-
variable
44
-
- Head to the [knot dashboard](https://tangled.org/knots) and
45
-
hit the "retry" button to verify your knot. This simply
46
-
writes a `sh.tangled.knot` record to your PDS.
47
-
48
-
If you use the nix module, simply bump the flake to the
49
-
latest revision, and change your config block like so:
50
-
51
-
```diff
52
-
services.tangled.knot = {
53
-
enable = true;
54
-
server = {
55
-
- secretFile = /path/to/secret;
56
-
+ owner = "did:plc:foo";
57
-
};
58
-
};
59
-
```
···
+3
docs/mode.html
+3
docs/mode.html
+7
docs/search.html
+7
docs/search.html
···
···
1
+
<form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full">
2
+
<input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]">
3
+
<label>
4
+
<span style="display:none;">Search</span>
5
+
<input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal">
6
+
</label>
7
+
</form>
-25
docs/spindle/architecture.md
-25
docs/spindle/architecture.md
···
1
-
# spindle architecture
2
-
3
-
Spindle is a small CI runner service. Here's a high level overview of how it operates:
4
-
5
-
* listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
6
-
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
7
-
* when a new repo record comes through (typically when you add a spindle to a
8
-
repo from the settings), spindle then resolves the underlying knot and
9
-
subscribes to repo events (see:
10
-
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
11
-
* the spindle engine then handles execution of the pipeline, with results and
12
-
logs beamed on the spindle event stream over wss
13
-
14
-
### the engine
15
-
16
-
At present, the only supported backend is Docker (and Podman, if Docker
17
-
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18
-
executes each step in the pipeline in a fresh container, with state persisted
19
-
across steps within the `/tangled/workspace` directory.
20
-
21
-
The base image for the container is constructed on the fly using
22
-
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
23
-
used packages.
24
-
25
-
The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
···
-52
docs/spindle/hosting.md
-52
docs/spindle/hosting.md
···
1
-
# spindle self-hosting guide
2
-
3
-
## prerequisites
4
-
5
-
* Go
6
-
* Docker (the only supported backend currently)
7
-
8
-
## configuration
9
-
10
-
Spindle is configured using environment variables. The following environment variables are available:
11
-
12
-
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
13
-
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
14
-
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
15
-
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
16
-
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
17
-
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
18
-
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
19
-
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
20
-
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
21
-
22
-
## running spindle
23
-
24
-
1. **Set the environment variables.** For example:
25
-
26
-
```shell
27
-
export SPINDLE_SERVER_HOSTNAME="your-hostname"
28
-
export SPINDLE_SERVER_OWNER="your-did"
29
-
```
30
-
31
-
2. **Build the Spindle binary.**
32
-
33
-
```shell
34
-
cd core
35
-
go mod download
36
-
go build -o cmd/spindle/spindle cmd/spindle/main.go
37
-
```
38
-
39
-
3. **Create the log directory.**
40
-
41
-
```shell
42
-
sudo mkdir -p /var/log/spindle
43
-
sudo chown $USER:$USER -R /var/log/spindle
44
-
```
45
-
46
-
4. **Run the Spindle binary.**
47
-
48
-
```shell
49
-
./cmd/spindle/spindle
50
-
```
51
-
52
-
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
···
-285
docs/spindle/openbao.md
-285
docs/spindle/openbao.md
···
1
-
# spindle secrets with openbao
2
-
3
-
This document covers setting up Spindle to use OpenBao for secrets
4
-
management via OpenBao Proxy instead of the default SQLite backend.
5
-
6
-
## overview
7
-
8
-
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
-
authentication automatically using AppRole credentials, while Spindle
10
-
connects to the local proxy instead of directly to the OpenBao server.
11
-
12
-
This approach provides better security, automatic token renewal, and
13
-
simplified application code.
14
-
15
-
## installation
16
-
17
-
Install OpenBao from nixpkgs:
18
-
19
-
```bash
20
-
nix shell nixpkgs#openbao # for a local server
21
-
```
22
-
23
-
## setup
24
-
25
-
The setup process can is documented for both local development and production.
26
-
27
-
### local development
28
-
29
-
Start OpenBao in dev mode:
30
-
31
-
```bash
32
-
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
-
```
34
-
35
-
This starts OpenBao on `http://localhost:8201` with a root token.
36
-
37
-
Set up environment for bao CLI:
38
-
39
-
```bash
40
-
export BAO_ADDR=http://localhost:8200
41
-
export BAO_TOKEN=root
42
-
```
43
-
44
-
### production
45
-
46
-
You would typically use a systemd service with a configuration file. Refer to
47
-
[@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be
48
-
achieved using Nix.
49
-
50
-
Then, initialize the bao server:
51
-
```bash
52
-
bao operator init -key-shares=1 -key-threshold=1
53
-
```
54
-
55
-
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
-
```bash
57
-
bao operator unseal <unseal_key>
58
-
```
59
-
60
-
All steps below remain the same across both dev and production setups.
61
-
62
-
### configure openbao server
63
-
64
-
Create the spindle KV mount:
65
-
66
-
```bash
67
-
bao secrets enable -path=spindle -version=2 kv
68
-
```
69
-
70
-
Set up AppRole authentication and policy:
71
-
72
-
Create a policy file `spindle-policy.hcl`:
73
-
74
-
```hcl
75
-
# Full access to spindle KV v2 data
76
-
path "spindle/data/*" {
77
-
capabilities = ["create", "read", "update", "delete"]
78
-
}
79
-
80
-
# Access to metadata for listing and management
81
-
path "spindle/metadata/*" {
82
-
capabilities = ["list", "read", "delete", "update"]
83
-
}
84
-
85
-
# Allow listing at root level
86
-
path "spindle/" {
87
-
capabilities = ["list"]
88
-
}
89
-
90
-
# Required for connection testing and health checks
91
-
path "auth/token/lookup-self" {
92
-
capabilities = ["read"]
93
-
}
94
-
```
95
-
96
-
Apply the policy and create an AppRole:
97
-
98
-
```bash
99
-
bao policy write spindle-policy spindle-policy.hcl
100
-
bao auth enable approle
101
-
bao write auth/approle/role/spindle \
102
-
token_policies="spindle-policy" \
103
-
token_ttl=1h \
104
-
token_max_ttl=4h \
105
-
bind_secret_id=true \
106
-
secret_id_ttl=0 \
107
-
secret_id_num_uses=0
108
-
```
109
-
110
-
Get the credentials:
111
-
112
-
```bash
113
-
# Get role ID (static)
114
-
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
-
116
-
# Generate secret ID
117
-
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
-
119
-
echo "Role ID: $ROLE_ID"
120
-
echo "Secret ID: $SECRET_ID"
121
-
```
122
-
123
-
### create proxy configuration
124
-
125
-
Create the credential files:
126
-
127
-
```bash
128
-
# Create directory for OpenBao files
129
-
mkdir -p /tmp/openbao
130
-
131
-
# Save credentials
132
-
echo "$ROLE_ID" > /tmp/openbao/role-id
133
-
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
-
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
-
```
136
-
137
-
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
-
139
-
```hcl
140
-
# OpenBao server connection
141
-
vault {
142
-
address = "http://localhost:8200"
143
-
}
144
-
145
-
# Auto-Auth using AppRole
146
-
auto_auth {
147
-
method "approle" {
148
-
mount_path = "auth/approle"
149
-
config = {
150
-
role_id_file_path = "/tmp/openbao/role-id"
151
-
secret_id_file_path = "/tmp/openbao/secret-id"
152
-
}
153
-
}
154
-
155
-
# Optional: write token to file for debugging
156
-
sink "file" {
157
-
config = {
158
-
path = "/tmp/openbao/token"
159
-
mode = 0640
160
-
}
161
-
}
162
-
}
163
-
164
-
# Proxy listener for Spindle
165
-
listener "tcp" {
166
-
address = "127.0.0.1:8201"
167
-
tls_disable = true
168
-
}
169
-
170
-
# Enable API proxy with auto-auth token
171
-
api_proxy {
172
-
use_auto_auth_token = true
173
-
}
174
-
175
-
# Enable response caching
176
-
cache {
177
-
use_auto_auth_token = true
178
-
}
179
-
180
-
# Logging
181
-
log_level = "info"
182
-
```
183
-
184
-
### start the proxy
185
-
186
-
Start OpenBao Proxy:
187
-
188
-
```bash
189
-
bao proxy -config=/tmp/openbao/proxy.hcl
190
-
```
191
-
192
-
The proxy will authenticate with OpenBao and start listening on
193
-
`127.0.0.1:8201`.
194
-
195
-
### configure spindle
196
-
197
-
Set these environment variables for Spindle:
198
-
199
-
```bash
200
-
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
-
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
-
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
-
```
204
-
205
-
Start Spindle:
206
-
207
-
Spindle will now connect to the local proxy, which handles all
208
-
authentication automatically.
209
-
210
-
## production setup for proxy
211
-
212
-
For production, you'll want to run the proxy as a service:
213
-
214
-
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
-
proper TLS settings for the vault connection.
216
-
217
-
## verifying setup
218
-
219
-
Test the proxy directly:
220
-
221
-
```bash
222
-
# Check proxy health
223
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
-
225
-
# Test token lookup through proxy
226
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
-
```
228
-
229
-
Test OpenBao operations through the server:
230
-
231
-
```bash
232
-
# List all secrets
233
-
bao kv list spindle/
234
-
235
-
# Add a test secret via Spindle API, then check it exists
236
-
bao kv list spindle/repos/
237
-
238
-
# Get a specific secret
239
-
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
-
```
241
-
242
-
## how it works
243
-
244
-
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
-
- The proxy authenticates with OpenBao using AppRole credentials
246
-
- All Spindle requests go through the proxy, which injects authentication tokens
247
-
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
-
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
-
- The proxy handles all token renewal automatically
250
-
- Spindle no longer manages tokens or authentication directly
251
-
252
-
## troubleshooting
253
-
254
-
**Connection refused**: Check that the OpenBao Proxy is running and
255
-
listening on the configured address.
256
-
257
-
**403 errors**: Verify the AppRole credentials are correct and the policy
258
-
has the necessary permissions.
259
-
260
-
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
-
the mount creation step again.
262
-
263
-
**Proxy authentication failures**: Check the proxy logs and verify the
264
-
role-id and secret-id files are readable and contain valid credentials.
265
-
266
-
**Secret not found after writing**: This can indicate policy permission
267
-
issues. Verify the policy includes both `spindle/data/*` and
268
-
`spindle/metadata/*` paths with appropriate capabilities.
269
-
270
-
Check proxy logs:
271
-
272
-
```bash
273
-
# If running as systemd service
274
-
journalctl -u openbao-proxy -f
275
-
276
-
# If running directly, check the console output
277
-
```
278
-
279
-
Test AppRole authentication manually:
280
-
281
-
```bash
282
-
bao write auth/approle/login \
283
-
role_id="$(cat /tmp/openbao/role-id)" \
284
-
secret_id="$(cat /tmp/openbao/secret-id)"
285
-
```
···
-183
docs/spindle/pipeline.md
-183
docs/spindle/pipeline.md
···
1
-
# spindle pipelines
2
-
3
-
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
4
-
5
-
The fields are:
6
-
7
-
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
8
-
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
9
-
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
10
-
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
11
-
- [Environment](#environment): An **optional** field that allows you to define environment variables.
12
-
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
13
-
14
-
## Trigger
15
-
16
-
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
17
-
18
-
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
19
-
- `push`: The workflow should run every time a commit is pushed to the repository.
20
-
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
-
- `manual`: The workflow can be triggered manually.
22
-
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
-
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
24
-
25
-
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
26
-
27
-
```yaml
28
-
when:
29
-
- event: ["push", "manual"]
30
-
branch: ["main", "develop"]
31
-
- event: ["pull_request"]
32
-
branch: ["main"]
33
-
```
34
-
35
-
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
-
37
-
```yaml
38
-
when:
39
-
- event: ["push"]
40
-
tag: ["v*"]
41
-
```
42
-
43
-
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
-
45
-
```yaml
46
-
when:
47
-
- event: ["push"]
48
-
branch: ["main", "release-*"]
49
-
tag: ["v*", "stable"]
50
-
```
51
-
52
-
## Engine
53
-
54
-
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
55
-
56
-
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
57
-
58
-
Example:
59
-
60
-
```yaml
61
-
engine: "nixery"
62
-
```
63
-
64
-
## Clone options
65
-
66
-
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
67
-
68
-
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
69
-
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
70
-
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
71
-
72
-
The default settings are:
73
-
74
-
```yaml
75
-
clone:
76
-
skip: false
77
-
depth: 1
78
-
submodules: false
79
-
```
80
-
81
-
## Dependencies
82
-
83
-
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
84
-
85
-
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
86
-
87
-
```yaml
88
-
dependencies:
89
-
# nixpkgs
90
-
nixpkgs:
91
-
- nodejs
92
-
- go
93
-
# custom registry
94
-
git+https://tangled.org/@example.com/my_pkg:
95
-
- my_pkg
96
-
```
97
-
98
-
Now these dependencies are available to use in your workflow!
99
-
100
-
## Environment
101
-
102
-
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
103
-
104
-
Example:
105
-
106
-
```yaml
107
-
environment:
108
-
GOOS: "linux"
109
-
GOARCH: "arm64"
110
-
NODE_ENV: "production"
111
-
MY_ENV_VAR: "MY_ENV_VALUE"
112
-
```
113
-
114
-
## Steps
115
-
116
-
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
117
-
118
-
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
119
-
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
120
-
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
121
-
122
-
Example:
123
-
124
-
```yaml
125
-
steps:
126
-
- name: "Build backend"
127
-
command: "go build"
128
-
environment:
129
-
GOOS: "darwin"
130
-
GOARCH: "arm64"
131
-
- name: "Build frontend"
132
-
command: "npm run build"
133
-
environment:
134
-
NODE_ENV: "production"
135
-
```
136
-
137
-
## Complete workflow
138
-
139
-
```yaml
140
-
# .tangled/workflows/build.yml
141
-
142
-
when:
143
-
- event: ["push", "manual"]
144
-
branch: ["main", "develop"]
145
-
- event: ["pull_request"]
146
-
branch: ["main"]
147
-
148
-
engine: "nixery"
149
-
150
-
# using the default values
151
-
clone:
152
-
skip: false
153
-
depth: 1
154
-
submodules: false
155
-
156
-
dependencies:
157
-
# nixpkgs
158
-
nixpkgs:
159
-
- nodejs
160
-
- go
161
-
# custom registry
162
-
git+https://tangled.org/@example.com/my_pkg:
163
-
- my_pkg
164
-
165
-
environment:
166
-
GOOS: "linux"
167
-
GOARCH: "arm64"
168
-
NODE_ENV: "production"
169
-
MY_ENV_VAR: "MY_ENV_VALUE"
170
-
171
-
steps:
172
-
- name: "Build backend"
173
-
command: "go build"
174
-
environment:
175
-
GOOS: "darwin"
176
-
GOARCH: "arm64"
177
-
- name: "Build frontend"
178
-
command: "npm run build"
179
-
environment:
180
-
NODE_ENV: "production"
181
-
```
182
-
183
-
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
···
+101
docs/styles.css
+101
docs/styles.css
···
···
1
+
svg {
2
+
width: 16px;
3
+
height: 16px;
4
+
}
5
+
6
+
:root {
7
+
--syntax-alert: #d20f39;
8
+
--syntax-annotation: #fe640b;
9
+
--syntax-attribute: #df8e1d;
10
+
--syntax-basen: #40a02b;
11
+
--syntax-builtin: #1e66f5;
12
+
--syntax-controlflow: #8839ef;
13
+
--syntax-char: #04a5e5;
14
+
--syntax-constant: #fe640b;
15
+
--syntax-comment: #9ca0b0;
16
+
--syntax-commentvar: #7c7f93;
17
+
--syntax-documentation: #9ca0b0;
18
+
--syntax-datatype: #df8e1d;
19
+
--syntax-decval: #40a02b;
20
+
--syntax-error: #d20f39;
21
+
--syntax-extension: #4c4f69;
22
+
--syntax-float: #40a02b;
23
+
--syntax-function: #1e66f5;
24
+
--syntax-import: #40a02b;
25
+
--syntax-information: #04a5e5;
26
+
--syntax-keyword: #8839ef;
27
+
--syntax-operator: #179299;
28
+
--syntax-other: #8839ef;
29
+
--syntax-preprocessor: #ea76cb;
30
+
--syntax-specialchar: #04a5e5;
31
+
--syntax-specialstring: #ea76cb;
32
+
--syntax-string: #40a02b;
33
+
--syntax-variable: #8839ef;
34
+
--syntax-verbatimstring: #40a02b;
35
+
--syntax-warning: #df8e1d;
36
+
}
37
+
38
+
@media (prefers-color-scheme: dark) {
39
+
:root {
40
+
--syntax-alert: #f38ba8;
41
+
--syntax-annotation: #fab387;
42
+
--syntax-attribute: #f9e2af;
43
+
--syntax-basen: #a6e3a1;
44
+
--syntax-builtin: #89b4fa;
45
+
--syntax-controlflow: #cba6f7;
46
+
--syntax-char: #89dceb;
47
+
--syntax-constant: #fab387;
48
+
--syntax-comment: #6c7086;
49
+
--syntax-commentvar: #585b70;
50
+
--syntax-documentation: #6c7086;
51
+
--syntax-datatype: #f9e2af;
52
+
--syntax-decval: #a6e3a1;
53
+
--syntax-error: #f38ba8;
54
+
--syntax-extension: #cdd6f4;
55
+
--syntax-float: #a6e3a1;
56
+
--syntax-function: #89b4fa;
57
+
--syntax-import: #a6e3a1;
58
+
--syntax-information: #89dceb;
59
+
--syntax-keyword: #cba6f7;
60
+
--syntax-operator: #94e2d5;
61
+
--syntax-other: #cba6f7;
62
+
--syntax-preprocessor: #f5c2e7;
63
+
--syntax-specialchar: #89dceb;
64
+
--syntax-specialstring: #f5c2e7;
65
+
--syntax-string: #a6e3a1;
66
+
--syntax-variable: #cba6f7;
67
+
--syntax-verbatimstring: #a6e3a1;
68
+
--syntax-warning: #f9e2af;
69
+
}
70
+
}
71
+
72
+
/* pandoc syntax highlighting classes */
73
+
code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */
74
+
code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */
75
+
code span.at { color: var(--syntax-attribute); } /* attribute */
76
+
code span.bn { color: var(--syntax-basen); } /* basen */
77
+
code span.bu { color: var(--syntax-builtin); } /* builtin */
78
+
code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */
79
+
code span.ch { color: var(--syntax-char); } /* char */
80
+
code span.cn { color: var(--syntax-constant); } /* constant */
81
+
code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */
82
+
code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */
83
+
code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */
84
+
code span.dt { color: var(--syntax-datatype); } /* datatype */
85
+
code span.dv { color: var(--syntax-decval); } /* decval */
86
+
code span.er { color: var(--syntax-error); font-weight: bold; } /* error */
87
+
code span.ex { color: var(--syntax-extension); } /* extension */
88
+
code span.fl { color: var(--syntax-float); } /* float */
89
+
code span.fu { color: var(--syntax-function); } /* function */
90
+
code span.im { color: var(--syntax-import); font-weight: bold; } /* import */
91
+
code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */
92
+
code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */
93
+
code span.op { color: var(--syntax-operator); } /* operator */
94
+
code span.ot { color: var(--syntax-other); } /* other */
95
+
code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */
96
+
code span.sc { color: var(--syntax-specialchar); } /* specialchar */
97
+
code span.ss { color: var(--syntax-specialstring); } /* specialstring */
98
+
code span.st { color: var(--syntax-string); } /* string */
99
+
code span.va { color: var(--syntax-variable); } /* variable */
100
+
code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */
101
+
code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+156
docs/template.html
+156
docs/template.html
···
···
1
+
<!DOCTYPE html>
2
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$>
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<meta name="generator" content="pandoc" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
7
+
$for(author-meta)$
8
+
<meta name="author" content="$author-meta$" />
9
+
$endfor$
10
+
11
+
$if(date-meta)$
12
+
<meta name="dcterms.date" content="$date-meta$" />
13
+
$endif$
14
+
15
+
$if(keywords)$
16
+
<meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" />
17
+
$endif$
18
+
19
+
$if(description-meta)$
20
+
<meta name="description" content="$description-meta$" />
21
+
$endif$
22
+
23
+
<title>$pagetitle$</title>
24
+
25
+
<style>
26
+
$styles.css()$
27
+
</style>
28
+
29
+
$for(css)$
30
+
<link rel="stylesheet" href="$css$" />
31
+
$endfor$
32
+
33
+
$for(header-includes)$
34
+
$header-includes$
35
+
$endfor$
36
+
37
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
+
39
+
</head>
40
+
<body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh">
41
+
$for(include-before)$
42
+
$include-before$
43
+
$endfor$
44
+
45
+
$if(toc)$
46
+
<!-- mobile TOC trigger -->
47
+
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
+
<button
49
+
type="button"
50
+
popovertarget="mobile-toc-popover"
51
+
popovertargetaction="toggle"
52
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
+
>
54
+
${ menu.svg() }
55
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
+
</button>
57
+
</div>
58
+
59
+
<div
60
+
id="mobile-toc-popover"
61
+
popover
62
+
class="mobile-toc-popover
63
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
64
+
h-full overflow-y-auto shadow-sm
65
+
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
66
+
>
67
+
<div class="flex flex-col min-h-full">
68
+
<div class="flex-1 space-y-4">
69
+
<button
70
+
type="button"
71
+
popovertarget="mobile-toc-popover"
72
+
popovertargetaction="toggle"
73
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
74
+
${ x.svg() }
75
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
+
</button>
77
+
${ search.html() }
78
+
${ table-of-contents:toc.html() }
79
+
</div>
80
+
${ single-page:mode.html() }
81
+
</div>
82
+
</div>
83
+
84
+
<!-- desktop sidebar toc -->
85
+
<nav
86
+
id="$idprefix$TOC"
87
+
role="doc-toc"
88
+
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
89
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
90
+
p-4 z-50 overflow-y-auto">
91
+
${ search.html() }
92
+
<div class="flex-1">
93
+
$if(toc-title)$
94
+
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
95
+
$endif$
96
+
${ table-of-contents:toc.html() }
97
+
</div>
98
+
${ single-page:mode.html() }
99
+
</nav>
100
+
$endif$
101
+
102
+
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
103
+
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
104
+
$if(top)$
105
+
$-- only print title block if this is NOT the top page
106
+
$else$
107
+
$if(title)$
108
+
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
109
+
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
110
+
$if(subtitle)$
111
+
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
112
+
$endif$
113
+
$for(author)$
114
+
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
115
+
$endfor$
116
+
$if(date)$
117
+
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
118
+
$endif$
119
+
$endif$
120
+
</header>
121
+
$endif$
122
+
123
+
$if(abstract)$
124
+
<article class="prose dark:prose-invert max-w-none">
125
+
$abstract$
126
+
</article>
127
+
$endif$
128
+
129
+
<article class="prose dark:prose-invert max-w-none">
130
+
$body$
131
+
</article>
132
+
</main>
133
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
134
+
<div class="max-w-4xl mx-auto px-8 py-4">
135
+
<div class="flex justify-between gap-4">
136
+
<span class="flex-1">
137
+
$if(previous.url)$
138
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span>
139
+
<a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a>
140
+
$endif$
141
+
</span>
142
+
<span class="flex-1 text-right">
143
+
$if(next.url)$
144
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span>
145
+
<a href="$next.url$" accesskey="n" rel="next">$next.title$</a>
146
+
$endif$
147
+
</span>
148
+
</div>
149
+
</div>
150
+
</nav>
151
+
</div>
152
+
$for(include-after)$
153
+
$include-after$
154
+
$endfor$
155
+
</body>
156
+
</html>
+4
docs/toc.html
+4
docs/toc.html
+9
-9
flake.lock
+9
-9
flake.lock
···
35
"systems": "systems"
36
},
37
"locked": {
38
-
"lastModified": 1694529238,
39
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
40
"owner": "numtide",
41
"repo": "flake-utils",
42
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
43
"type": "github"
44
},
45
"original": {
···
56
]
57
},
58
"locked": {
59
-
"lastModified": 1754078208,
60
-
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
61
"owner": "nix-community",
62
"repo": "gomod2nix",
63
-
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
64
"type": "github"
65
},
66
"original": {
···
150
},
151
"nixpkgs": {
152
"locked": {
153
-
"lastModified": 1751984180,
154
-
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
155
"owner": "nixos",
156
"repo": "nixpkgs",
157
-
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
158
"type": "github"
159
},
160
"original": {
···
35
"systems": "systems"
36
},
37
"locked": {
38
+
"lastModified": 1731533236,
39
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
40
"owner": "numtide",
41
"repo": "flake-utils",
42
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
43
"type": "github"
44
},
45
"original": {
···
56
]
57
},
58
"locked": {
59
+
"lastModified": 1763982521,
60
+
"narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=",
61
"owner": "nix-community",
62
"repo": "gomod2nix",
63
+
"rev": "02e63a239d6eabd595db56852535992c898eba72",
64
"type": "github"
65
},
66
"original": {
···
150
},
151
"nixpkgs": {
152
"locked": {
153
+
"lastModified": 1766070988,
154
+
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
155
"owner": "nixos",
156
"repo": "nixpkgs",
157
+
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
158
"type": "github"
159
},
160
"original": {
+21
-5
flake.nix
+21
-5
flake.nix
···
76
};
77
buildGoApplication =
78
(self.callPackage "${gomod2nix}/builder" {
79
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
80
}).buildGoApplication;
81
modules = ./nix/gomod2nix.toml;
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
83
-
inherit (pkgs) gcc;
84
inherit sqlite-lib-src;
85
};
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
···
89
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
90
};
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
93
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
94
knot = self.callPackage ./nix/pkgs/knot.nix {};
95
});
96
in {
97
overlays.default = final: prev: {
98
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
99
};
100
101
packages = forAllSystems (system: let
···
104
staticPackages = mkPackageSet pkgs.pkgsStatic;
105
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
106
in {
107
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
108
109
pkgsStatic-appview = staticPackages.appview;
110
pkgsStatic-knot = staticPackages.knot;
111
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
112
pkgsStatic-spindle = staticPackages.spindle;
113
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
114
115
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
116
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
117
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
118
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
119
120
treefmt-wrapper = pkgs.treefmt.withConfig {
121
settings.formatter = {
···
156
nativeBuildInputs = [
157
pkgs.go
158
pkgs.air
159
-
pkgs.tilt
160
pkgs.gopls
161
pkgs.httpie
162
pkgs.litecli
···
76
};
77
buildGoApplication =
78
(self.callPackage "${gomod2nix}/builder" {
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
80
}).buildGoApplication;
81
modules = ./nix/gomod2nix.toml;
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
83
inherit sqlite-lib-src;
84
};
85
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
···
88
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
89
};
90
appview = self.callPackage ./nix/pkgs/appview.nix {};
91
+
docs = self.callPackage ./nix/pkgs/docs.nix {
92
+
inherit inter-fonts-src ibm-plex-mono-src lucide-src;
93
+
};
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
+
dolly = self.callPackage ./nix/pkgs/dolly.nix {};
98
});
99
in {
100
overlays.default = final: prev: {
101
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly;
102
};
103
104
packages = forAllSystems (system: let
···
107
staticPackages = mkPackageSet pkgs.pkgsStatic;
108
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
109
in {
110
+
inherit
111
+
(packages)
112
+
appview
113
+
appview-static-files
114
+
lexgen
115
+
goat
116
+
spindle
117
+
knot
118
+
knot-unwrapped
119
+
sqlite-lib
120
+
docs
121
+
dolly
122
+
;
123
124
pkgsStatic-appview = staticPackages.appview;
125
pkgsStatic-knot = staticPackages.knot;
126
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
127
pkgsStatic-spindle = staticPackages.spindle;
128
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
129
+
pkgsStatic-dolly = staticPackages.dolly;
130
131
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
132
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
133
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
134
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
135
+
pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly;
136
137
treefmt-wrapper = pkgs.treefmt.withConfig {
138
settings.formatter = {
···
173
nativeBuildInputs = [
174
pkgs.go
175
pkgs.air
176
pkgs.gopls
177
pkgs.httpie
178
pkgs.litecli
+3
-4
go.mod
+3
-4
go.mod
···
1
module tangled.org/core
2
3
-
go 1.24.4
4
5
require (
6
github.com/Blank-Xu/sql-adapter v1.1.1
···
44
github.com/stretchr/testify v1.10.0
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
-
github.com/wyatt915/goldmark-treeblood v0.0.1
48
github.com/yuin/goldmark v1.7.13
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
golang.org/x/crypto v0.40.0
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
golang.org/x/image v0.31.0
54
golang.org/x/net v0.42.0
55
-
golang.org/x/sync v0.17.0
56
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
57
gopkg.in/yaml.v3 v3.0.1
58
)
···
190
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
191
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
192
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
193
-
github.com/wyatt915/treeblood v0.1.16 // indirect
194
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
195
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
196
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
···
205
go.uber.org/atomic v1.11.0 // indirect
206
go.uber.org/multierr v1.11.0 // indirect
207
go.uber.org/zap v1.27.0 // indirect
208
golang.org/x/sys v0.34.0 // indirect
209
golang.org/x/text v0.29.0 // indirect
210
golang.org/x/time v0.12.0 // indirect
···
1
module tangled.org/core
2
3
+
go 1.25.0
4
5
require (
6
github.com/Blank-Xu/sql-adapter v1.1.1
···
44
github.com/stretchr/testify v1.10.0
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
github.com/yuin/goldmark v1.7.13
48
+
github.com/yuin/goldmark-emoji v1.0.6
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
golang.org/x/crypto v0.40.0
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
golang.org/x/image v0.31.0
54
golang.org/x/net v0.42.0
55
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
56
gopkg.in/yaml.v3 v3.0.1
57
)
···
189
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
190
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
191
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
192
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
193
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
194
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
···
203
go.uber.org/atomic v1.11.0 // indirect
204
go.uber.org/multierr v1.11.0 // indirect
205
go.uber.org/zap v1.27.0 // indirect
206
+
golang.org/x/sync v0.17.0 // indirect
207
golang.org/x/sys v0.34.0 // indirect
208
golang.org/x/text v0.29.0 // indirect
209
golang.org/x/time v0.12.0 // indirect
+2
-4
go.sum
+2
-4
go.sum
···
495
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
496
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
497
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
498
-
github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs=
499
-
github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208=
500
-
github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y=
501
-
github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
502
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
503
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
504
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
509
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
510
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
511
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
512
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
513
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
514
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
···
495
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
496
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
497
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
498
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
499
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
500
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
505
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
506
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
507
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
508
+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
509
+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
510
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
511
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
512
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4
-4
hook/hook.go
+4
-4
hook/hook.go
···
48
},
49
Commands: []*cli.Command{
50
{
51
-
Name: "post-recieve",
52
-
Usage: "sends a post-recieve hook to the knot (waits for stdin)",
53
-
Action: postRecieve,
54
},
55
},
56
}
57
}
58
59
-
func postRecieve(ctx context.Context, cmd *cli.Command) error {
60
gitDir := cmd.String("git-dir")
61
userDid := cmd.String("user-did")
62
userHandle := cmd.String("user-handle")
···
48
},
49
Commands: []*cli.Command{
50
{
51
+
Name: "post-receive",
52
+
Usage: "sends a post-receive hook to the knot (waits for stdin)",
53
+
Action: postReceive,
54
},
55
},
56
}
57
}
58
59
+
func postReceive(ctx context.Context, cmd *cli.Command) error {
60
gitDir := cmd.String("git-dir")
61
userDid := cmd.String("user-did")
62
userHandle := cmd.String("user-handle")
+1
-1
hook/setup.go
+1
-1
hook/setup.go
···
138
option_var="GIT_PUSH_OPTION_$i"
139
push_options+=(-push-option "${!option_var}")
140
done
141
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
142
`, executablePath, config.internalApi)
143
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
···
138
option_var="GIT_PUSH_OPTION_$i"
139
push_options+=(-push-option "${!option_var}")
140
done
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive
142
`, executablePath, config.internalApi)
143
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+88
ico/ico.go
+88
ico/ico.go
···
···
1
+
package ico
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/binary"
6
+
"fmt"
7
+
"image"
8
+
"image/png"
9
+
)
10
+
11
+
type IconDir struct {
12
+
Reserved uint16 // must be 0
13
+
Type uint16 // 1 for ICO, 2 for CUR
14
+
Count uint16 // number of images
15
+
}
16
+
17
+
type IconDirEntry struct {
18
+
Width uint8 // 0 means 256
19
+
Height uint8 // 0 means 256
20
+
ColorCount uint8
21
+
Reserved uint8 // must be 0
22
+
ColorPlanes uint16 // 0 or 1
23
+
BitsPerPixel uint16
24
+
SizeInBytes uint32
25
+
Offset uint32
26
+
}
27
+
28
+
func ImageToIco(img image.Image) ([]byte, error) {
29
+
// encode image as png
30
+
var pngBuf bytes.Buffer
31
+
if err := png.Encode(&pngBuf, img); err != nil {
32
+
return nil, fmt.Errorf("failed to encode PNG: %w", err)
33
+
}
34
+
pngData := pngBuf.Bytes()
35
+
36
+
// get image dimensions
37
+
bounds := img.Bounds()
38
+
width := bounds.Dx()
39
+
height := bounds.Dy()
40
+
41
+
// prepare output buffer
42
+
var icoBuf bytes.Buffer
43
+
44
+
iconDir := IconDir{
45
+
Reserved: 0,
46
+
Type: 1, // ICO format
47
+
Count: 1, // One image
48
+
}
49
+
50
+
w := uint8(width)
51
+
h := uint8(height)
52
+
53
+
// width/height of 256 should be stored as 0
54
+
if width == 256 {
55
+
w = 0
56
+
}
57
+
if height == 256 {
58
+
h = 0
59
+
}
60
+
61
+
iconDirEntry := IconDirEntry{
62
+
Width: w,
63
+
Height: h,
64
+
ColorCount: 0, // 0 for PNG (32-bit)
65
+
Reserved: 0,
66
+
ColorPlanes: 1,
67
+
BitsPerPixel: 32, // PNG with alpha
68
+
SizeInBytes: uint32(len(pngData)),
69
+
Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY
70
+
}
71
+
72
+
// write IconDir
73
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil {
74
+
return nil, fmt.Errorf("failed to write ICONDIR: %w", err)
75
+
}
76
+
77
+
// write IconDirEntry
78
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil {
79
+
return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err)
80
+
}
81
+
82
+
// write PNG data directly
83
+
if _, err := icoBuf.Write(pngData); err != nil {
84
+
return nil, fmt.Errorf("failed to write PNG data: %w", err)
85
+
}
86
+
87
+
return icoBuf.Bytes(), nil
88
+
}
+2
-1
input.css
+2
-1
input.css
+15
-4
jetstream/jetstream.go
+15
-4
jetstream/jetstream.go
···
72
// existing instances of the closure when j.WantedDids is mutated
73
return func(ctx context.Context, evt *models.Event) error {
74
75
// empty filter => all dids allowed
76
-
if len(j.wantedDids) == 0 {
77
-
return processFunc(ctx, evt)
78
}
79
80
-
if _, ok := j.wantedDids[evt.Did]; ok {
81
return processFunc(ctx, evt)
82
} else {
83
return nil
···
122
123
go func() {
124
if j.waitForDid {
125
-
for len(j.wantedDids) == 0 {
126
time.Sleep(time.Second)
127
}
128
}
···
72
// existing instances of the closure when j.WantedDids is mutated
73
return func(ctx context.Context, evt *models.Event) error {
74
75
+
j.mu.RLock()
76
// empty filter => all dids allowed
77
+
matches := len(j.wantedDids) == 0
78
+
if !matches {
79
+
if _, ok := j.wantedDids[evt.Did]; ok {
80
+
matches = true
81
+
}
82
}
83
+
j.mu.RUnlock()
84
85
+
if matches {
86
return processFunc(ctx, evt)
87
} else {
88
return nil
···
127
128
go func() {
129
if j.waitForDid {
130
+
for {
131
+
j.mu.RLock()
132
+
hasDid := len(j.wantedDids) != 0
133
+
j.mu.RUnlock()
134
+
if hasDid {
135
+
break
136
+
}
137
time.Sleep(time.Second)
138
}
139
}
+81
knotserver/db/db.go
+81
knotserver/db/db.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"log/slog"
7
+
"strings"
8
+
9
+
_ "github.com/mattn/go-sqlite3"
10
+
"tangled.org/core/log"
11
+
)
12
+
13
+
type DB struct {
14
+
db *sql.DB
15
+
logger *slog.Logger
16
+
}
17
+
18
+
func Setup(ctx context.Context, dbPath string) (*DB, error) {
19
+
// https://github.com/mattn/go-sqlite3#connection-string
20
+
opts := []string{
21
+
"_foreign_keys=1",
22
+
"_journal_mode=WAL",
23
+
"_synchronous=NORMAL",
24
+
"_auto_vacuum=incremental",
25
+
}
26
+
27
+
logger := log.FromContext(ctx)
28
+
logger = log.SubLogger(logger, "db")
29
+
30
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
31
+
if err != nil {
32
+
return nil, err
33
+
}
34
+
35
+
conn, err := db.Conn(ctx)
36
+
if err != nil {
37
+
return nil, err
38
+
}
39
+
defer conn.Close()
40
+
41
+
_, err = conn.ExecContext(ctx, `
42
+
create table if not exists known_dids (
43
+
did text primary key
44
+
);
45
+
46
+
create table if not exists public_keys (
47
+
id integer primary key autoincrement,
48
+
did text not null,
49
+
key text not null,
50
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
51
+
unique(did, key),
52
+
foreign key (did) references known_dids(did) on delete cascade
53
+
);
54
+
55
+
create table if not exists _jetstream (
56
+
id integer primary key autoincrement,
57
+
last_time_us integer not null
58
+
);
59
+
60
+
create table if not exists events (
61
+
rkey text not null,
62
+
nsid text not null,
63
+
event text not null, -- json
64
+
created integer not null default (strftime('%s', 'now')),
65
+
primary key (rkey, nsid)
66
+
);
67
+
68
+
create table if not exists migrations (
69
+
id integer primary key autoincrement,
70
+
name text unique
71
+
);
72
+
`)
73
+
if err != nil {
74
+
return nil, err
75
+
}
76
+
77
+
return &DB{
78
+
db: db,
79
+
logger: logger,
80
+
}, nil
81
+
}
-64
knotserver/db/init.go
-64
knotserver/db/init.go
···
1
-
package db
2
-
3
-
import (
4
-
"database/sql"
5
-
"strings"
6
-
7
-
_ "github.com/mattn/go-sqlite3"
8
-
)
9
-
10
-
type DB struct {
11
-
db *sql.DB
12
-
}
13
-
14
-
func Setup(dbPath string) (*DB, error) {
15
-
// https://github.com/mattn/go-sqlite3#connection-string
16
-
opts := []string{
17
-
"_foreign_keys=1",
18
-
"_journal_mode=WAL",
19
-
"_synchronous=NORMAL",
20
-
"_auto_vacuum=incremental",
21
-
}
22
-
23
-
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
24
-
if err != nil {
25
-
return nil, err
26
-
}
27
-
28
-
// NOTE: If any other migration is added here, you MUST
29
-
// copy the pattern in appview: use a single sql.Conn
30
-
// for every migration.
31
-
32
-
_, err = db.Exec(`
33
-
create table if not exists known_dids (
34
-
did text primary key
35
-
);
36
-
37
-
create table if not exists public_keys (
38
-
id integer primary key autoincrement,
39
-
did text not null,
40
-
key text not null,
41
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
42
-
unique(did, key),
43
-
foreign key (did) references known_dids(did) on delete cascade
44
-
);
45
-
46
-
create table if not exists _jetstream (
47
-
id integer primary key autoincrement,
48
-
last_time_us integer not null
49
-
);
50
-
51
-
create table if not exists events (
52
-
rkey text not null,
53
-
nsid text not null,
54
-
event text not null, -- json
55
-
created integer not null default (strftime('%s', 'now')),
56
-
primary key (rkey, nsid)
57
-
);
58
-
`)
59
-
if err != nil {
60
-
return nil, err
61
-
}
62
-
63
-
return &DB{db: db}, nil
64
-
}
···
+1
-17
knotserver/git/diff.go
+1
-17
knotserver/git/diff.go
···
77
nd.Diff = append(nd.Diff, ndiff)
78
}
79
80
-
nd.Stat.FilesChanged = len(diffs)
81
-
nd.Commit.This = c.Hash.String()
82
-
nd.Commit.PGPSignature = c.PGPSignature
83
-
nd.Commit.Committer = c.Committer
84
-
nd.Commit.Tree = c.TreeHash.String()
85
-
86
-
if parent.Hash.IsZero() {
87
-
nd.Commit.Parent = ""
88
-
} else {
89
-
nd.Commit.Parent = parent.Hash.String()
90
-
}
91
-
nd.Commit.Author = c.Author
92
-
nd.Commit.Message = c.Message
93
-
94
-
if v, ok := c.ExtraHeaders["change-id"]; ok {
95
-
nd.Commit.ChangedId = string(v)
96
-
}
97
98
return &nd, nil
99
}
+38
-2
knotserver/git/fork.go
+38
-2
knotserver/git/fork.go
···
3
import (
4
"errors"
5
"fmt"
6
"os/exec"
7
8
"github.com/go-git/go-git/v5"
9
"github.com/go-git/go-git/v5/config"
10
)
11
12
-
func Fork(repoPath, source string) error {
13
-
cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath)
14
if err := cloneCmd.Run(); err != nil {
15
return fmt.Errorf("failed to bare clone repository: %w", err)
16
}
···
21
}
22
23
return nil
24
}
25
26
func (g *GitRepo) Sync() error {
···
3
import (
4
"errors"
5
"fmt"
6
+
"log/slog"
7
+
"net/url"
8
"os/exec"
9
+
"path/filepath"
10
11
"github.com/go-git/go-git/v5"
12
"github.com/go-git/go-git/v5/config"
13
+
knotconfig "tangled.org/core/knotserver/config"
14
)
15
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)
27
if err := cloneCmd.Run(); err != nil {
28
return fmt.Errorf("failed to bare clone repository: %w", err)
29
}
···
34
}
35
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
60
}
61
62
func (g *GitRepo) Sync() error {
+13
-1
knotserver/git/service/service.go
+13
-1
knotserver/git/service/service.go
···
95
return c.RunService(cmd)
96
}
97
98
+
func (c *ServiceCommand) UploadArchive() error {
99
+
cmd := exec.Command("git", []string{
100
+
"upload-archive",
101
+
".",
102
+
}...)
103
+
104
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
105
+
cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol))
106
+
cmd.Dir = c.Dir
107
+
108
+
return c.RunService(cmd)
109
+
}
110
+
111
func (c *ServiceCommand) UploadPack() error {
112
cmd := exec.Command("git", []string{
113
"upload-pack",
114
"--stateless-rpc",
115
".",
+47
knotserver/git.go
+47
knotserver/git.go
···
56
}
57
}
58
59
+
func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) {
60
+
did := chi.URLParam(r, "did")
61
+
name := chi.URLParam(r, "name")
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
+
if err != nil {
64
+
gitError(w, err.Error(), http.StatusInternalServerError)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
+
return
67
+
}
68
+
69
+
const expectedContentType = "application/x-git-upload-archive-request"
70
+
contentType := r.Header.Get("Content-Type")
71
+
if contentType != expectedContentType {
72
+
gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
73
+
}
74
+
75
+
var bodyReader io.ReadCloser = r.Body
76
+
if r.Header.Get("Content-Encoding") == "gzip" {
77
+
gzipReader, err := gzip.NewReader(r.Body)
78
+
if err != nil {
79
+
gitError(w, err.Error(), http.StatusInternalServerError)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err)
81
+
return
82
+
}
83
+
defer gzipReader.Close()
84
+
bodyReader = gzipReader
85
+
}
86
+
87
+
w.Header().Set("Content-Type", "application/x-git-upload-archive-result")
88
+
89
+
h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo)
90
+
91
+
cmd := service.ServiceCommand{
92
+
GitProtocol: r.Header.Get("Git-Protocol"),
93
+
Dir: repo,
94
+
Stdout: w,
95
+
Stdin: bodyReader,
96
+
}
97
+
98
+
w.WriteHeader(http.StatusOK)
99
+
100
+
if err := cmd.UploadArchive(); err != nil {
101
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
102
+
return
103
+
}
104
+
}
105
+
106
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
107
did := chi.URLParam(r, "did")
108
name := chi.URLParam(r, "name")
+1
-25
knotserver/router.go
+1
-25
knotserver/router.go
···
5
"fmt"
6
"log/slog"
7
"net/http"
8
-
"strings"
9
10
"github.com/go-chi/chi/v5"
11
"tangled.org/core/idresolver"
···
80
})
81
82
r.Route("/{did}", func(r chi.Router) {
83
-
r.Use(h.resolveDidRedirect)
84
r.Route("/{name}", func(r chi.Router) {
85
// routes for git operations
86
r.Get("/info/refs", h.InfoRefs)
87
r.Post("/git-upload-pack", h.UploadPack)
88
r.Post("/git-receive-pack", h.ReceivePack)
89
})
···
115
}
116
117
return xrpc.Router()
118
-
}
119
-
120
-
func (h *Knot) resolveDidRedirect(next http.Handler) http.Handler {
121
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122
-
didOrHandle := chi.URLParam(r, "did")
123
-
if strings.HasPrefix(didOrHandle, "did:") {
124
-
next.ServeHTTP(w, r)
125
-
return
126
-
}
127
-
128
-
trimmed := strings.TrimPrefix(didOrHandle, "@")
129
-
id, err := h.resolver.ResolveIdent(r.Context(), trimmed)
130
-
if err != nil {
131
-
// invalid did or handle
132
-
h.l.Error("failed to resolve did/handle", "handle", trimmed, "err", err)
133
-
http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError)
134
-
return
135
-
}
136
-
137
-
suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle)
138
-
newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery)
139
-
http.Redirect(w, r, newPath, http.StatusTemporaryRedirect)
140
-
})
141
}
142
143
func (h *Knot) configureOwner() error {
···
5
"fmt"
6
"log/slog"
7
"net/http"
8
9
"github.com/go-chi/chi/v5"
10
"tangled.org/core/idresolver"
···
79
})
80
81
r.Route("/{did}", func(r chi.Router) {
82
r.Route("/{name}", func(r chi.Router) {
83
// routes for git operations
84
r.Get("/info/refs", h.InfoRefs)
85
+
r.Post("/git-upload-archive", h.UploadArchive)
86
r.Post("/git-upload-pack", h.UploadPack)
87
r.Post("/git-receive-pack", h.ReceivePack)
88
})
···
114
}
115
116
return xrpc.Router()
117
}
118
119
func (h *Knot) configureOwner() error {
+1
-1
knotserver/server.go
+1
-1
knotserver/server.go
+1
-1
knotserver/xrpc/create_repo.go
+1
-1
knotserver/xrpc/create_repo.go
···
84
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
85
86
if data.Source != nil && *data.Source != "" {
87
-
err = git.Fork(repoPath, *data.Source)
88
if err != nil {
89
l.Error("forking repo", "error", err.Error())
90
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
···
84
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
85
86
if data.Source != nil && *data.Source != "" {
87
+
err = git.Fork(repoPath, *data.Source, h.Config)
88
if err != nil {
89
l.Error("forking repo", "error", err.Error())
90
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+6
-1
knotserver/xrpc/repo_log.go
+6
-1
knotserver/xrpc/repo_log.go
···
62
return
63
}
64
65
+
tcommits := make([]types.Commit, len(commits))
66
+
for i, c := range commits {
67
+
tcommits[i].FromGoGitCommit(c)
68
+
}
69
+
70
// Create response using existing types.RepoLogResponse
71
response := types.RepoLogResponse{
72
+
Commits: tcommits,
73
Ref: ref,
74
Page: (offset / limit) + 1,
75
PerPage: limit,
+14
lexicons/issue/comment.json
+14
lexicons/issue/comment.json
···
29
"replyTo": {
30
"type": "string",
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
+
}
46
}
47
}
48
}
+14
lexicons/issue/issue.json
+14
lexicons/issue/issue.json
···
24
"createdAt": {
25
"type": "string",
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
+
}
41
}
42
}
43
}
+14
lexicons/pulls/comment.json
+14
lexicons/pulls/comment.json
···
25
"createdAt": {
26
"type": "string",
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
+
}
42
}
43
}
44
}
+24
-2
lexicons/pulls/pull.json
+24
-2
lexicons/pulls/pull.json
···
12
"required": [
13
"target",
14
"title",
15
+
"patchBlob",
16
"createdAt"
17
],
18
"properties": {
···
27
"type": "string"
28
},
29
"patch": {
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": [
36
+
"text/x-patch"
37
+
],
38
+
"description": "patch content"
39
},
40
"source": {
41
"type": "ref",
···
44
"createdAt": {
45
"type": "string",
46
"format": "datetime"
47
+
},
48
+
"mentions": {
49
+
"type": "array",
50
+
"items": {
51
+
"type": "string",
52
+
"format": "did"
53
+
}
54
+
},
55
+
"references": {
56
+
"type": "array",
57
+
"items": {
58
+
"type": "string",
59
+
"format": "at-uri"
60
+
}
61
}
62
}
63
}
+3
-30
nix/gomod2nix.toml
+3
-30
nix/gomod2nix.toml
···
165
[mod."github.com/davecgh/go-spew"]
166
version = "v1.1.2-0.20180830191138-d8f796af33cc"
167
hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc="
168
-
[mod."github.com/decred/dcrd/dcrec/secp256k1/v4"]
169
-
version = "v4.4.0"
170
-
hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg="
171
[mod."github.com/dgraph-io/ristretto"]
172
version = "v0.2.0"
173
hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw="
···
373
[mod."github.com/klauspost/cpuid/v2"]
374
version = "v2.3.0"
375
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
376
-
[mod."github.com/lestrrat-go/blackmagic"]
377
-
version = "v1.0.4"
378
-
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
379
-
[mod."github.com/lestrrat-go/httpcc"]
380
-
version = "v1.0.1"
381
-
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
382
-
[mod."github.com/lestrrat-go/httprc"]
383
-
version = "v1.0.6"
384
-
hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM="
385
-
[mod."github.com/lestrrat-go/iter"]
386
-
version = "v1.0.2"
387
-
hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw="
388
-
[mod."github.com/lestrrat-go/jwx/v2"]
389
-
version = "v2.1.6"
390
-
hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc="
391
-
[mod."github.com/lestrrat-go/option"]
392
-
version = "v1.0.1"
393
-
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
394
[mod."github.com/lucasb-eyer/go-colorful"]
395
version = "v1.2.0"
396
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
···
511
[mod."github.com/ryanuber/go-glob"]
512
version = "v1.0.0"
513
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
514
-
[mod."github.com/segmentio/asm"]
515
-
version = "v1.2.0"
516
-
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
517
[mod."github.com/sergi/go-diff"]
518
version = "v1.1.0"
519
hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY="
···
548
[mod."github.com/whyrusleeping/cbor-gen"]
549
version = "v0.3.1"
550
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
551
-
[mod."github.com/wyatt915/goldmark-treeblood"]
552
-
version = "v0.0.1"
553
-
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
554
-
[mod."github.com/wyatt915/treeblood"]
555
-
version = "v0.1.16"
556
-
hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw="
557
[mod."github.com/xo/terminfo"]
558
version = "v0.0.0-20220910002029-abceb7e1c41e"
559
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
560
[mod."github.com/yuin/goldmark"]
561
version = "v1.7.13"
562
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
563
[mod."github.com/yuin/goldmark-highlighting/v2"]
564
version = "v2.0.0-20230729083705-37449abec8cc"
565
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
···
165
[mod."github.com/davecgh/go-spew"]
166
version = "v1.1.2-0.20180830191138-d8f796af33cc"
167
hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc="
168
[mod."github.com/dgraph-io/ristretto"]
169
version = "v0.2.0"
170
hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw="
···
370
[mod."github.com/klauspost/cpuid/v2"]
371
version = "v2.3.0"
372
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
373
[mod."github.com/lucasb-eyer/go-colorful"]
374
version = "v1.2.0"
375
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
···
490
[mod."github.com/ryanuber/go-glob"]
491
version = "v1.0.0"
492
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
493
[mod."github.com/sergi/go-diff"]
494
version = "v1.1.0"
495
hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY="
···
524
[mod."github.com/whyrusleeping/cbor-gen"]
525
version = "v0.3.1"
526
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
527
[mod."github.com/xo/terminfo"]
528
version = "v0.0.0-20220910002029-abceb7e1c41e"
529
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
530
[mod."github.com/yuin/goldmark"]
531
version = "v1.7.13"
532
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
533
+
[mod."github.com/yuin/goldmark-emoji"]
534
+
version = "v1.0.6"
535
+
hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY="
536
[mod."github.com/yuin/goldmark-highlighting/v2"]
537
version = "v2.0.0-20230729083705-37449abec8cc"
538
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+2
nix/modules/knot.nix
+2
nix/modules/knot.nix
+6
-1
nix/pkgs/appview-static-files.nix
+6
-1
nix/pkgs/appview-static-files.nix
···
8
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
src,
12
}:
13
runCommandLocal "appview-static-files" {
···
17
(allow file-read* (subpath "/System/Library/OpenSSL"))
18
'';
19
} ''
20
-
mkdir -p $out/{fonts,icons} && cd $out
21
cp -f ${htmx-src} htmx.min.js
22
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
23
cp -rf ${lucide-src}/*.svg icons/
···
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
# for whatever reason (produces broken css), so we are doing this instead
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
···
8
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
+
dolly,
12
src,
13
}:
14
runCommandLocal "appview-static-files" {
···
18
(allow file-read* (subpath "/System/Library/OpenSSL"))
19
'';
20
} ''
21
+
mkdir -p $out/{fonts,icons,logos} && cd $out
22
cp -f ${htmx-src} htmx.min.js
23
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
24
cp -rf ${lucide-src}/*.svg icons/
···
27
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
28
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
29
cp -f ${actor-typeahead-src}/actor-typeahead.js .
30
+
31
+
${dolly}/bin/dolly -output logos/dolly.png -size 180x180
32
+
${dolly}/bin/dolly -output logos/dolly.ico -size 48x48
33
+
${dolly}/bin/dolly -output logos/dolly.svg
34
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
35
# for whatever reason (produces broken css), so we are doing this instead
36
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+53
nix/pkgs/docs.nix
+53
nix/pkgs/docs.nix
···
···
1
+
{
2
+
pandoc,
3
+
tailwindcss,
4
+
runCommandLocal,
5
+
inter-fonts-src,
6
+
ibm-plex-mono-src,
7
+
lucide-src,
8
+
src,
9
+
}:
10
+
runCommandLocal "docs" {} ''
11
+
mkdir -p working
12
+
13
+
# copy templates, themes, styles, filters to working directory
14
+
cp ${src}/docs/*.html working/
15
+
cp ${src}/docs/*.theme working/
16
+
cp ${src}/docs/*.css working/
17
+
18
+
# icons
19
+
cp -rf ${lucide-src}/*.svg working/
20
+
21
+
# content - chunked
22
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
+
-o $out/ \
24
+
-t chunkedhtml \
25
+
--variable toc \
26
+
--variable-json single-page=false \
27
+
--toc-depth=2 \
28
+
--css=stylesheet.css \
29
+
--chunk-template="%i.html" \
30
+
--highlight-style=working/highlight.theme \
31
+
--template=working/template.html
32
+
33
+
# content - single page
34
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
35
+
-o $out/single-page.html \
36
+
--toc \
37
+
--variable toc \
38
+
--variable single-page \
39
+
--toc-depth=2 \
40
+
--css=stylesheet.css \
41
+
--highlight-style=working/highlight.theme \
42
+
--template=working/template.html
43
+
44
+
# fonts
45
+
mkdir -p $out/static/fonts
46
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/
47
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/
48
+
cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/
49
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/
50
+
51
+
# styles
52
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css
53
+
''
+21
nix/pkgs/dolly.nix
+21
nix/pkgs/dolly.nix
···
···
1
+
{
2
+
buildGoApplication,
3
+
modules,
4
+
src,
5
+
}:
6
+
buildGoApplication {
7
+
pname = "dolly";
8
+
version = "0.1.0";
9
+
inherit src modules;
10
+
11
+
# patch the static dir
12
+
postUnpack = ''
13
+
pushd source
14
+
mkdir -p appview/pages/static
15
+
touch appview/pages/static/x
16
+
popd
17
+
'';
18
+
19
+
doCheck = false;
20
+
subPackages = ["cmd/dolly"];
21
+
}
+7
-5
nix/pkgs/sqlite-lib.nix
+7
-5
nix/pkgs/sqlite-lib.nix
···
1
{
2
-
gcc,
3
stdenv,
4
sqlite-lib-src,
5
}:
6
stdenv.mkDerivation {
7
name = "sqlite-lib";
8
src = sqlite-lib-src;
9
-
nativeBuildInputs = [gcc];
10
buildPhase = ''
11
-
gcc -c sqlite3.c
12
-
ar rcs libsqlite3.a sqlite3.o
13
-
ranlib libsqlite3.a
14
mkdir -p $out/include $out/lib
15
cp *.h $out/include
16
cp libsqlite3.a $out/lib
···
1
{
2
stdenv,
3
sqlite-lib-src,
4
}:
5
stdenv.mkDerivation {
6
name = "sqlite-lib";
7
src = sqlite-lib-src;
8
+
9
buildPhase = ''
10
+
$CC -c sqlite3.c
11
+
$AR rcs libsqlite3.a sqlite3.o
12
+
$RANLIB libsqlite3.a
13
+
'';
14
+
15
+
installPhase = ''
16
mkdir -p $out/include $out/lib
17
cp *.h $out/include
18
cp libsqlite3.a $out/lib
+5
-5
nix/vm.nix
+5
-5
nix/vm.nix
···
8
var = builtins.getEnv name;
9
in
10
if var == ""
11
-
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
else var;
13
envVarOr = name: default: let
14
var = builtins.getEnv name;
···
48
# knot
49
{
50
from = "host";
51
-
host.port = 6000;
52
-
guest.port = 6000;
53
}
54
# spindle
55
{
···
87
motd = "Welcome to the development knot!\n";
88
server = {
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
90
-
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
plcUrl = plcUrl;
92
jetstreamEndpoint = jetstream;
93
-
listenAddr = "0.0.0.0:6000";
94
};
95
};
96
services.tangled.spindle = {
···
8
var = builtins.getEnv name;
9
in
10
if var == ""
11
+
then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details"
12
else var;
13
envVarOr = name: default: let
14
var = builtins.getEnv name;
···
48
# knot
49
{
50
from = "host";
51
+
host.port = 6444;
52
+
guest.port = 6444;
53
}
54
# spindle
55
{
···
87
motd = "Welcome to the development knot!\n";
88
server = {
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444";
91
plcUrl = plcUrl;
92
jetstreamEndpoint = jetstream;
93
+
listenAddr = "0.0.0.0:6444";
94
};
95
};
96
services.tangled.spindle = {
+122
orm/orm.go
+122
orm/orm.go
···
···
1
+
package orm
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"fmt"
7
+
"log/slog"
8
+
"reflect"
9
+
"strings"
10
+
)
11
+
12
+
type migrationFn = func(*sql.Tx) error
13
+
14
+
func RunMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
15
+
logger = logger.With("migration", name)
16
+
17
+
tx, err := c.BeginTx(context.Background(), nil)
18
+
if err != nil {
19
+
return err
20
+
}
21
+
defer tx.Rollback()
22
+
23
+
var exists bool
24
+
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
25
+
if err != nil {
26
+
return err
27
+
}
28
+
29
+
if !exists {
30
+
// run migration
31
+
err = migrationFn(tx)
32
+
if err != nil {
33
+
logger.Error("failed to run migration", "err", err)
34
+
return err
35
+
}
36
+
37
+
// mark migration as complete
38
+
_, err = tx.Exec("insert into migrations (name) values (?)", name)
39
+
if err != nil {
40
+
logger.Error("failed to mark migration as complete", "err", err)
41
+
return err
42
+
}
43
+
44
+
// commit the transaction
45
+
if err := tx.Commit(); err != nil {
46
+
return err
47
+
}
48
+
49
+
logger.Info("migration applied successfully")
50
+
} else {
51
+
logger.Warn("skipped migration, already applied")
52
+
}
53
+
54
+
return nil
55
+
}
56
+
57
+
type Filter struct {
58
+
Key string
59
+
arg any
60
+
Cmp string
61
+
}
62
+
63
+
func newFilter(key, cmp string, arg any) Filter {
64
+
return Filter{
65
+
Key: key,
66
+
arg: arg,
67
+
Cmp: cmp,
68
+
}
69
+
}
70
+
71
+
func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) }
72
+
func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) }
73
+
func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) }
74
+
func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) }
75
+
func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) }
76
+
func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) }
77
+
func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) }
78
+
func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) }
79
+
func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) }
80
+
func FilterContains(key string, arg any) Filter {
81
+
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
82
+
}
83
+
84
+
func (f Filter) Condition() string {
85
+
rv := reflect.ValueOf(f.arg)
86
+
kind := rv.Kind()
87
+
88
+
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
89
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
90
+
if rv.Len() == 0 {
91
+
// always false
92
+
return "1 = 0"
93
+
}
94
+
95
+
placeholders := make([]string, rv.Len())
96
+
for i := range placeholders {
97
+
placeholders[i] = "?"
98
+
}
99
+
100
+
return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", "))
101
+
}
102
+
103
+
return fmt.Sprintf("%s %s ?", f.Key, f.Cmp)
104
+
}
105
+
106
+
func (f Filter) Arg() []any {
107
+
rv := reflect.ValueOf(f.arg)
108
+
kind := rv.Kind()
109
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
110
+
if rv.Len() == 0 {
111
+
return nil
112
+
}
113
+
114
+
out := make([]any, rv.Len())
115
+
for i := range rv.Len() {
116
+
out[i] = rv.Index(i).Interface()
117
+
}
118
+
return out
119
+
}
120
+
121
+
return []any{f.arg}
122
+
}
-1
patchutil/patchutil.go
-1
patchutil/patchutil.go
+8
rbac/rbac.go
+8
rbac/rbac.go
···
285
return e.E.Enforce(user, domain, repo, "repo:delete")
286
}
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
+
296
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
297
return e.E.Enforce(user, domain, repo, "repo:push")
298
}
+3
-3
readme.md
+3
-3
readme.md
···
10
11
## docs
12
13
+
- [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide)
14
+
- [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!**
15
+
- [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled)
16
17
## security
18
+31
sets/gen.go
+31
sets/gen.go
···
···
1
+
package sets
2
+
3
+
import (
4
+
"math/rand"
5
+
"reflect"
6
+
"testing/quick"
7
+
)
8
+
9
+
func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value {
10
+
s := New[T]()
11
+
12
+
var zero T
13
+
itemType := reflect.TypeOf(zero)
14
+
15
+
for {
16
+
if s.Len() >= size {
17
+
break
18
+
}
19
+
20
+
item, ok := quick.Value(itemType, rand)
21
+
if !ok {
22
+
continue
23
+
}
24
+
25
+
if val, ok := item.Interface().(T); ok {
26
+
s.Insert(val)
27
+
}
28
+
}
29
+
30
+
return reflect.ValueOf(s)
31
+
}
+35
sets/readme.txt
+35
sets/readme.txt
···
···
1
+
sets
2
+
----
3
+
set datastructure for go with generics and iterators. the
4
+
api is supposed to mimic rust's std::collections::HashSet api.
5
+
6
+
s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4}))
7
+
s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6}))
8
+
9
+
union := sets.Collect(s1.Union(s2))
10
+
intersect := sets.Collect(s1.Intersection(s2))
11
+
diff := sets.Collect(s1.Difference(s2))
12
+
symdiff := sets.Collect(s1.SymmetricDifference(s2))
13
+
14
+
s1.Len() // 4
15
+
s1.Contains(1) // true
16
+
s1.IsEmpty() // false
17
+
s1.IsSubset(s2) // true
18
+
s1.IsSuperset(s2) // false
19
+
s1.IsDisjoint(s2) // false
20
+
21
+
if exists := s1.Insert(1); exists {
22
+
// already existed in set
23
+
}
24
+
25
+
if existed := s1.Remove(1); existed {
26
+
// existed in set, now removed
27
+
}
28
+
29
+
30
+
testing
31
+
-------
32
+
includes property-based tests using the wonderful
33
+
testing/quick module!
34
+
35
+
go test -v
+174
sets/set.go
+174
sets/set.go
···
···
1
+
package sets
2
+
3
+
import (
4
+
"iter"
5
+
"maps"
6
+
)
7
+
8
+
type Set[T comparable] struct {
9
+
data map[T]struct{}
10
+
}
11
+
12
+
func New[T comparable]() Set[T] {
13
+
return Set[T]{
14
+
data: make(map[T]struct{}),
15
+
}
16
+
}
17
+
18
+
func (s *Set[T]) Insert(item T) bool {
19
+
_, exists := s.data[item]
20
+
s.data[item] = struct{}{}
21
+
return !exists
22
+
}
23
+
24
+
func Singleton[T comparable](item T) Set[T] {
25
+
n := New[T]()
26
+
_ = n.Insert(item)
27
+
return n
28
+
}
29
+
30
+
func (s *Set[T]) Remove(item T) bool {
31
+
_, exists := s.data[item]
32
+
if exists {
33
+
delete(s.data, item)
34
+
}
35
+
return exists
36
+
}
37
+
38
+
func (s Set[T]) Contains(item T) bool {
39
+
_, exists := s.data[item]
40
+
return exists
41
+
}
42
+
43
+
func (s Set[T]) Len() int {
44
+
return len(s.data)
45
+
}
46
+
47
+
func (s Set[T]) IsEmpty() bool {
48
+
return len(s.data) == 0
49
+
}
50
+
51
+
func (s *Set[T]) Clear() {
52
+
s.data = make(map[T]struct{})
53
+
}
54
+
55
+
func (s Set[T]) All() iter.Seq[T] {
56
+
return func(yield func(T) bool) {
57
+
for item := range s.data {
58
+
if !yield(item) {
59
+
return
60
+
}
61
+
}
62
+
}
63
+
}
64
+
65
+
func (s Set[T]) Clone() Set[T] {
66
+
return Set[T]{
67
+
data: maps.Clone(s.data),
68
+
}
69
+
}
70
+
71
+
func (s Set[T]) Union(other Set[T]) iter.Seq[T] {
72
+
if s.Len() >= other.Len() {
73
+
return chain(s.All(), other.Difference(s))
74
+
} else {
75
+
return chain(other.All(), s.Difference(other))
76
+
}
77
+
}
78
+
79
+
func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] {
80
+
return func(yield func(T) bool) {
81
+
for _, seq := range seqs {
82
+
for item := range seq {
83
+
if !yield(item) {
84
+
return
85
+
}
86
+
}
87
+
}
88
+
}
89
+
}
90
+
91
+
func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] {
92
+
return func(yield func(T) bool) {
93
+
for item := range s.data {
94
+
if other.Contains(item) {
95
+
if !yield(item) {
96
+
return
97
+
}
98
+
}
99
+
}
100
+
}
101
+
}
102
+
103
+
func (s Set[T]) Difference(other Set[T]) iter.Seq[T] {
104
+
return func(yield func(T) bool) {
105
+
for item := range s.data {
106
+
if !other.Contains(item) {
107
+
if !yield(item) {
108
+
return
109
+
}
110
+
}
111
+
}
112
+
}
113
+
}
114
+
115
+
func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] {
116
+
return func(yield func(T) bool) {
117
+
for item := range s.data {
118
+
if !other.Contains(item) {
119
+
if !yield(item) {
120
+
return
121
+
}
122
+
}
123
+
}
124
+
for item := range other.data {
125
+
if !s.Contains(item) {
126
+
if !yield(item) {
127
+
return
128
+
}
129
+
}
130
+
}
131
+
}
132
+
}
133
+
134
+
func (s Set[T]) IsSubset(other Set[T]) bool {
135
+
for item := range s.data {
136
+
if !other.Contains(item) {
137
+
return false
138
+
}
139
+
}
140
+
return true
141
+
}
142
+
143
+
func (s Set[T]) IsSuperset(other Set[T]) bool {
144
+
return other.IsSubset(s)
145
+
}
146
+
147
+
func (s Set[T]) IsDisjoint(other Set[T]) bool {
148
+
for item := range s.data {
149
+
if other.Contains(item) {
150
+
return false
151
+
}
152
+
}
153
+
return true
154
+
}
155
+
156
+
func (s Set[T]) Equal(other Set[T]) bool {
157
+
if s.Len() != other.Len() {
158
+
return false
159
+
}
160
+
for item := range s.data {
161
+
if !other.Contains(item) {
162
+
return false
163
+
}
164
+
}
165
+
return true
166
+
}
167
+
168
+
func Collect[T comparable](seq iter.Seq[T]) Set[T] {
169
+
result := New[T]()
170
+
for item := range seq {
171
+
result.Insert(item)
172
+
}
173
+
return result
174
+
}
+411
sets/set_test.go
+411
sets/set_test.go
···
···
1
+
package sets
2
+
3
+
import (
4
+
"slices"
5
+
"testing"
6
+
"testing/quick"
7
+
)
8
+
9
+
func TestNew(t *testing.T) {
10
+
s := New[int]()
11
+
if s.Len() != 0 {
12
+
t.Errorf("New set should be empty, got length %d", s.Len())
13
+
}
14
+
if !s.IsEmpty() {
15
+
t.Error("New set should be empty")
16
+
}
17
+
}
18
+
19
+
func TestFromSlice(t *testing.T) {
20
+
s := Collect(slices.Values([]int{1, 2, 3, 2, 1}))
21
+
if s.Len() != 3 {
22
+
t.Errorf("Expected length 3, got %d", s.Len())
23
+
}
24
+
if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) {
25
+
t.Error("Set should contain all unique elements from slice")
26
+
}
27
+
}
28
+
29
+
func TestInsert(t *testing.T) {
30
+
s := New[string]()
31
+
32
+
if !s.Insert("hello") {
33
+
t.Error("First insert should return true")
34
+
}
35
+
if s.Insert("hello") {
36
+
t.Error("Duplicate insert should return false")
37
+
}
38
+
if s.Len() != 1 {
39
+
t.Errorf("Expected length 1, got %d", s.Len())
40
+
}
41
+
}
42
+
43
+
func TestRemove(t *testing.T) {
44
+
s := Collect(slices.Values([]int{1, 2, 3}))
45
+
46
+
if !s.Remove(2) {
47
+
t.Error("Remove existing element should return true")
48
+
}
49
+
if s.Remove(2) {
50
+
t.Error("Remove non-existing element should return false")
51
+
}
52
+
if s.Contains(2) {
53
+
t.Error("Element should be removed")
54
+
}
55
+
if s.Len() != 2 {
56
+
t.Errorf("Expected length 2, got %d", s.Len())
57
+
}
58
+
}
59
+
60
+
func TestContains(t *testing.T) {
61
+
s := Collect(slices.Values([]int{1, 2, 3}))
62
+
63
+
if !s.Contains(1) {
64
+
t.Error("Should contain 1")
65
+
}
66
+
if s.Contains(4) {
67
+
t.Error("Should not contain 4")
68
+
}
69
+
}
70
+
71
+
func TestClear(t *testing.T) {
72
+
s := Collect(slices.Values([]int{1, 2, 3}))
73
+
s.Clear()
74
+
75
+
if !s.IsEmpty() {
76
+
t.Error("Set should be empty after clear")
77
+
}
78
+
if s.Len() != 0 {
79
+
t.Errorf("Expected length 0, got %d", s.Len())
80
+
}
81
+
}
82
+
83
+
func TestIterator(t *testing.T) {
84
+
s := Collect(slices.Values([]int{1, 2, 3}))
85
+
var items []int
86
+
87
+
for item := range s.All() {
88
+
items = append(items, item)
89
+
}
90
+
91
+
slices.Sort(items)
92
+
expected := []int{1, 2, 3}
93
+
if !slices.Equal(items, expected) {
94
+
t.Errorf("Expected %v, got %v", expected, items)
95
+
}
96
+
}
97
+
98
+
func TestClone(t *testing.T) {
99
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
100
+
s2 := s1.Clone()
101
+
102
+
if !s1.Equal(s2) {
103
+
t.Error("Cloned set should be equal to original")
104
+
}
105
+
106
+
s2.Insert(4)
107
+
if s1.Contains(4) {
108
+
t.Error("Modifying clone should not affect original")
109
+
}
110
+
}
111
+
112
+
func TestUnion(t *testing.T) {
113
+
s1 := Collect(slices.Values([]int{1, 2}))
114
+
s2 := Collect(slices.Values([]int{2, 3}))
115
+
116
+
result := Collect(s1.Union(s2))
117
+
expected := Collect(slices.Values([]int{1, 2, 3}))
118
+
119
+
if !result.Equal(expected) {
120
+
t.Errorf("Expected %v, got %v", expected, result)
121
+
}
122
+
}
123
+
124
+
func TestIntersection(t *testing.T) {
125
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
126
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
127
+
128
+
expected := Collect(slices.Values([]int{2, 3}))
129
+
result := Collect(s1.Intersection(s2))
130
+
131
+
if !result.Equal(expected) {
132
+
t.Errorf("Expected %v, got %v", expected, result)
133
+
}
134
+
}
135
+
136
+
func TestDifference(t *testing.T) {
137
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
138
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
139
+
140
+
expected := Collect(slices.Values([]int{1}))
141
+
result := Collect(s1.Difference(s2))
142
+
143
+
if !result.Equal(expected) {
144
+
t.Errorf("Expected %v, got %v", expected, result)
145
+
}
146
+
}
147
+
148
+
func TestSymmetricDifference(t *testing.T) {
149
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
150
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
151
+
152
+
expected := Collect(slices.Values([]int{1, 4}))
153
+
result := Collect(s1.SymmetricDifference(s2))
154
+
155
+
if !result.Equal(expected) {
156
+
t.Errorf("Expected %v, got %v", expected, result)
157
+
}
158
+
}
159
+
160
+
func TestSymmetricDifferenceCommutativeProperty(t *testing.T) {
161
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
162
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
163
+
164
+
result1 := Collect(s1.SymmetricDifference(s2))
165
+
result2 := Collect(s2.SymmetricDifference(s1))
166
+
167
+
if !result1.Equal(result2) {
168
+
t.Errorf("Expected %v, got %v", result1, result2)
169
+
}
170
+
}
171
+
172
+
func TestIsSubset(t *testing.T) {
173
+
s1 := Collect(slices.Values([]int{1, 2}))
174
+
s2 := Collect(slices.Values([]int{1, 2, 3}))
175
+
176
+
if !s1.IsSubset(s2) {
177
+
t.Error("s1 should be subset of s2")
178
+
}
179
+
if s2.IsSubset(s1) {
180
+
t.Error("s2 should not be subset of s1")
181
+
}
182
+
}
183
+
184
+
func TestIsSuperset(t *testing.T) {
185
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
186
+
s2 := Collect(slices.Values([]int{1, 2}))
187
+
188
+
if !s1.IsSuperset(s2) {
189
+
t.Error("s1 should be superset of s2")
190
+
}
191
+
if s2.IsSuperset(s1) {
192
+
t.Error("s2 should not be superset of s1")
193
+
}
194
+
}
195
+
196
+
func TestIsDisjoint(t *testing.T) {
197
+
s1 := Collect(slices.Values([]int{1, 2}))
198
+
s2 := Collect(slices.Values([]int{3, 4}))
199
+
s3 := Collect(slices.Values([]int{2, 3}))
200
+
201
+
if !s1.IsDisjoint(s2) {
202
+
t.Error("s1 and s2 should be disjoint")
203
+
}
204
+
if s1.IsDisjoint(s3) {
205
+
t.Error("s1 and s3 should not be disjoint")
206
+
}
207
+
}
208
+
209
+
func TestEqual(t *testing.T) {
210
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
211
+
s2 := Collect(slices.Values([]int{3, 2, 1}))
212
+
s3 := Collect(slices.Values([]int{1, 2}))
213
+
214
+
if !s1.Equal(s2) {
215
+
t.Error("s1 and s2 should be equal")
216
+
}
217
+
if s1.Equal(s3) {
218
+
t.Error("s1 and s3 should not be equal")
219
+
}
220
+
}
221
+
222
+
func TestCollect(t *testing.T) {
223
+
s1 := Collect(slices.Values([]int{1, 2}))
224
+
s2 := Collect(slices.Values([]int{2, 3}))
225
+
226
+
unionSet := Collect(s1.Union(s2))
227
+
if unionSet.Len() != 3 {
228
+
t.Errorf("Expected union set length 3, got %d", unionSet.Len())
229
+
}
230
+
if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) {
231
+
t.Error("Union set should contain 1, 2, and 3")
232
+
}
233
+
234
+
diffSet := Collect(s1.Difference(s2))
235
+
if diffSet.Len() != 1 {
236
+
t.Errorf("Expected difference set length 1, got %d", diffSet.Len())
237
+
}
238
+
if !diffSet.Contains(1) {
239
+
t.Error("Difference set should contain 1")
240
+
}
241
+
}
242
+
243
+
func TestPropertySingleonLen(t *testing.T) {
244
+
f := func(item int) bool {
245
+
single := Singleton(item)
246
+
return single.Len() == 1
247
+
}
248
+
249
+
if err := quick.Check(f, nil); err != nil {
250
+
t.Error(err)
251
+
}
252
+
}
253
+
254
+
func TestPropertyInsertIdempotent(t *testing.T) {
255
+
f := func(s Set[int], item int) bool {
256
+
clone := s.Clone()
257
+
258
+
clone.Insert(item)
259
+
firstLen := clone.Len()
260
+
261
+
clone.Insert(item)
262
+
secondLen := clone.Len()
263
+
264
+
return firstLen == secondLen
265
+
}
266
+
267
+
if err := quick.Check(f, nil); err != nil {
268
+
t.Error(err)
269
+
}
270
+
}
271
+
272
+
func TestPropertyUnionCommutative(t *testing.T) {
273
+
f := func(s1 Set[int], s2 Set[int]) bool {
274
+
union1 := Collect(s1.Union(s2))
275
+
union2 := Collect(s2.Union(s1))
276
+
return union1.Equal(union2)
277
+
}
278
+
279
+
if err := quick.Check(f, nil); err != nil {
280
+
t.Error(err)
281
+
}
282
+
}
283
+
284
+
func TestPropertyIntersectionCommutative(t *testing.T) {
285
+
f := func(s1 Set[int], s2 Set[int]) bool {
286
+
inter1 := Collect(s1.Intersection(s2))
287
+
inter2 := Collect(s2.Intersection(s1))
288
+
return inter1.Equal(inter2)
289
+
}
290
+
291
+
if err := quick.Check(f, nil); err != nil {
292
+
t.Error(err)
293
+
}
294
+
}
295
+
296
+
func TestPropertyCloneEquals(t *testing.T) {
297
+
f := func(s Set[int]) bool {
298
+
clone := s.Clone()
299
+
return s.Equal(clone)
300
+
}
301
+
302
+
if err := quick.Check(f, nil); err != nil {
303
+
t.Error(err)
304
+
}
305
+
}
306
+
307
+
func TestPropertyIntersectionIsSubset(t *testing.T) {
308
+
f := func(s1 Set[int], s2 Set[int]) bool {
309
+
inter := Collect(s1.Intersection(s2))
310
+
return inter.IsSubset(s1) && inter.IsSubset(s2)
311
+
}
312
+
313
+
if err := quick.Check(f, nil); err != nil {
314
+
t.Error(err)
315
+
}
316
+
}
317
+
318
+
func TestPropertyUnionIsSuperset(t *testing.T) {
319
+
f := func(s1 Set[int], s2 Set[int]) bool {
320
+
union := Collect(s1.Union(s2))
321
+
return union.IsSuperset(s1) && union.IsSuperset(s2)
322
+
}
323
+
324
+
if err := quick.Check(f, nil); err != nil {
325
+
t.Error(err)
326
+
}
327
+
}
328
+
329
+
func TestPropertyDifferenceDisjoint(t *testing.T) {
330
+
f := func(s1 Set[int], s2 Set[int]) bool {
331
+
diff := Collect(s1.Difference(s2))
332
+
return diff.IsDisjoint(s2)
333
+
}
334
+
335
+
if err := quick.Check(f, nil); err != nil {
336
+
t.Error(err)
337
+
}
338
+
}
339
+
340
+
func TestPropertySymmetricDifferenceCommutative(t *testing.T) {
341
+
f := func(s1 Set[int], s2 Set[int]) bool {
342
+
symDiff1 := Collect(s1.SymmetricDifference(s2))
343
+
symDiff2 := Collect(s2.SymmetricDifference(s1))
344
+
return symDiff1.Equal(symDiff2)
345
+
}
346
+
347
+
if err := quick.Check(f, nil); err != nil {
348
+
t.Error(err)
349
+
}
350
+
}
351
+
352
+
func TestPropertyRemoveWorks(t *testing.T) {
353
+
f := func(s Set[int], item int) bool {
354
+
clone := s.Clone()
355
+
clone.Insert(item)
356
+
clone.Remove(item)
357
+
return !clone.Contains(item)
358
+
}
359
+
360
+
if err := quick.Check(f, nil); err != nil {
361
+
t.Error(err)
362
+
}
363
+
}
364
+
365
+
func TestPropertyClearEmpty(t *testing.T) {
366
+
f := func(s Set[int]) bool {
367
+
s.Clear()
368
+
return s.IsEmpty() && s.Len() == 0
369
+
}
370
+
371
+
if err := quick.Check(f, nil); err != nil {
372
+
t.Error(err)
373
+
}
374
+
}
375
+
376
+
func TestPropertyIsSubsetReflexive(t *testing.T) {
377
+
f := func(s Set[int]) bool {
378
+
return s.IsSubset(s)
379
+
}
380
+
381
+
if err := quick.Check(f, nil); err != nil {
382
+
t.Error(err)
383
+
}
384
+
}
385
+
386
+
func TestPropertyDeMorganUnion(t *testing.T) {
387
+
f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool {
388
+
// create a universe that contains both sets
389
+
u := universe.Clone()
390
+
for item := range s1.All() {
391
+
u.Insert(item)
392
+
}
393
+
for item := range s2.All() {
394
+
u.Insert(item)
395
+
}
396
+
397
+
// (A u B)' = A' n B'
398
+
union := Collect(s1.Union(s2))
399
+
complementUnion := Collect(u.Difference(union))
400
+
401
+
complementS1 := Collect(u.Difference(s1))
402
+
complementS2 := Collect(u.Difference(s2))
403
+
intersectionComplements := Collect(complementS1.Intersection(complementS2))
404
+
405
+
return complementUnion.Equal(intersectionComplements)
406
+
}
407
+
408
+
if err := quick.Check(f, nil); err != nil {
409
+
t.Error(err)
410
+
}
411
+
}
+1
spindle/db/repos.go
+1
spindle/db/repos.go
+22
-21
spindle/engine/engine.go
+22
-21
spindle/engine/engine.go
···
3
import (
4
"context"
5
"errors"
6
-
"fmt"
7
"log/slog"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"golang.org/x/sync/errgroup"
11
"tangled.org/core/notifier"
12
"tangled.org/core/spindle/config"
13
"tangled.org/core/spindle/db"
···
31
}
32
}
33
34
-
eg, ctx := errgroup.WithContext(ctx)
35
for eng, wfs := range pipeline.Workflows {
36
workflowTimeout := eng.WorkflowTimeout()
37
l.Info("using workflow timeout", "timeout", workflowTimeout)
38
39
for _, w := range wfs {
40
-
eg.Go(func() error {
41
wid := models.WorkflowId{
42
PipelineId: pipelineId,
43
Name: w.Name,
···
45
46
err := db.StatusRunning(wid, n)
47
if err != nil {
48
-
return err
49
}
50
51
err = eng.SetupWorkflow(ctx, wid, &w)
···
61
62
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
63
if dbErr != nil {
64
-
return dbErr
65
}
66
-
return err
67
}
68
defer eng.DestroyWorkflow(ctx, wid)
69
70
-
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid)
71
if err != nil {
72
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
73
wfLogger = nil
···
99
if errors.Is(err, ErrTimedOut) {
100
dbErr := db.StatusTimeout(wid, n)
101
if dbErr != nil {
102
-
return dbErr
103
}
104
} else {
105
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
106
if dbErr != nil {
107
-
return dbErr
108
}
109
}
110
-
111
-
return fmt.Errorf("starting steps image: %w", err)
112
}
113
}
114
115
err = db.StatusSuccess(wid, n)
116
if err != nil {
117
-
return err
118
}
119
-
120
-
return nil
121
-
})
122
}
123
}
124
125
-
if err := eg.Wait(); err != nil {
126
-
l.Error("failed to run one or more workflows", "err", err)
127
-
} else {
128
-
l.Info("successfully ran full pipeline")
129
-
}
130
}
···
3
import (
4
"context"
5
"errors"
6
"log/slog"
7
+
"sync"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
"tangled.org/core/notifier"
11
"tangled.org/core/spindle/config"
12
"tangled.org/core/spindle/db"
···
30
}
31
}
32
33
+
var wg sync.WaitGroup
34
for eng, wfs := range pipeline.Workflows {
35
workflowTimeout := eng.WorkflowTimeout()
36
l.Info("using workflow timeout", "timeout", workflowTimeout)
37
38
for _, w := range wfs {
39
+
wg.Add(1)
40
+
go func() {
41
+
defer wg.Done()
42
+
43
wid := models.WorkflowId{
44
PipelineId: pipelineId,
45
Name: w.Name,
···
47
48
err := db.StatusRunning(wid, n)
49
if err != nil {
50
+
l.Error("failed to set workflow status to running", "wid", wid, "err", err)
51
+
return
52
}
53
54
err = eng.SetupWorkflow(ctx, wid, &w)
···
64
65
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
66
if dbErr != nil {
67
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
68
}
69
+
return
70
}
71
defer eng.DestroyWorkflow(ctx, wid)
72
73
+
secretValues := make([]string, len(allSecrets))
74
+
for i, s := range allSecrets {
75
+
secretValues[i] = s.Value
76
+
}
77
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues)
78
if err != nil {
79
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
80
wfLogger = nil
···
106
if errors.Is(err, ErrTimedOut) {
107
dbErr := db.StatusTimeout(wid, n)
108
if dbErr != nil {
109
+
l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
110
}
111
} else {
112
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
113
if dbErr != nil {
114
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
115
}
116
}
117
+
return
118
}
119
}
120
121
err = db.StatusSuccess(wid, n)
122
if err != nil {
123
+
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
124
}
125
+
}()
126
}
127
}
128
129
+
wg.Wait()
130
+
l.Info("all workflows completed")
131
}
+9
-8
spindle/engines/nixery/engine.go
+9
-8
spindle/engines/nixery/engine.go
···
73
type addlFields struct {
74
image string
75
container string
76
-
env map[string]string
77
}
78
79
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
···
103
swf.Steps = append(swf.Steps, sstep)
104
}
105
swf.Name = twf.Name
106
-
addl.env = dwf.Environment
107
addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery)
108
109
setup := &setupSteps{}
···
288
289
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
290
addl := w.Data.(addlFields)
291
-
workflowEnvs := ConstructEnvs(addl.env)
292
// TODO(winter): should SetupWorkflow also have secret access?
293
// IMO yes, but probably worth thinking on.
294
for _, s := range secrets {
295
workflowEnvs.AddEnv(s.Key, s.Value)
296
}
297
298
-
step := w.Steps[idx].(Step)
299
300
select {
301
case <-ctx.Done():
···
304
}
305
306
envs := append(EnvVars(nil), workflowEnvs...)
307
-
for k, v := range step.environment {
308
-
envs.AddEnv(k, v)
309
}
310
envs.AddEnv("HOME", homeDir)
311
312
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
313
-
Cmd: []string{"bash", "-c", step.command},
314
AttachStdout: true,
315
AttachStderr: true,
316
Env: envs,
···
333
// Docker doesn't provide an API to kill an exec run
334
// (sure, we could grab the PID and kill it ourselves,
335
// but that's wasted effort)
336
-
e.l.Warn("step timed out", "step", step.Name)
337
338
<-tailDone
339
···
73
type addlFields struct {
74
image string
75
container string
76
}
77
78
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
···
102
swf.Steps = append(swf.Steps, sstep)
103
}
104
swf.Name = twf.Name
105
+
swf.Environment = dwf.Environment
106
addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery)
107
108
setup := &setupSteps{}
···
287
288
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
289
addl := w.Data.(addlFields)
290
+
workflowEnvs := ConstructEnvs(w.Environment)
291
// TODO(winter): should SetupWorkflow also have secret access?
292
// IMO yes, but probably worth thinking on.
293
for _, s := range secrets {
294
workflowEnvs.AddEnv(s.Key, s.Value)
295
}
296
297
+
step := w.Steps[idx]
298
299
select {
300
case <-ctx.Done():
···
303
}
304
305
envs := append(EnvVars(nil), workflowEnvs...)
306
+
if nixStep, ok := step.(Step); ok {
307
+
for k, v := range nixStep.environment {
308
+
envs.AddEnv(k, v)
309
+
}
310
}
311
envs.AddEnv("HOME", homeDir)
312
313
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
314
+
Cmd: []string{"bash", "-c", step.Command()},
315
AttachStdout: true,
316
AttachStderr: true,
317
Env: envs,
···
334
// Docker doesn't provide an API to kill an exec run
335
// (sure, we could grab the PID and kill it ourselves,
336
// but that's wasted effort)
337
+
e.l.Warn("step timed out", "step", step.Name())
338
339
<-tailDone
340
+6
-7
spindle/models/clone.go
+6
-7
spindle/models/clone.go
···
55
}
56
}
57
58
-
repoURL := buildRepoURL(tr, dev)
59
60
var cloneOpts tangled.Pipeline_CloneOpts
61
if twf.Clone != nil {
···
101
}
102
}
103
104
-
// buildRepoURL constructs the repository URL from trigger metadata
105
-
func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string {
106
-
if tr.Repo == nil {
107
return ""
108
}
109
110
-
// Determine protocol
111
scheme := "https://"
112
if devMode {
113
scheme = "http://"
114
}
115
116
// Get host from knot
117
-
host := tr.Repo.Knot
118
119
// In dev mode, replace localhost with host.docker.internal for Docker networking
120
if devMode && strings.Contains(host, "localhost") {
···
122
}
123
124
// Build URL: {scheme}{knot}/{did}/{repo}
125
-
return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo)
126
}
127
128
// buildFetchArgs constructs the arguments for git fetch based on clone options
···
55
}
56
}
57
58
+
repoURL := BuildRepoURL(tr.Repo, dev)
59
60
var cloneOpts tangled.Pipeline_CloneOpts
61
if twf.Clone != nil {
···
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") {
···
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
+6
-1
spindle/models/logger.go
+6
-1
spindle/models/logger.go
···
12
type WorkflowLogger struct {
13
file *os.File
14
encoder *json.Encoder
15
}
16
17
-
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
18
path := LogFilePath(baseDir, wid)
19
20
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
25
return &WorkflowLogger{
26
file: file,
27
encoder: json.NewEncoder(file),
28
}, nil
29
}
30
···
62
63
func (w *dataWriter) Write(p []byte) (int, error) {
64
line := strings.TrimRight(string(p), "\r\n")
65
entry := NewDataLogLine(w.idx, line, w.stream)
66
if err := w.logger.encoder.Encode(entry); err != nil {
67
return 0, err
···
12
type WorkflowLogger struct {
13
file *os.File
14
encoder *json.Encoder
15
+
mask *SecretMask
16
}
17
18
+
func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) {
19
path := LogFilePath(baseDir, wid)
20
21
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
26
return &WorkflowLogger{
27
file: file,
28
encoder: json.NewEncoder(file),
29
+
mask: NewSecretMask(secretValues),
30
}, nil
31
}
32
···
64
65
func (w *dataWriter) Write(p []byte) (int, error) {
66
line := strings.TrimRight(string(p), "\r\n")
67
+
if w.logger.mask != nil {
68
+
line = w.logger.mask.Mask(line)
69
+
}
70
entry := NewDataLogLine(w.idx, line, w.stream)
71
if err := w.logger.encoder.Encode(entry); err != nil {
72
return 0, err
+4
-3
spindle/models/pipeline.go
+4
-3
spindle/models/pipeline.go
+77
spindle/models/pipeline_env.go
+77
spindle/models/pipeline_env.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"strings"
5
+
6
+
"github.com/go-git/go-git/v5/plumbing"
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/workflow"
9
+
)
10
+
11
+
// PipelineEnvVars extracts environment variables from pipeline trigger metadata.
12
+
// These are framework-provided variables that are injected into workflow steps.
13
+
func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId, devMode bool) map[string]string {
14
+
if tr == nil {
15
+
return nil
16
+
}
17
+
18
+
env := make(map[string]string)
19
+
20
+
// Standard CI environment variable
21
+
env["CI"] = "true"
22
+
23
+
env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey
24
+
25
+
// Repo info
26
+
if tr.Repo != nil {
27
+
env["TANGLED_REPO_KNOT"] = tr.Repo.Knot
28
+
env["TANGLED_REPO_DID"] = tr.Repo.Did
29
+
env["TANGLED_REPO_NAME"] = tr.Repo.Repo
30
+
env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch
31
+
env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo, devMode)
32
+
}
33
+
34
+
switch workflow.TriggerKind(tr.Kind) {
35
+
case workflow.TriggerKindPush:
36
+
if tr.Push != nil {
37
+
refName := plumbing.ReferenceName(tr.Push.Ref)
38
+
refType := "branch"
39
+
if refName.IsTag() {
40
+
refType = "tag"
41
+
}
42
+
43
+
env["TANGLED_REF"] = tr.Push.Ref
44
+
env["TANGLED_REF_NAME"] = refName.Short()
45
+
env["TANGLED_REF_TYPE"] = refType
46
+
env["TANGLED_SHA"] = tr.Push.NewSha
47
+
env["TANGLED_COMMIT_SHA"] = tr.Push.NewSha
48
+
}
49
+
50
+
case workflow.TriggerKindPullRequest:
51
+
if tr.PullRequest != nil {
52
+
// For PRs, the "ref" is the source branch
53
+
env["TANGLED_REF"] = "refs/heads/" + tr.PullRequest.SourceBranch
54
+
env["TANGLED_REF_NAME"] = tr.PullRequest.SourceBranch
55
+
env["TANGLED_REF_TYPE"] = "branch"
56
+
env["TANGLED_SHA"] = tr.PullRequest.SourceSha
57
+
env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha
58
+
59
+
// PR-specific variables
60
+
env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch
61
+
env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch
62
+
env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha
63
+
env["TANGLED_PR_ACTION"] = tr.PullRequest.Action
64
+
}
65
+
66
+
case workflow.TriggerKindManual:
67
+
// Manual triggers may not have ref/sha info
68
+
// Include any manual inputs if present
69
+
if tr.Manual != nil {
70
+
for _, pair := range tr.Manual.Inputs {
71
+
env["TANGLED_INPUT_"+strings.ToUpper(pair.Key)] = pair.Value
72
+
}
73
+
}
74
+
}
75
+
76
+
return env
77
+
}
+260
spindle/models/pipeline_env_test.go
+260
spindle/models/pipeline_env_test.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"testing"
5
+
6
+
"tangled.org/core/api/tangled"
7
+
"tangled.org/core/workflow"
8
+
)
9
+
10
+
func TestPipelineEnvVars_PushBranch(t *testing.T) {
11
+
tr := &tangled.Pipeline_TriggerMetadata{
12
+
Kind: string(workflow.TriggerKindPush),
13
+
Push: &tangled.Pipeline_PushTriggerData{
14
+
NewSha: "abc123def456",
15
+
OldSha: "000000000000",
16
+
Ref: "refs/heads/main",
17
+
},
18
+
Repo: &tangled.Pipeline_TriggerRepo{
19
+
Knot: "example.com",
20
+
Did: "did:plc:user123",
21
+
Repo: "my-repo",
22
+
DefaultBranch: "main",
23
+
},
24
+
}
25
+
id := PipelineId{
26
+
Knot: "example.com",
27
+
Rkey: "123123",
28
+
}
29
+
env := PipelineEnvVars(tr, id, false)
30
+
31
+
// Check standard CI variable
32
+
if env["CI"] != "true" {
33
+
t.Errorf("Expected CI='true', got '%s'", env["CI"])
34
+
}
35
+
36
+
// Check ref variables
37
+
if env["TANGLED_REF"] != "refs/heads/main" {
38
+
t.Errorf("Expected TANGLED_REF='refs/heads/main', got '%s'", env["TANGLED_REF"])
39
+
}
40
+
if env["TANGLED_REF_NAME"] != "main" {
41
+
t.Errorf("Expected TANGLED_REF_NAME='main', got '%s'", env["TANGLED_REF_NAME"])
42
+
}
43
+
if env["TANGLED_REF_TYPE"] != "branch" {
44
+
t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"])
45
+
}
46
+
47
+
// Check SHA variables
48
+
if env["TANGLED_SHA"] != "abc123def456" {
49
+
t.Errorf("Expected TANGLED_SHA='abc123def456', got '%s'", env["TANGLED_SHA"])
50
+
}
51
+
if env["TANGLED_COMMIT_SHA"] != "abc123def456" {
52
+
t.Errorf("Expected TANGLED_COMMIT_SHA='abc123def456', got '%s'", env["TANGLED_COMMIT_SHA"])
53
+
}
54
+
55
+
// Check repo variables
56
+
if env["TANGLED_REPO_KNOT"] != "example.com" {
57
+
t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"])
58
+
}
59
+
if env["TANGLED_REPO_DID"] != "did:plc:user123" {
60
+
t.Errorf("Expected TANGLED_REPO_DID='did:plc:user123', got '%s'", env["TANGLED_REPO_DID"])
61
+
}
62
+
if env["TANGLED_REPO_NAME"] != "my-repo" {
63
+
t.Errorf("Expected TANGLED_REPO_NAME='my-repo', got '%s'", env["TANGLED_REPO_NAME"])
64
+
}
65
+
if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" {
66
+
t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"])
67
+
}
68
+
if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" {
69
+
t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"])
70
+
}
71
+
}
72
+
73
+
func TestPipelineEnvVars_PushTag(t *testing.T) {
74
+
tr := &tangled.Pipeline_TriggerMetadata{
75
+
Kind: string(workflow.TriggerKindPush),
76
+
Push: &tangled.Pipeline_PushTriggerData{
77
+
NewSha: "abc123def456",
78
+
OldSha: "000000000000",
79
+
Ref: "refs/tags/v1.2.3",
80
+
},
81
+
Repo: &tangled.Pipeline_TriggerRepo{
82
+
Knot: "example.com",
83
+
Did: "did:plc:user123",
84
+
Repo: "my-repo",
85
+
},
86
+
}
87
+
id := PipelineId{
88
+
Knot: "example.com",
89
+
Rkey: "123123",
90
+
}
91
+
env := PipelineEnvVars(tr, id, false)
92
+
93
+
if env["TANGLED_REF"] != "refs/tags/v1.2.3" {
94
+
t.Errorf("Expected TANGLED_REF='refs/tags/v1.2.3', got '%s'", env["TANGLED_REF"])
95
+
}
96
+
if env["TANGLED_REF_NAME"] != "v1.2.3" {
97
+
t.Errorf("Expected TANGLED_REF_NAME='v1.2.3', got '%s'", env["TANGLED_REF_NAME"])
98
+
}
99
+
if env["TANGLED_REF_TYPE"] != "tag" {
100
+
t.Errorf("Expected TANGLED_REF_TYPE='tag', got '%s'", env["TANGLED_REF_TYPE"])
101
+
}
102
+
}
103
+
104
+
func TestPipelineEnvVars_PullRequest(t *testing.T) {
105
+
tr := &tangled.Pipeline_TriggerMetadata{
106
+
Kind: string(workflow.TriggerKindPullRequest),
107
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
108
+
SourceBranch: "feature-branch",
109
+
TargetBranch: "main",
110
+
SourceSha: "pr-sha-789",
111
+
Action: "opened",
112
+
},
113
+
Repo: &tangled.Pipeline_TriggerRepo{
114
+
Knot: "example.com",
115
+
Did: "did:plc:user123",
116
+
Repo: "my-repo",
117
+
},
118
+
}
119
+
id := PipelineId{
120
+
Knot: "example.com",
121
+
Rkey: "123123",
122
+
}
123
+
env := PipelineEnvVars(tr, id, false)
124
+
125
+
// Check ref variables for PR
126
+
if env["TANGLED_REF"] != "refs/heads/feature-branch" {
127
+
t.Errorf("Expected TANGLED_REF='refs/heads/feature-branch', got '%s'", env["TANGLED_REF"])
128
+
}
129
+
if env["TANGLED_REF_NAME"] != "feature-branch" {
130
+
t.Errorf("Expected TANGLED_REF_NAME='feature-branch', got '%s'", env["TANGLED_REF_NAME"])
131
+
}
132
+
if env["TANGLED_REF_TYPE"] != "branch" {
133
+
t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"])
134
+
}
135
+
136
+
// Check SHA variables
137
+
if env["TANGLED_SHA"] != "pr-sha-789" {
138
+
t.Errorf("Expected TANGLED_SHA='pr-sha-789', got '%s'", env["TANGLED_SHA"])
139
+
}
140
+
if env["TANGLED_COMMIT_SHA"] != "pr-sha-789" {
141
+
t.Errorf("Expected TANGLED_COMMIT_SHA='pr-sha-789', got '%s'", env["TANGLED_COMMIT_SHA"])
142
+
}
143
+
144
+
// Check PR-specific variables
145
+
if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" {
146
+
t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"])
147
+
}
148
+
if env["TANGLED_PR_TARGET_BRANCH"] != "main" {
149
+
t.Errorf("Expected TANGLED_PR_TARGET_BRANCH='main', got '%s'", env["TANGLED_PR_TARGET_BRANCH"])
150
+
}
151
+
if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" {
152
+
t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"])
153
+
}
154
+
if env["TANGLED_PR_ACTION"] != "opened" {
155
+
t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"])
156
+
}
157
+
}
158
+
159
+
func TestPipelineEnvVars_ManualWithInputs(t *testing.T) {
160
+
tr := &tangled.Pipeline_TriggerMetadata{
161
+
Kind: string(workflow.TriggerKindManual),
162
+
Manual: &tangled.Pipeline_ManualTriggerData{
163
+
Inputs: []*tangled.Pipeline_Pair{
164
+
{Key: "version", Value: "1.0.0"},
165
+
{Key: "environment", Value: "production"},
166
+
},
167
+
},
168
+
Repo: &tangled.Pipeline_TriggerRepo{
169
+
Knot: "example.com",
170
+
Did: "did:plc:user123",
171
+
Repo: "my-repo",
172
+
},
173
+
}
174
+
id := PipelineId{
175
+
Knot: "example.com",
176
+
Rkey: "123123",
177
+
}
178
+
env := PipelineEnvVars(tr, id, false)
179
+
180
+
// Check manual input variables
181
+
if env["TANGLED_INPUT_VERSION"] != "1.0.0" {
182
+
t.Errorf("Expected TANGLED_INPUT_VERSION='1.0.0', got '%s'", env["TANGLED_INPUT_VERSION"])
183
+
}
184
+
if env["TANGLED_INPUT_ENVIRONMENT"] != "production" {
185
+
t.Errorf("Expected TANGLED_INPUT_ENVIRONMENT='production', got '%s'", env["TANGLED_INPUT_ENVIRONMENT"])
186
+
}
187
+
188
+
// Manual triggers shouldn't have ref/sha variables
189
+
if _, ok := env["TANGLED_REF"]; ok {
190
+
t.Error("Manual trigger should not have TANGLED_REF")
191
+
}
192
+
if _, ok := env["TANGLED_SHA"]; ok {
193
+
t.Error("Manual trigger should not have TANGLED_SHA")
194
+
}
195
+
}
196
+
197
+
func TestPipelineEnvVars_DevMode(t *testing.T) {
198
+
tr := &tangled.Pipeline_TriggerMetadata{
199
+
Kind: string(workflow.TriggerKindPush),
200
+
Push: &tangled.Pipeline_PushTriggerData{
201
+
NewSha: "abc123",
202
+
Ref: "refs/heads/main",
203
+
},
204
+
Repo: &tangled.Pipeline_TriggerRepo{
205
+
Knot: "localhost:3000",
206
+
Did: "did:plc:user123",
207
+
Repo: "my-repo",
208
+
},
209
+
}
210
+
id := PipelineId{
211
+
Knot: "example.com",
212
+
Rkey: "123123",
213
+
}
214
+
env := PipelineEnvVars(tr, id, true)
215
+
216
+
// Dev mode should use http:// and replace localhost with host.docker.internal
217
+
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
218
+
if env["TANGLED_REPO_URL"] != expectedURL {
219
+
t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"])
220
+
}
221
+
}
222
+
223
+
func TestPipelineEnvVars_NilTrigger(t *testing.T) {
224
+
id := PipelineId{
225
+
Knot: "example.com",
226
+
Rkey: "123123",
227
+
}
228
+
env := PipelineEnvVars(nil, id, false)
229
+
230
+
if env != nil {
231
+
t.Error("Expected nil env for nil trigger")
232
+
}
233
+
}
234
+
235
+
func TestPipelineEnvVars_NilPushData(t *testing.T) {
236
+
tr := &tangled.Pipeline_TriggerMetadata{
237
+
Kind: string(workflow.TriggerKindPush),
238
+
Push: nil,
239
+
Repo: &tangled.Pipeline_TriggerRepo{
240
+
Knot: "example.com",
241
+
Did: "did:plc:user123",
242
+
Repo: "my-repo",
243
+
},
244
+
}
245
+
id := PipelineId{
246
+
Knot: "example.com",
247
+
Rkey: "123123",
248
+
}
249
+
env := PipelineEnvVars(tr, id, false)
250
+
251
+
// Should still have repo variables
252
+
if env["TANGLED_REPO_KNOT"] != "example.com" {
253
+
t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"])
254
+
}
255
+
256
+
// Should not have ref/sha variables
257
+
if _, ok := env["TANGLED_REF"]; ok {
258
+
t.Error("Should not have TANGLED_REF when push data is nil")
259
+
}
260
+
}
+51
spindle/models/secret_mask.go
+51
spindle/models/secret_mask.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"strings"
6
+
)
7
+
8
+
// SecretMask replaces secret values in strings with "***".
9
+
type SecretMask struct {
10
+
replacer *strings.Replacer
11
+
}
12
+
13
+
// NewSecretMask creates a mask for the given secret values.
14
+
// Also registers base64-encoded variants of each secret.
15
+
func NewSecretMask(values []string) *SecretMask {
16
+
var pairs []string
17
+
18
+
for _, value := range values {
19
+
if value == "" {
20
+
continue
21
+
}
22
+
23
+
pairs = append(pairs, value, "***")
24
+
25
+
b64 := base64.StdEncoding.EncodeToString([]byte(value))
26
+
if b64 != value {
27
+
pairs = append(pairs, b64, "***")
28
+
}
29
+
30
+
b64NoPad := strings.TrimRight(b64, "=")
31
+
if b64NoPad != b64 && b64NoPad != value {
32
+
pairs = append(pairs, b64NoPad, "***")
33
+
}
34
+
}
35
+
36
+
if len(pairs) == 0 {
37
+
return nil
38
+
}
39
+
40
+
return &SecretMask{
41
+
replacer: strings.NewReplacer(pairs...),
42
+
}
43
+
}
44
+
45
+
// Mask replaces all registered secret values with "***".
46
+
func (m *SecretMask) Mask(input string) string {
47
+
if m == nil || m.replacer == nil {
48
+
return input
49
+
}
50
+
return m.replacer.Replace(input)
51
+
}
+135
spindle/models/secret_mask_test.go
+135
spindle/models/secret_mask_test.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"testing"
6
+
)
7
+
8
+
func TestSecretMask_BasicMasking(t *testing.T) {
9
+
mask := NewSecretMask([]string{"mysecret123"})
10
+
11
+
input := "The password is mysecret123 in this log"
12
+
expected := "The password is *** in this log"
13
+
14
+
result := mask.Mask(input)
15
+
if result != expected {
16
+
t.Errorf("expected %q, got %q", expected, result)
17
+
}
18
+
}
19
+
20
+
func TestSecretMask_Base64Encoded(t *testing.T) {
21
+
secret := "mysecret123"
22
+
mask := NewSecretMask([]string{secret})
23
+
24
+
b64 := base64.StdEncoding.EncodeToString([]byte(secret))
25
+
input := "Encoded: " + b64
26
+
expected := "Encoded: ***"
27
+
28
+
result := mask.Mask(input)
29
+
if result != expected {
30
+
t.Errorf("expected %q, got %q", expected, result)
31
+
}
32
+
}
33
+
34
+
func TestSecretMask_Base64NoPadding(t *testing.T) {
35
+
// "test" encodes to "dGVzdA==" with padding
36
+
secret := "test"
37
+
mask := NewSecretMask([]string{secret})
38
+
39
+
b64NoPad := "dGVzdA" // base64 without padding
40
+
input := "Token: " + b64NoPad
41
+
expected := "Token: ***"
42
+
43
+
result := mask.Mask(input)
44
+
if result != expected {
45
+
t.Errorf("expected %q, got %q", expected, result)
46
+
}
47
+
}
48
+
49
+
func TestSecretMask_MultipleSecrets(t *testing.T) {
50
+
mask := NewSecretMask([]string{"password1", "apikey123"})
51
+
52
+
input := "Using password1 and apikey123 for auth"
53
+
expected := "Using *** and *** for auth"
54
+
55
+
result := mask.Mask(input)
56
+
if result != expected {
57
+
t.Errorf("expected %q, got %q", expected, result)
58
+
}
59
+
}
60
+
61
+
func TestSecretMask_MultipleOccurrences(t *testing.T) {
62
+
mask := NewSecretMask([]string{"secret"})
63
+
64
+
input := "secret appears twice: secret"
65
+
expected := "*** appears twice: ***"
66
+
67
+
result := mask.Mask(input)
68
+
if result != expected {
69
+
t.Errorf("expected %q, got %q", expected, result)
70
+
}
71
+
}
72
+
73
+
func TestSecretMask_ShortValues(t *testing.T) {
74
+
mask := NewSecretMask([]string{"abc", "xy", ""})
75
+
76
+
if mask == nil {
77
+
t.Fatal("expected non-nil mask")
78
+
}
79
+
80
+
input := "abc xy test"
81
+
expected := "*** *** test"
82
+
result := mask.Mask(input)
83
+
if result != expected {
84
+
t.Errorf("expected %q, got %q", expected, result)
85
+
}
86
+
}
87
+
88
+
func TestSecretMask_NilMask(t *testing.T) {
89
+
var mask *SecretMask
90
+
91
+
input := "some input text"
92
+
result := mask.Mask(input)
93
+
if result != input {
94
+
t.Errorf("expected %q, got %q", input, result)
95
+
}
96
+
}
97
+
98
+
func TestSecretMask_EmptyInput(t *testing.T) {
99
+
mask := NewSecretMask([]string{"secret"})
100
+
101
+
result := mask.Mask("")
102
+
if result != "" {
103
+
t.Errorf("expected empty string, got %q", result)
104
+
}
105
+
}
106
+
107
+
func TestSecretMask_NoMatch(t *testing.T) {
108
+
mask := NewSecretMask([]string{"secretvalue"})
109
+
110
+
input := "nothing to mask here"
111
+
result := mask.Mask(input)
112
+
if result != input {
113
+
t.Errorf("expected %q, got %q", input, result)
114
+
}
115
+
}
116
+
117
+
func TestSecretMask_EmptySecretsList(t *testing.T) {
118
+
mask := NewSecretMask([]string{})
119
+
120
+
if mask != nil {
121
+
t.Error("expected nil mask for empty secrets list")
122
+
}
123
+
}
124
+
125
+
func TestSecretMask_EmptySecretsFiltered(t *testing.T) {
126
+
mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"})
127
+
128
+
input := "Using validpassword here"
129
+
expected := "Using *** here"
130
+
131
+
result := mask.Mask(input)
132
+
if result != expected {
133
+
t.Errorf("expected %q, got %q", expected, result)
134
+
}
135
+
}
+1
-1
spindle/motd
+1
-1
spindle/motd
+42
-13
spindle/server.go
+42
-13
spindle/server.go
···
6
"encoding/json"
7
"fmt"
8
"log/slog"
9
"net/http"
10
11
"github.com/go-chi/chi/v5"
12
"tangled.org/core/api/tangled"
···
29
)
30
31
//go:embed motd
32
-
var motd []byte
33
34
const (
35
rbacDomain = "thisserver"
36
)
37
38
type Spindle struct {
39
-
jc *jetstream.JetstreamClient
40
-
db *db.DB
41
-
e *rbac.Enforcer
42
-
l *slog.Logger
43
-
n *notifier.Notifier
44
-
engs map[string]models.Engine
45
-
jq *queue.Queue
46
-
cfg *config.Config
47
-
ks *eventconsumer.Consumer
48
-
res *idresolver.Resolver
49
-
vault secrets.Manager
50
}
51
52
// New creates a new Spindle server with the provided configuration and engines.
···
127
cfg: cfg,
128
res: resolver,
129
vault: vault,
130
}
131
132
err = e.AddSpindle(rbacDomain)
···
200
return s.e
201
}
202
203
// Start starts the Spindle server (blocking).
204
func (s *Spindle) Start(ctx context.Context) error {
205
// starts a job queue runner in the background
···
245
mux := chi.NewRouter()
246
247
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
248
-
w.Write(motd)
249
})
250
mux.HandleFunc("/events", s.Events)
251
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
311
312
workflows := make(map[models.Engine][]models.Workflow)
313
314
for _, w := range tpl.Workflows {
315
if w != nil {
316
if _, ok := s.engs[w.Engine]; !ok {
···
335
if err != nil {
336
return err
337
}
338
339
workflows[eng] = append(workflows[eng], *ewf)
340
···
6
"encoding/json"
7
"fmt"
8
"log/slog"
9
+
"maps"
10
"net/http"
11
+
"sync"
12
13
"github.com/go-chi/chi/v5"
14
"tangled.org/core/api/tangled"
···
31
)
32
33
//go:embed motd
34
+
var defaultMotd []byte
35
36
const (
37
rbacDomain = "thisserver"
38
)
39
40
type Spindle struct {
41
+
jc *jetstream.JetstreamClient
42
+
db *db.DB
43
+
e *rbac.Enforcer
44
+
l *slog.Logger
45
+
n *notifier.Notifier
46
+
engs map[string]models.Engine
47
+
jq *queue.Queue
48
+
cfg *config.Config
49
+
ks *eventconsumer.Consumer
50
+
res *idresolver.Resolver
51
+
vault secrets.Manager
52
+
motd []byte
53
+
motdMu sync.RWMutex
54
}
55
56
// New creates a new Spindle server with the provided configuration and engines.
···
131
cfg: cfg,
132
res: resolver,
133
vault: vault,
134
+
motd: defaultMotd,
135
}
136
137
err = e.AddSpindle(rbacDomain)
···
205
return s.e
206
}
207
208
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
+
func (s *Spindle) SetMotdContent(content []byte) {
210
+
s.motdMu.Lock()
211
+
defer s.motdMu.Unlock()
212
+
s.motd = content
213
+
}
214
+
215
+
// GetMotdContent returns the current MOTD content.
216
+
func (s *Spindle) GetMotdContent() []byte {
217
+
s.motdMu.RLock()
218
+
defer s.motdMu.RUnlock()
219
+
return s.motd
220
+
}
221
+
222
// Start starts the Spindle server (blocking).
223
func (s *Spindle) Start(ctx context.Context) error {
224
// starts a job queue runner in the background
···
264
mux := chi.NewRouter()
265
266
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
267
+
w.Write(s.GetMotdContent())
268
})
269
mux.HandleFunc("/events", s.Events)
270
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
330
331
workflows := make(map[models.Engine][]models.Workflow)
332
333
+
// Build pipeline environment variables once for all workflows
334
+
pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev)
335
+
336
for _, w := range tpl.Workflows {
337
if w != nil {
338
if _, ok := s.engs[w.Engine]; !ok {
···
357
if err != nil {
358
return err
359
}
360
+
361
+
// inject TANGLED_* env vars after InitWorkflow
362
+
// This prevents user-defined env vars from overriding them
363
+
if ewf.Environment == nil {
364
+
ewf.Environment = make(map[string]string)
365
+
}
366
+
maps.Copy(ewf.Environment, pipelineEnv)
367
368
workflows[eng] = append(workflows[eng], *ewf)
369
+1
-1
tailwind.config.js
+1
-1
tailwind.config.js
+199
types/commit.go
+199
types/commit.go
···
···
1
+
package types
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/json"
6
+
"fmt"
7
+
"maps"
8
+
"regexp"
9
+
"strings"
10
+
11
+
"github.com/go-git/go-git/v5/plumbing"
12
+
"github.com/go-git/go-git/v5/plumbing/object"
13
+
)
14
+
15
+
type Commit struct {
16
+
// hash of the commit object.
17
+
Hash plumbing.Hash `json:"hash,omitempty"`
18
+
19
+
// author is the original author of the commit.
20
+
Author object.Signature `json:"author"`
21
+
22
+
// committer is the one performing the commit, might be different from author.
23
+
Committer object.Signature `json:"committer"`
24
+
25
+
// message is the commit message, contains arbitrary text.
26
+
Message string `json:"message"`
27
+
28
+
// treehash is the hash of the root tree of the commit.
29
+
Tree string `json:"tree"`
30
+
31
+
// parents are the hashes of the parent commits of the commit.
32
+
ParentHashes []plumbing.Hash `json:"parent_hashes,omitempty"`
33
+
34
+
// pgpsignature is the pgp signature of the commit.
35
+
PGPSignature string `json:"pgp_signature,omitempty"`
36
+
37
+
// mergetag is the embedded tag object when a merge commit is created by
38
+
// merging a signed tag.
39
+
MergeTag string `json:"merge_tag,omitempty"`
40
+
41
+
// changeid is a unique identifier for the change (e.g., gerrit change-id).
42
+
ChangeId string `json:"change_id,omitempty"`
43
+
44
+
// extraheaders contains additional headers not captured by other fields.
45
+
ExtraHeaders map[string][]byte `json:"extra_headers,omitempty"`
46
+
47
+
// deprecated: kept for backwards compatibility with old json format.
48
+
This string `json:"this,omitempty"`
49
+
50
+
// deprecated: kept for backwards compatibility with old json format.
51
+
Parent string `json:"parent,omitempty"`
52
+
}
53
+
54
+
// types.Commit is an unify two commit structs:
55
+
// - git.object.Commit from
56
+
// - types.NiceDiff.commit
57
+
//
58
+
// to do this in backwards compatible fashion, we define the base struct
59
+
// to use the same fields as NiceDiff.Commit, and then we also unmarshal
60
+
// the struct fields from go-git structs, this custom unmarshal makes sense
61
+
// of both representations and unifies them to have maximal data in either
62
+
// form.
63
+
func (c *Commit) UnmarshalJSON(data []byte) error {
64
+
type Alias Commit
65
+
66
+
aux := &struct {
67
+
*object.Commit
68
+
*Alias
69
+
}{
70
+
Alias: (*Alias)(c),
71
+
}
72
+
73
+
if err := json.Unmarshal(data, aux); err != nil {
74
+
return err
75
+
}
76
+
77
+
c.FromGoGitCommit(aux.Commit)
78
+
79
+
return nil
80
+
}
81
+
82
+
// fill in as much of Commit as possible from the given go-git commit
83
+
func (c *Commit) FromGoGitCommit(gc *object.Commit) {
84
+
if gc == nil {
85
+
return
86
+
}
87
+
88
+
if c.Hash.IsZero() {
89
+
c.Hash = gc.Hash
90
+
}
91
+
if c.This == "" {
92
+
c.This = gc.Hash.String()
93
+
}
94
+
if isEmptySignature(c.Author) {
95
+
c.Author = gc.Author
96
+
}
97
+
if isEmptySignature(c.Committer) {
98
+
c.Committer = gc.Committer
99
+
}
100
+
if c.Message == "" {
101
+
c.Message = gc.Message
102
+
}
103
+
if c.Tree == "" {
104
+
c.Tree = gc.TreeHash.String()
105
+
}
106
+
if c.PGPSignature == "" {
107
+
c.PGPSignature = gc.PGPSignature
108
+
}
109
+
if c.MergeTag == "" {
110
+
c.MergeTag = gc.MergeTag
111
+
}
112
+
113
+
if len(c.ParentHashes) == 0 {
114
+
c.ParentHashes = gc.ParentHashes
115
+
}
116
+
if c.Parent == "" && len(gc.ParentHashes) > 0 {
117
+
c.Parent = gc.ParentHashes[0].String()
118
+
}
119
+
120
+
if len(c.ExtraHeaders) == 0 {
121
+
c.ExtraHeaders = make(map[string][]byte)
122
+
maps.Copy(c.ExtraHeaders, gc.ExtraHeaders)
123
+
}
124
+
125
+
if c.ChangeId == "" {
126
+
if v, ok := gc.ExtraHeaders["change-id"]; ok {
127
+
c.ChangeId = string(v)
128
+
}
129
+
}
130
+
}
131
+
132
+
func isEmptySignature(s object.Signature) bool {
133
+
return s.Email == "" && s.Name == "" && s.When.IsZero()
134
+
}
135
+
136
+
// produce a verifiable payload from this commit's metadata
137
+
func (c *Commit) Payload() string {
138
+
author := bytes.NewBuffer([]byte{})
139
+
c.Author.Encode(author)
140
+
141
+
committer := bytes.NewBuffer([]byte{})
142
+
c.Committer.Encode(committer)
143
+
144
+
payload := strings.Builder{}
145
+
146
+
fmt.Fprintf(&payload, "tree %s\n", c.Tree)
147
+
148
+
if len(c.ParentHashes) > 0 {
149
+
for _, p := range c.ParentHashes {
150
+
fmt.Fprintf(&payload, "parent %s\n", p.String())
151
+
}
152
+
} else {
153
+
// present for backwards compatibility
154
+
fmt.Fprintf(&payload, "parent %s\n", c.Parent)
155
+
}
156
+
157
+
fmt.Fprintf(&payload, "author %s\n", author.String())
158
+
fmt.Fprintf(&payload, "committer %s\n", committer.String())
159
+
160
+
if c.ChangeId != "" {
161
+
fmt.Fprintf(&payload, "change-id %s\n", c.ChangeId)
162
+
} else if v, ok := c.ExtraHeaders["change-id"]; ok {
163
+
fmt.Fprintf(&payload, "change-id %s\n", string(v))
164
+
}
165
+
166
+
fmt.Fprintf(&payload, "\n%s", c.Message)
167
+
168
+
return payload.String()
169
+
}
170
+
171
+
var (
172
+
coAuthorRegex = regexp.MustCompile(`(?im)^Co-authored-by:\s*(.+?)\s*<([^>]+)>`)
173
+
)
174
+
175
+
func (commit Commit) CoAuthors() []object.Signature {
176
+
var coAuthors []object.Signature
177
+
seen := make(map[string]bool)
178
+
matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1)
179
+
180
+
for _, match := range matches {
181
+
if len(match) >= 3 {
182
+
name := strings.TrimSpace(match[1])
183
+
email := strings.TrimSpace(match[2])
184
+
185
+
if seen[email] {
186
+
continue
187
+
}
188
+
seen[email] = true
189
+
190
+
coAuthors = append(coAuthors, object.Signature{
191
+
Name: name,
192
+
Email: email,
193
+
When: commit.Committer.When,
194
+
})
195
+
}
196
+
}
197
+
198
+
return coAuthors
199
+
}
+5
-12
types/diff.go
+5
-12
types/diff.go
···
2
3
import (
4
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
-
"github.com/go-git/go-git/v5/plumbing/object"
6
)
7
8
type DiffOpts struct {
···
43
44
// A nicer git diff representation.
45
type NiceDiff struct {
46
-
Commit struct {
47
-
Message string `json:"message"`
48
-
Author object.Signature `json:"author"`
49
-
This string `json:"this"`
50
-
Parent string `json:"parent"`
51
-
PGPSignature string `json:"pgp_signature"`
52
-
Committer object.Signature `json:"committer"`
53
-
Tree string `json:"tree"`
54
-
ChangedId string `json:"change_id"`
55
-
} `json:"commit"`
56
-
Stat struct {
57
FilesChanged int `json:"files_changed"`
58
Insertions int `json:"insertions"`
59
Deletions int `json:"deletions"`
···
84
85
// used by html elements as a unique ID for hrefs
86
func (d *Diff) Id() string {
87
return d.Name.New
88
}
89
···
2
3
import (
4
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
)
6
7
type DiffOpts struct {
···
42
43
// A nicer git diff representation.
44
type NiceDiff struct {
45
+
Commit Commit `json:"commit"`
46
+
Stat struct {
47
FilesChanged int `json:"files_changed"`
48
Insertions int `json:"insertions"`
49
Deletions int `json:"deletions"`
···
74
75
// used by html elements as a unique ID for hrefs
76
func (d *Diff) Id() string {
77
+
if d.IsDelete {
78
+
return d.Name.Old
79
+
}
80
return d.Name.New
81
}
82
+112
types/diff_test.go
+112
types/diff_test.go
···
···
1
+
package types
2
+
3
+
import "testing"
4
+
5
+
func TestDiffId(t *testing.T) {
6
+
tests := []struct {
7
+
name string
8
+
diff Diff
9
+
expected string
10
+
}{
11
+
{
12
+
name: "regular file uses new name",
13
+
diff: Diff{
14
+
Name: struct {
15
+
Old string `json:"old"`
16
+
New string `json:"new"`
17
+
}{Old: "", New: "src/main.go"},
18
+
},
19
+
expected: "src/main.go",
20
+
},
21
+
{
22
+
name: "new file uses new name",
23
+
diff: Diff{
24
+
Name: struct {
25
+
Old string `json:"old"`
26
+
New string `json:"new"`
27
+
}{Old: "", New: "src/new.go"},
28
+
IsNew: true,
29
+
},
30
+
expected: "src/new.go",
31
+
},
32
+
{
33
+
name: "deleted file uses old name",
34
+
diff: Diff{
35
+
Name: struct {
36
+
Old string `json:"old"`
37
+
New string `json:"new"`
38
+
}{Old: "src/deleted.go", New: ""},
39
+
IsDelete: true,
40
+
},
41
+
expected: "src/deleted.go",
42
+
},
43
+
{
44
+
name: "renamed file uses new name",
45
+
diff: Diff{
46
+
Name: struct {
47
+
Old string `json:"old"`
48
+
New string `json:"new"`
49
+
}{Old: "src/old.go", New: "src/renamed.go"},
50
+
IsRename: true,
51
+
},
52
+
expected: "src/renamed.go",
53
+
},
54
+
}
55
+
56
+
for _, tt := range tests {
57
+
t.Run(tt.name, func(t *testing.T) {
58
+
if got := tt.diff.Id(); got != tt.expected {
59
+
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
+
}
61
+
})
62
+
}
63
+
}
64
+
65
+
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
+
// ChangedFiles() must return values matching each Diff's Id()
67
+
// so that sidebar links point to the correct anchors.
68
+
// Tests existing, deleted, new, and renamed files.
69
+
nd := NiceDiff{
70
+
Diff: []Diff{
71
+
{
72
+
Name: struct {
73
+
Old string `json:"old"`
74
+
New string `json:"new"`
75
+
}{Old: "", New: "src/modified.go"},
76
+
},
77
+
{
78
+
Name: struct {
79
+
Old string `json:"old"`
80
+
New string `json:"new"`
81
+
}{Old: "src/deleted.go", New: ""},
82
+
IsDelete: true,
83
+
},
84
+
{
85
+
Name: struct {
86
+
Old string `json:"old"`
87
+
New string `json:"new"`
88
+
}{Old: "", New: "src/new.go"},
89
+
IsNew: true,
90
+
},
91
+
{
92
+
Name: struct {
93
+
Old string `json:"old"`
94
+
New string `json:"new"`
95
+
}{Old: "src/old.go", New: "src/renamed.go"},
96
+
IsRename: true,
97
+
},
98
+
},
99
+
}
100
+
101
+
changedFiles := nd.ChangedFiles()
102
+
103
+
if len(changedFiles) != len(nd.Diff) {
104
+
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
+
}
106
+
107
+
for i, diff := range nd.Diff {
108
+
if changedFiles[i] != diff.Id() {
109
+
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
+
}
111
+
}
112
+
}
+17
-17
types/repo.go
+17
-17
types/repo.go
···
8
)
9
10
type RepoIndexResponse struct {
11
-
IsEmpty bool `json:"is_empty"`
12
-
Ref string `json:"ref,omitempty"`
13
-
Readme string `json:"readme,omitempty"`
14
-
ReadmeFileName string `json:"readme_file_name,omitempty"`
15
-
Commits []*object.Commit `json:"commits,omitempty"`
16
-
Description string `json:"description,omitempty"`
17
-
Files []NiceTree `json:"files,omitempty"`
18
-
Branches []Branch `json:"branches,omitempty"`
19
-
Tags []*TagReference `json:"tags,omitempty"`
20
-
TotalCommits int `json:"total_commits,omitempty"`
21
}
22
23
type RepoLogResponse struct {
24
-
Commits []*object.Commit `json:"commits,omitempty"`
25
-
Ref string `json:"ref,omitempty"`
26
-
Description string `json:"description,omitempty"`
27
-
Log bool `json:"log,omitempty"`
28
-
Total int `json:"total,omitempty"`
29
-
Page int `json:"page,omitempty"`
30
-
PerPage int `json:"per_page,omitempty"`
31
}
32
33
type RepoCommitResponse struct {
···
8
)
9
10
type RepoIndexResponse struct {
11
+
IsEmpty bool `json:"is_empty"`
12
+
Ref string `json:"ref,omitempty"`
13
+
Readme string `json:"readme,omitempty"`
14
+
ReadmeFileName string `json:"readme_file_name,omitempty"`
15
+
Commits []Commit `json:"commits,omitempty"`
16
+
Description string `json:"description,omitempty"`
17
+
Files []NiceTree `json:"files,omitempty"`
18
+
Branches []Branch `json:"branches,omitempty"`
19
+
Tags []*TagReference `json:"tags,omitempty"`
20
+
TotalCommits int `json:"total_commits,omitempty"`
21
}
22
23
type RepoLogResponse struct {
24
+
Commits []Commit `json:"commits,omitempty"`
25
+
Ref string `json:"ref,omitempty"`
26
+
Description string `json:"description,omitempty"`
27
+
Log bool `json:"log,omitempty"`
28
+
Total int `json:"total,omitempty"`
29
+
Page int `json:"page,omitempty"`
30
+
PerPage int `json:"per_page,omitempty"`
31
}
32
33
type RepoCommitResponse struct {