+28
.tangled/workflows/build.yml
+28
.tangled/workflows/build.yml
···
1
+
when:
2
+
- event: ["push", "pull_request"]
3
+
branch: ["master"]
4
+
5
+
dependencies:
6
+
nixpkgs:
7
+
- go
8
+
- gcc
9
+
10
+
environment:
11
+
CGO_ENABLED: 1
12
+
13
+
steps:
14
+
- name: patch static dir
15
+
command: |
16
+
mkdir -p appview/pages/static; touch appview/pages/static/x
17
+
18
+
- name: build appview
19
+
command: |
20
+
go build -o appview.out ./cmd/appview
21
+
22
+
- name: build knot
23
+
command: |
24
+
go build -o knot.out ./cmd/knot
25
+
26
+
- name: build spindle
27
+
command: |
28
+
go build -o spindle.out ./cmd/spindle
+18
.tangled/workflows/fmt.yml
+18
.tangled/workflows/fmt.yml
+19
.tangled/workflows/test.yml
+19
.tangled/workflows/test.yml
···
1
+
when:
2
+
- event: ["push", "pull_request"]
3
+
branch: ["master"]
4
+
5
+
dependencies:
6
+
nixpkgs:
7
+
- go
8
+
- gcc
9
+
10
+
steps:
11
+
- name: patch static dir
12
+
command: |
13
+
mkdir -p appview/pages/static; touch appview/pages/static/x
14
+
15
+
- name: run all tests
16
+
environment:
17
+
CGO_ENABLED: 1
18
+
command: |
19
+
go test -v ./...
+1098
-160
api/tangled/cbor_gen.go
+1098
-160
api/tangled/cbor_gen.go
···
504
504
505
505
return nil
506
506
}
507
+
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
508
+
if t == nil {
509
+
_, err := w.Write(cbg.CborNull)
510
+
return err
511
+
}
512
+
513
+
cw := cbg.NewCborWriter(w)
514
+
515
+
if _, err := cw.Write([]byte{164}); err != nil {
516
+
return err
517
+
}
518
+
519
+
// t.LexiconTypeID (string) (string)
520
+
if len("$type") > 1000000 {
521
+
return xerrors.Errorf("Value in field \"$type\" was too long")
522
+
}
523
+
524
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
525
+
return err
526
+
}
527
+
if _, err := cw.WriteString(string("$type")); err != nil {
528
+
return err
529
+
}
530
+
531
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil {
532
+
return err
533
+
}
534
+
if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil {
535
+
return err
536
+
}
537
+
538
+
// t.Subject (string) (string)
539
+
if len("subject") > 1000000 {
540
+
return xerrors.Errorf("Value in field \"subject\" was too long")
541
+
}
542
+
543
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
544
+
return err
545
+
}
546
+
if _, err := cw.WriteString(string("subject")); err != nil {
547
+
return err
548
+
}
549
+
550
+
if len(t.Subject) > 1000000 {
551
+
return xerrors.Errorf("Value in field t.Subject was too long")
552
+
}
553
+
554
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
555
+
return err
556
+
}
557
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
558
+
return err
559
+
}
560
+
561
+
// t.Reaction (string) (string)
562
+
if len("reaction") > 1000000 {
563
+
return xerrors.Errorf("Value in field \"reaction\" was too long")
564
+
}
565
+
566
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reaction"))); err != nil {
567
+
return err
568
+
}
569
+
if _, err := cw.WriteString(string("reaction")); err != nil {
570
+
return err
571
+
}
572
+
573
+
if len(t.Reaction) > 1000000 {
574
+
return xerrors.Errorf("Value in field t.Reaction was too long")
575
+
}
576
+
577
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Reaction))); err != nil {
578
+
return err
579
+
}
580
+
if _, err := cw.WriteString(string(t.Reaction)); err != nil {
581
+
return err
582
+
}
583
+
584
+
// t.CreatedAt (string) (string)
585
+
if len("createdAt") > 1000000 {
586
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
587
+
}
588
+
589
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
590
+
return err
591
+
}
592
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
593
+
return err
594
+
}
595
+
596
+
if len(t.CreatedAt) > 1000000 {
597
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
598
+
}
599
+
600
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
601
+
return err
602
+
}
603
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
604
+
return err
605
+
}
606
+
return nil
607
+
}
608
+
609
+
func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) {
610
+
*t = FeedReaction{}
611
+
612
+
cr := cbg.NewCborReader(r)
613
+
614
+
maj, extra, err := cr.ReadHeader()
615
+
if err != nil {
616
+
return err
617
+
}
618
+
defer func() {
619
+
if err == io.EOF {
620
+
err = io.ErrUnexpectedEOF
621
+
}
622
+
}()
623
+
624
+
if maj != cbg.MajMap {
625
+
return fmt.Errorf("cbor input should be of type map")
626
+
}
627
+
628
+
if extra > cbg.MaxLength {
629
+
return fmt.Errorf("FeedReaction: map struct too large (%d)", extra)
630
+
}
631
+
632
+
n := extra
633
+
634
+
nameBuf := make([]byte, 9)
635
+
for i := uint64(0); i < n; i++ {
636
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
637
+
if err != nil {
638
+
return err
639
+
}
640
+
641
+
if !ok {
642
+
// Field doesn't exist on this type, so ignore it
643
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
644
+
return err
645
+
}
646
+
continue
647
+
}
648
+
649
+
switch string(nameBuf[:nameLen]) {
650
+
// t.LexiconTypeID (string) (string)
651
+
case "$type":
652
+
653
+
{
654
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
655
+
if err != nil {
656
+
return err
657
+
}
658
+
659
+
t.LexiconTypeID = string(sval)
660
+
}
661
+
// t.Subject (string) (string)
662
+
case "subject":
663
+
664
+
{
665
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
666
+
if err != nil {
667
+
return err
668
+
}
669
+
670
+
t.Subject = string(sval)
671
+
}
672
+
// t.Reaction (string) (string)
673
+
case "reaction":
674
+
675
+
{
676
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
677
+
if err != nil {
678
+
return err
679
+
}
680
+
681
+
t.Reaction = string(sval)
682
+
}
683
+
// t.CreatedAt (string) (string)
684
+
case "createdAt":
685
+
686
+
{
687
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
688
+
if err != nil {
689
+
return err
690
+
}
691
+
692
+
t.CreatedAt = string(sval)
693
+
}
694
+
695
+
default:
696
+
// Field doesn't exist on this type, so ignore it
697
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
698
+
return err
699
+
}
700
+
}
701
+
}
702
+
703
+
return nil
704
+
}
507
705
func (t *FeedStar) MarshalCBOR(w io.Writer) error {
508
706
if t == nil {
509
707
_, err := w.Write(cbg.CborNull)
···
2188
2386
2189
2387
return nil
2190
2388
}
2191
-
func (t *Pipeline_Dependencies_Elem) MarshalCBOR(w io.Writer) error {
2389
+
func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error {
2192
2390
if t == nil {
2193
2391
_, err := w.Write(cbg.CborNull)
2194
2392
return err
···
2258
2456
return nil
2259
2457
}
2260
2458
2261
-
func (t *Pipeline_Dependencies_Elem) UnmarshalCBOR(r io.Reader) (err error) {
2262
-
*t = Pipeline_Dependencies_Elem{}
2459
+
func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) {
2460
+
*t = Pipeline_Dependency{}
2263
2461
2264
2462
cr := cbg.NewCborReader(r)
2265
2463
···
2278
2476
}
2279
2477
2280
2478
if extra > cbg.MaxLength {
2281
-
return fmt.Errorf("Pipeline_Dependencies_Elem: map struct too large (%d)", extra)
2479
+
return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra)
2282
2480
}
2283
2481
2284
2482
n := extra
···
2378
2576
return err
2379
2577
}
2380
2578
2381
-
// t.Inputs ([]*tangled.Pipeline_ManualTriggerData_Inputs_Elem) (slice)
2579
+
// t.Inputs ([]*tangled.Pipeline_Pair) (slice)
2382
2580
if t.Inputs != nil {
2383
2581
2384
2582
if len("inputs") > 1000000 {
···
2450
2648
}
2451
2649
2452
2650
switch string(nameBuf[:nameLen]) {
2453
-
// t.Inputs ([]*tangled.Pipeline_ManualTriggerData_Inputs_Elem) (slice)
2651
+
// t.Inputs ([]*tangled.Pipeline_Pair) (slice)
2454
2652
case "inputs":
2455
2653
2456
2654
maj, extra, err = cr.ReadHeader()
···
2467
2665
}
2468
2666
2469
2667
if extra > 0 {
2470
-
t.Inputs = make([]*Pipeline_ManualTriggerData_Inputs_Elem, extra)
2668
+
t.Inputs = make([]*Pipeline_Pair, extra)
2471
2669
}
2472
2670
2473
2671
for i := 0; i < int(extra); i++ {
···
2489
2687
if err := cr.UnreadByte(); err != nil {
2490
2688
return err
2491
2689
}
2492
-
t.Inputs[i] = new(Pipeline_ManualTriggerData_Inputs_Elem)
2690
+
t.Inputs[i] = new(Pipeline_Pair)
2493
2691
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
2494
2692
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
2495
2693
}
···
2510
2708
2511
2709
return nil
2512
2710
}
2513
-
func (t *Pipeline_ManualTriggerData_Inputs_Elem) MarshalCBOR(w io.Writer) error {
2711
+
func (t *Pipeline_Pair) MarshalCBOR(w io.Writer) error {
2514
2712
if t == nil {
2515
2713
_, err := w.Write(cbg.CborNull)
2516
2714
return err
···
2570
2768
return nil
2571
2769
}
2572
2770
2573
-
func (t *Pipeline_ManualTriggerData_Inputs_Elem) UnmarshalCBOR(r io.Reader) (err error) {
2574
-
*t = Pipeline_ManualTriggerData_Inputs_Elem{}
2771
+
func (t *Pipeline_Pair) UnmarshalCBOR(r io.Reader) (err error) {
2772
+
*t = Pipeline_Pair{}
2575
2773
2576
2774
cr := cbg.NewCborReader(r)
2577
2775
···
2590
2788
}
2591
2789
2592
2790
if extra > cbg.MaxLength {
2593
-
return fmt.Errorf("Pipeline_ManualTriggerData_Inputs_Elem: map struct too large (%d)", extra)
2791
+
return fmt.Errorf("Pipeline_Pair: map struct too large (%d)", extra)
2594
2792
}
2595
2793
2596
2794
n := extra
···
3014
3212
3015
3213
return nil
3016
3214
}
3215
+
func (t *PipelineStatus) MarshalCBOR(w io.Writer) error {
3216
+
if t == nil {
3217
+
_, err := w.Write(cbg.CborNull)
3218
+
return err
3219
+
}
3220
+
3221
+
cw := cbg.NewCborWriter(w)
3222
+
fieldCount := 7
3223
+
3224
+
if t.Error == nil {
3225
+
fieldCount--
3226
+
}
3227
+
3228
+
if t.ExitCode == nil {
3229
+
fieldCount--
3230
+
}
3231
+
3232
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3233
+
return err
3234
+
}
3235
+
3236
+
// t.LexiconTypeID (string) (string)
3237
+
if len("$type") > 1000000 {
3238
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3239
+
}
3240
+
3241
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3242
+
return err
3243
+
}
3244
+
if _, err := cw.WriteString(string("$type")); err != nil {
3245
+
return err
3246
+
}
3247
+
3248
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.pipeline.status"))); err != nil {
3249
+
return err
3250
+
}
3251
+
if _, err := cw.WriteString(string("sh.tangled.pipeline.status")); err != nil {
3252
+
return err
3253
+
}
3254
+
3255
+
// t.Error (string) (string)
3256
+
if t.Error != nil {
3257
+
3258
+
if len("error") > 1000000 {
3259
+
return xerrors.Errorf("Value in field \"error\" was too long")
3260
+
}
3261
+
3262
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("error"))); err != nil {
3263
+
return err
3264
+
}
3265
+
if _, err := cw.WriteString(string("error")); err != nil {
3266
+
return err
3267
+
}
3268
+
3269
+
if t.Error == nil {
3270
+
if _, err := cw.Write(cbg.CborNull); err != nil {
3271
+
return err
3272
+
}
3273
+
} else {
3274
+
if len(*t.Error) > 1000000 {
3275
+
return xerrors.Errorf("Value in field t.Error was too long")
3276
+
}
3277
+
3278
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Error))); err != nil {
3279
+
return err
3280
+
}
3281
+
if _, err := cw.WriteString(string(*t.Error)); err != nil {
3282
+
return err
3283
+
}
3284
+
}
3285
+
}
3286
+
3287
+
// t.Status (string) (string)
3288
+
if len("status") > 1000000 {
3289
+
return xerrors.Errorf("Value in field \"status\" was too long")
3290
+
}
3291
+
3292
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("status"))); err != nil {
3293
+
return err
3294
+
}
3295
+
if _, err := cw.WriteString(string("status")); err != nil {
3296
+
return err
3297
+
}
3298
+
3299
+
if len(t.Status) > 1000000 {
3300
+
return xerrors.Errorf("Value in field t.Status was too long")
3301
+
}
3302
+
3303
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Status))); err != nil {
3304
+
return err
3305
+
}
3306
+
if _, err := cw.WriteString(string(t.Status)); err != nil {
3307
+
return err
3308
+
}
3309
+
3310
+
// t.ExitCode (int64) (int64)
3311
+
if t.ExitCode != nil {
3312
+
3313
+
if len("exitCode") > 1000000 {
3314
+
return xerrors.Errorf("Value in field \"exitCode\" was too long")
3315
+
}
3316
+
3317
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("exitCode"))); err != nil {
3318
+
return err
3319
+
}
3320
+
if _, err := cw.WriteString(string("exitCode")); err != nil {
3321
+
return err
3322
+
}
3323
+
3324
+
if t.ExitCode == nil {
3325
+
if _, err := cw.Write(cbg.CborNull); err != nil {
3326
+
return err
3327
+
}
3328
+
} else {
3329
+
if *t.ExitCode >= 0 {
3330
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.ExitCode)); err != nil {
3331
+
return err
3332
+
}
3333
+
} else {
3334
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.ExitCode-1)); err != nil {
3335
+
return err
3336
+
}
3337
+
}
3338
+
}
3339
+
3340
+
}
3341
+
3342
+
// t.Pipeline (string) (string)
3343
+
if len("pipeline") > 1000000 {
3344
+
return xerrors.Errorf("Value in field \"pipeline\" was too long")
3345
+
}
3346
+
3347
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pipeline"))); err != nil {
3348
+
return err
3349
+
}
3350
+
if _, err := cw.WriteString(string("pipeline")); err != nil {
3351
+
return err
3352
+
}
3353
+
3354
+
if len(t.Pipeline) > 1000000 {
3355
+
return xerrors.Errorf("Value in field t.Pipeline was too long")
3356
+
}
3357
+
3358
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Pipeline))); err != nil {
3359
+
return err
3360
+
}
3361
+
if _, err := cw.WriteString(string(t.Pipeline)); err != nil {
3362
+
return err
3363
+
}
3364
+
3365
+
// t.Workflow (string) (string)
3366
+
if len("workflow") > 1000000 {
3367
+
return xerrors.Errorf("Value in field \"workflow\" was too long")
3368
+
}
3369
+
3370
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("workflow"))); err != nil {
3371
+
return err
3372
+
}
3373
+
if _, err := cw.WriteString(string("workflow")); err != nil {
3374
+
return err
3375
+
}
3376
+
3377
+
if len(t.Workflow) > 1000000 {
3378
+
return xerrors.Errorf("Value in field t.Workflow was too long")
3379
+
}
3380
+
3381
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Workflow))); err != nil {
3382
+
return err
3383
+
}
3384
+
if _, err := cw.WriteString(string(t.Workflow)); err != nil {
3385
+
return err
3386
+
}
3387
+
3388
+
// t.CreatedAt (string) (string)
3389
+
if len("createdAt") > 1000000 {
3390
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
3391
+
}
3392
+
3393
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
3394
+
return err
3395
+
}
3396
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
3397
+
return err
3398
+
}
3399
+
3400
+
if len(t.CreatedAt) > 1000000 {
3401
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
3402
+
}
3403
+
3404
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
3405
+
return err
3406
+
}
3407
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
3408
+
return err
3409
+
}
3410
+
return nil
3411
+
}
3412
+
3413
+
func (t *PipelineStatus) UnmarshalCBOR(r io.Reader) (err error) {
3414
+
*t = PipelineStatus{}
3415
+
3416
+
cr := cbg.NewCborReader(r)
3417
+
3418
+
maj, extra, err := cr.ReadHeader()
3419
+
if err != nil {
3420
+
return err
3421
+
}
3422
+
defer func() {
3423
+
if err == io.EOF {
3424
+
err = io.ErrUnexpectedEOF
3425
+
}
3426
+
}()
3427
+
3428
+
if maj != cbg.MajMap {
3429
+
return fmt.Errorf("cbor input should be of type map")
3430
+
}
3431
+
3432
+
if extra > cbg.MaxLength {
3433
+
return fmt.Errorf("PipelineStatus: map struct too large (%d)", extra)
3434
+
}
3435
+
3436
+
n := extra
3437
+
3438
+
nameBuf := make([]byte, 9)
3439
+
for i := uint64(0); i < n; i++ {
3440
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3441
+
if err != nil {
3442
+
return err
3443
+
}
3444
+
3445
+
if !ok {
3446
+
// Field doesn't exist on this type, so ignore it
3447
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3448
+
return err
3449
+
}
3450
+
continue
3451
+
}
3452
+
3453
+
switch string(nameBuf[:nameLen]) {
3454
+
// t.LexiconTypeID (string) (string)
3455
+
case "$type":
3456
+
3457
+
{
3458
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3459
+
if err != nil {
3460
+
return err
3461
+
}
3462
+
3463
+
t.LexiconTypeID = string(sval)
3464
+
}
3465
+
// t.Error (string) (string)
3466
+
case "error":
3467
+
3468
+
{
3469
+
b, err := cr.ReadByte()
3470
+
if err != nil {
3471
+
return err
3472
+
}
3473
+
if b != cbg.CborNull[0] {
3474
+
if err := cr.UnreadByte(); err != nil {
3475
+
return err
3476
+
}
3477
+
3478
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3479
+
if err != nil {
3480
+
return err
3481
+
}
3482
+
3483
+
t.Error = (*string)(&sval)
3484
+
}
3485
+
}
3486
+
// t.Status (string) (string)
3487
+
case "status":
3488
+
3489
+
{
3490
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3491
+
if err != nil {
3492
+
return err
3493
+
}
3494
+
3495
+
t.Status = string(sval)
3496
+
}
3497
+
// t.ExitCode (int64) (int64)
3498
+
case "exitCode":
3499
+
{
3500
+
3501
+
b, err := cr.ReadByte()
3502
+
if err != nil {
3503
+
return err
3504
+
}
3505
+
if b != cbg.CborNull[0] {
3506
+
if err := cr.UnreadByte(); err != nil {
3507
+
return err
3508
+
}
3509
+
maj, extra, err := cr.ReadHeader()
3510
+
if err != nil {
3511
+
return err
3512
+
}
3513
+
var extraI int64
3514
+
switch maj {
3515
+
case cbg.MajUnsignedInt:
3516
+
extraI = int64(extra)
3517
+
if extraI < 0 {
3518
+
return fmt.Errorf("int64 positive overflow")
3519
+
}
3520
+
case cbg.MajNegativeInt:
3521
+
extraI = int64(extra)
3522
+
if extraI < 0 {
3523
+
return fmt.Errorf("int64 negative overflow")
3524
+
}
3525
+
extraI = -1 - extraI
3526
+
default:
3527
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
3528
+
}
3529
+
3530
+
t.ExitCode = (*int64)(&extraI)
3531
+
}
3532
+
}
3533
+
// t.Pipeline (string) (string)
3534
+
case "pipeline":
3535
+
3536
+
{
3537
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3538
+
if err != nil {
3539
+
return err
3540
+
}
3541
+
3542
+
t.Pipeline = string(sval)
3543
+
}
3544
+
// t.Workflow (string) (string)
3545
+
case "workflow":
3546
+
3547
+
{
3548
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3549
+
if err != nil {
3550
+
return err
3551
+
}
3552
+
3553
+
t.Workflow = string(sval)
3554
+
}
3555
+
// t.CreatedAt (string) (string)
3556
+
case "createdAt":
3557
+
3558
+
{
3559
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3560
+
if err != nil {
3561
+
return err
3562
+
}
3563
+
3564
+
t.CreatedAt = string(sval)
3565
+
}
3566
+
3567
+
default:
3568
+
// Field doesn't exist on this type, so ignore it
3569
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3570
+
return err
3571
+
}
3572
+
}
3573
+
}
3574
+
3575
+
return nil
3576
+
}
3017
3577
func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error {
3018
3578
if t == nil {
3019
3579
_, err := w.Write(cbg.CborNull)
···
3021
3581
}
3022
3582
3023
3583
cw := cbg.NewCborWriter(w)
3584
+
fieldCount := 3
3024
3585
3025
-
if _, err := cw.Write([]byte{162}); err != nil {
3586
+
if t.Environment == nil {
3587
+
fieldCount--
3588
+
}
3589
+
3590
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3026
3591
return err
3027
3592
}
3028
3593
···
3071
3636
if _, err := cw.WriteString(string(t.Command)); err != nil {
3072
3637
return err
3073
3638
}
3639
+
3640
+
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3641
+
if t.Environment != nil {
3642
+
3643
+
if len("environment") > 1000000 {
3644
+
return xerrors.Errorf("Value in field \"environment\" was too long")
3645
+
}
3646
+
3647
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
3648
+
return err
3649
+
}
3650
+
if _, err := cw.WriteString(string("environment")); err != nil {
3651
+
return err
3652
+
}
3653
+
3654
+
if len(t.Environment) > 8192 {
3655
+
return xerrors.Errorf("Slice value in field t.Environment was too long")
3656
+
}
3657
+
3658
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
3659
+
return err
3660
+
}
3661
+
for _, v := range t.Environment {
3662
+
if err := v.MarshalCBOR(cw); err != nil {
3663
+
return err
3664
+
}
3665
+
3666
+
}
3667
+
}
3074
3668
return nil
3075
3669
}
3076
3670
···
3099
3693
3100
3694
n := extra
3101
3695
3102
-
nameBuf := make([]byte, 7)
3696
+
nameBuf := make([]byte, 11)
3103
3697
for i := uint64(0); i < n; i++ {
3104
3698
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3105
3699
if err != nil {
···
3136
3730
}
3137
3731
3138
3732
t.Command = string(sval)
3733
+
}
3734
+
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3735
+
case "environment":
3736
+
3737
+
maj, extra, err = cr.ReadHeader()
3738
+
if err != nil {
3739
+
return err
3740
+
}
3741
+
3742
+
if extra > 8192 {
3743
+
return fmt.Errorf("t.Environment: array too large (%d)", extra)
3744
+
}
3745
+
3746
+
if maj != cbg.MajArray {
3747
+
return fmt.Errorf("expected cbor array")
3748
+
}
3749
+
3750
+
if extra > 0 {
3751
+
t.Environment = make([]*Pipeline_Pair, extra)
3752
+
}
3753
+
3754
+
for i := 0; i < int(extra); i++ {
3755
+
{
3756
+
var maj byte
3757
+
var extra uint64
3758
+
var err error
3759
+
_ = maj
3760
+
_ = extra
3761
+
_ = err
3762
+
3763
+
{
3764
+
3765
+
b, err := cr.ReadByte()
3766
+
if err != nil {
3767
+
return err
3768
+
}
3769
+
if b != cbg.CborNull[0] {
3770
+
if err := cr.UnreadByte(); err != nil {
3771
+
return err
3772
+
}
3773
+
t.Environment[i] = new(Pipeline_Pair)
3774
+
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
3775
+
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
3776
+
}
3777
+
}
3778
+
3779
+
}
3780
+
3781
+
}
3139
3782
}
3140
3783
3141
3784
default:
···
3693
4336
3694
4337
}
3695
4338
3696
-
// t.Environment ([]*tangled.Pipeline_Workflow_Environment_Elem) (slice)
4339
+
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3697
4340
if len("environment") > 1000000 {
3698
4341
return xerrors.Errorf("Value in field \"environment\" was too long")
3699
4342
}
···
3719
4362
3720
4363
}
3721
4364
3722
-
// t.Dependencies ([]tangled.Pipeline_Dependencies_Elem) (slice)
4365
+
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
3723
4366
if len("dependencies") > 1000000 {
3724
4367
return xerrors.Errorf("Value in field \"dependencies\" was too long")
3725
4368
}
···
3868
4511
3869
4512
}
3870
4513
}
3871
-
// t.Environment ([]*tangled.Pipeline_Workflow_Environment_Elem) (slice)
4514
+
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3872
4515
case "environment":
3873
4516
3874
4517
maj, extra, err = cr.ReadHeader()
···
3885
4528
}
3886
4529
3887
4530
if extra > 0 {
3888
-
t.Environment = make([]*Pipeline_Workflow_Environment_Elem, extra)
4531
+
t.Environment = make([]*Pipeline_Pair, extra)
3889
4532
}
3890
4533
3891
4534
for i := 0; i < int(extra); i++ {
···
3907
4550
if err := cr.UnreadByte(); err != nil {
3908
4551
return err
3909
4552
}
3910
-
t.Environment[i] = new(Pipeline_Workflow_Environment_Elem)
4553
+
t.Environment[i] = new(Pipeline_Pair)
3911
4554
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
3912
4555
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
3913
4556
}
···
3917
4560
3918
4561
}
3919
4562
}
3920
-
// t.Dependencies ([]tangled.Pipeline_Dependencies_Elem) (slice)
4563
+
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
3921
4564
case "dependencies":
3922
4565
3923
4566
maj, extra, err = cr.ReadHeader()
···
3934
4577
}
3935
4578
3936
4579
if extra > 0 {
3937
-
t.Dependencies = make([]Pipeline_Dependencies_Elem, extra)
4580
+
t.Dependencies = make([]*Pipeline_Dependency, extra)
3938
4581
}
3939
4582
3940
4583
for i := 0; i < int(extra); i++ {
···
3948
4591
3949
4592
{
3950
4593
3951
-
if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil {
3952
-
return xerrors.Errorf("unmarshaling t.Dependencies[i]: %w", err)
4594
+
b, err := cr.ReadByte()
4595
+
if err != nil {
4596
+
return err
4597
+
}
4598
+
if b != cbg.CborNull[0] {
4599
+
if err := cr.UnreadByte(); err != nil {
4600
+
return err
4601
+
}
4602
+
t.Dependencies[i] = new(Pipeline_Dependency)
4603
+
if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil {
4604
+
return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err)
4605
+
}
3953
4606
}
3954
4607
3955
4608
}
···
3967
4620
3968
4621
return nil
3969
4622
}
3970
-
func (t *Pipeline_Workflow_Environment_Elem) MarshalCBOR(w io.Writer) error {
3971
-
if t == nil {
3972
-
_, err := w.Write(cbg.CborNull)
3973
-
return err
3974
-
}
3975
-
3976
-
cw := cbg.NewCborWriter(w)
3977
-
3978
-
if _, err := cw.Write([]byte{162}); err != nil {
3979
-
return err
3980
-
}
3981
-
3982
-
// t.Key (string) (string)
3983
-
if len("key") > 1000000 {
3984
-
return xerrors.Errorf("Value in field \"key\" was too long")
3985
-
}
3986
-
3987
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil {
3988
-
return err
3989
-
}
3990
-
if _, err := cw.WriteString(string("key")); err != nil {
3991
-
return err
3992
-
}
3993
-
3994
-
if len(t.Key) > 1000000 {
3995
-
return xerrors.Errorf("Value in field t.Key was too long")
3996
-
}
3997
-
3998
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil {
3999
-
return err
4000
-
}
4001
-
if _, err := cw.WriteString(string(t.Key)); err != nil {
4002
-
return err
4003
-
}
4004
-
4005
-
// t.Value (string) (string)
4006
-
if len("value") > 1000000 {
4007
-
return xerrors.Errorf("Value in field \"value\" was too long")
4008
-
}
4009
-
4010
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil {
4011
-
return err
4012
-
}
4013
-
if _, err := cw.WriteString(string("value")); err != nil {
4014
-
return err
4015
-
}
4016
-
4017
-
if len(t.Value) > 1000000 {
4018
-
return xerrors.Errorf("Value in field t.Value was too long")
4019
-
}
4020
-
4021
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil {
4022
-
return err
4023
-
}
4024
-
if _, err := cw.WriteString(string(t.Value)); err != nil {
4025
-
return err
4026
-
}
4027
-
return nil
4028
-
}
4029
-
4030
-
func (t *Pipeline_Workflow_Environment_Elem) UnmarshalCBOR(r io.Reader) (err error) {
4031
-
*t = Pipeline_Workflow_Environment_Elem{}
4032
-
4033
-
cr := cbg.NewCborReader(r)
4034
-
4035
-
maj, extra, err := cr.ReadHeader()
4036
-
if err != nil {
4037
-
return err
4038
-
}
4039
-
defer func() {
4040
-
if err == io.EOF {
4041
-
err = io.ErrUnexpectedEOF
4042
-
}
4043
-
}()
4044
-
4045
-
if maj != cbg.MajMap {
4046
-
return fmt.Errorf("cbor input should be of type map")
4047
-
}
4048
-
4049
-
if extra > cbg.MaxLength {
4050
-
return fmt.Errorf("Pipeline_Workflow_Environment_Elem: map struct too large (%d)", extra)
4051
-
}
4052
-
4053
-
n := extra
4054
-
4055
-
nameBuf := make([]byte, 5)
4056
-
for i := uint64(0); i < n; i++ {
4057
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4058
-
if err != nil {
4059
-
return err
4060
-
}
4061
-
4062
-
if !ok {
4063
-
// Field doesn't exist on this type, so ignore it
4064
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
4065
-
return err
4066
-
}
4067
-
continue
4068
-
}
4069
-
4070
-
switch string(nameBuf[:nameLen]) {
4071
-
// t.Key (string) (string)
4072
-
case "key":
4073
-
4074
-
{
4075
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4076
-
if err != nil {
4077
-
return err
4078
-
}
4079
-
4080
-
t.Key = string(sval)
4081
-
}
4082
-
// t.Value (string) (string)
4083
-
case "value":
4084
-
4085
-
{
4086
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4087
-
if err != nil {
4088
-
return err
4089
-
}
4090
-
4091
-
t.Value = string(sval)
4092
-
}
4093
-
4094
-
default:
4095
-
// Field doesn't exist on this type, so ignore it
4096
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
4097
-
return err
4098
-
}
4099
-
}
4100
-
}
4101
-
4102
-
return nil
4103
-
}
4104
4623
func (t *PublicKey) MarshalCBOR(w io.Writer) error {
4105
4624
if t == nil {
4106
4625
_, err := w.Write(cbg.CborNull)
···
4306
4825
}
4307
4826
4308
4827
cw := cbg.NewCborWriter(w)
4309
-
fieldCount := 7
4828
+
fieldCount := 8
4310
4829
4311
4830
if t.Description == nil {
4312
4831
fieldCount--
4313
4832
}
4314
4833
4315
4834
if t.Source == nil {
4835
+
fieldCount--
4836
+
}
4837
+
4838
+
if t.Spindle == nil {
4316
4839
fieldCount--
4317
4840
}
4318
4841
···
4440
4963
}
4441
4964
}
4442
4965
4966
+
// t.Spindle (string) (string)
4967
+
if t.Spindle != nil {
4968
+
4969
+
if len("spindle") > 1000000 {
4970
+
return xerrors.Errorf("Value in field \"spindle\" was too long")
4971
+
}
4972
+
4973
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("spindle"))); err != nil {
4974
+
return err
4975
+
}
4976
+
if _, err := cw.WriteString(string("spindle")); err != nil {
4977
+
return err
4978
+
}
4979
+
4980
+
if t.Spindle == nil {
4981
+
if _, err := cw.Write(cbg.CborNull); err != nil {
4982
+
return err
4983
+
}
4984
+
} else {
4985
+
if len(*t.Spindle) > 1000000 {
4986
+
return xerrors.Errorf("Value in field t.Spindle was too long")
4987
+
}
4988
+
4989
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Spindle))); err != nil {
4990
+
return err
4991
+
}
4992
+
if _, err := cw.WriteString(string(*t.Spindle)); err != nil {
4993
+
return err
4994
+
}
4995
+
}
4996
+
}
4997
+
4443
4998
// t.CreatedAt (string) (string)
4444
4999
if len("createdAt") > 1000000 {
4445
5000
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
4601
5156
}
4602
5157
4603
5158
t.Source = (*string)(&sval)
5159
+
}
5160
+
}
5161
+
// t.Spindle (string) (string)
5162
+
case "spindle":
5163
+
5164
+
{
5165
+
b, err := cr.ReadByte()
5166
+
if err != nil {
5167
+
return err
5168
+
}
5169
+
if b != cbg.CborNull[0] {
5170
+
if err := cr.UnreadByte(); err != nil {
5171
+
return err
5172
+
}
5173
+
5174
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5175
+
if err != nil {
5176
+
return err
5177
+
}
5178
+
5179
+
t.Spindle = (*string)(&sval)
4604
5180
}
4605
5181
}
4606
5182
// t.CreatedAt (string) (string)
···
6630
7206
}
6631
7207
6632
7208
cw := cbg.NewCborWriter(w)
6633
-
fieldCount := 2
7209
+
fieldCount := 3
6634
7210
6635
7211
if t.Repo == nil {
6636
7212
fieldCount--
6637
7213
}
6638
7214
6639
7215
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
7216
+
return err
7217
+
}
7218
+
7219
+
// t.Sha (string) (string)
7220
+
if len("sha") > 1000000 {
7221
+
return xerrors.Errorf("Value in field \"sha\" was too long")
7222
+
}
7223
+
7224
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sha"))); err != nil {
7225
+
return err
7226
+
}
7227
+
if _, err := cw.WriteString(string("sha")); err != nil {
7228
+
return err
7229
+
}
7230
+
7231
+
if len(t.Sha) > 1000000 {
7232
+
return xerrors.Errorf("Value in field t.Sha was too long")
7233
+
}
7234
+
7235
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sha))); err != nil {
7236
+
return err
7237
+
}
7238
+
if _, err := cw.WriteString(string(t.Sha)); err != nil {
6640
7239
return err
6641
7240
}
6642
7241
···
6738
7337
}
6739
7338
6740
7339
switch string(nameBuf[:nameLen]) {
6741
-
// t.Repo (string) (string)
7340
+
// t.Sha (string) (string)
7341
+
case "sha":
7342
+
7343
+
{
7344
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7345
+
if err != nil {
7346
+
return err
7347
+
}
7348
+
7349
+
t.Sha = string(sval)
7350
+
}
7351
+
// t.Repo (string) (string)
6742
7352
case "repo":
6743
7353
6744
7354
{
···
6945
7555
6946
7556
return nil
6947
7557
}
7558
+
func (t *Spindle) MarshalCBOR(w io.Writer) error {
7559
+
if t == nil {
7560
+
_, err := w.Write(cbg.CborNull)
7561
+
return err
7562
+
}
7563
+
7564
+
cw := cbg.NewCborWriter(w)
7565
+
7566
+
if _, err := cw.Write([]byte{162}); err != nil {
7567
+
return err
7568
+
}
7569
+
7570
+
// t.LexiconTypeID (string) (string)
7571
+
if len("$type") > 1000000 {
7572
+
return xerrors.Errorf("Value in field \"$type\" was too long")
7573
+
}
7574
+
7575
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
7576
+
return err
7577
+
}
7578
+
if _, err := cw.WriteString(string("$type")); err != nil {
7579
+
return err
7580
+
}
7581
+
7582
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.spindle"))); err != nil {
7583
+
return err
7584
+
}
7585
+
if _, err := cw.WriteString(string("sh.tangled.spindle")); err != nil {
7586
+
return err
7587
+
}
7588
+
7589
+
// t.CreatedAt (string) (string)
7590
+
if len("createdAt") > 1000000 {
7591
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
7592
+
}
7593
+
7594
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
7595
+
return err
7596
+
}
7597
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
7598
+
return err
7599
+
}
7600
+
7601
+
if len(t.CreatedAt) > 1000000 {
7602
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
7603
+
}
7604
+
7605
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
7606
+
return err
7607
+
}
7608
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7609
+
return err
7610
+
}
7611
+
return nil
7612
+
}
7613
+
7614
+
func (t *Spindle) UnmarshalCBOR(r io.Reader) (err error) {
7615
+
*t = Spindle{}
7616
+
7617
+
cr := cbg.NewCborReader(r)
7618
+
7619
+
maj, extra, err := cr.ReadHeader()
7620
+
if err != nil {
7621
+
return err
7622
+
}
7623
+
defer func() {
7624
+
if err == io.EOF {
7625
+
err = io.ErrUnexpectedEOF
7626
+
}
7627
+
}()
7628
+
7629
+
if maj != cbg.MajMap {
7630
+
return fmt.Errorf("cbor input should be of type map")
7631
+
}
7632
+
7633
+
if extra > cbg.MaxLength {
7634
+
return fmt.Errorf("Spindle: map struct too large (%d)", extra)
7635
+
}
7636
+
7637
+
n := extra
7638
+
7639
+
nameBuf := make([]byte, 9)
7640
+
for i := uint64(0); i < n; i++ {
7641
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7642
+
if err != nil {
7643
+
return err
7644
+
}
7645
+
7646
+
if !ok {
7647
+
// Field doesn't exist on this type, so ignore it
7648
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
7649
+
return err
7650
+
}
7651
+
continue
7652
+
}
7653
+
7654
+
switch string(nameBuf[:nameLen]) {
7655
+
// t.LexiconTypeID (string) (string)
7656
+
case "$type":
7657
+
7658
+
{
7659
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7660
+
if err != nil {
7661
+
return err
7662
+
}
7663
+
7664
+
t.LexiconTypeID = string(sval)
7665
+
}
7666
+
// t.CreatedAt (string) (string)
7667
+
case "createdAt":
7668
+
7669
+
{
7670
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7671
+
if err != nil {
7672
+
return err
7673
+
}
7674
+
7675
+
t.CreatedAt = string(sval)
7676
+
}
7677
+
7678
+
default:
7679
+
// Field doesn't exist on this type, so ignore it
7680
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
7681
+
return err
7682
+
}
7683
+
}
7684
+
}
7685
+
7686
+
return nil
7687
+
}
7688
+
func (t *SpindleMember) MarshalCBOR(w io.Writer) error {
7689
+
if t == nil {
7690
+
_, err := w.Write(cbg.CborNull)
7691
+
return err
7692
+
}
7693
+
7694
+
cw := cbg.NewCborWriter(w)
7695
+
7696
+
if _, err := cw.Write([]byte{164}); err != nil {
7697
+
return err
7698
+
}
7699
+
7700
+
// t.LexiconTypeID (string) (string)
7701
+
if len("$type") > 1000000 {
7702
+
return xerrors.Errorf("Value in field \"$type\" was too long")
7703
+
}
7704
+
7705
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
7706
+
return err
7707
+
}
7708
+
if _, err := cw.WriteString(string("$type")); err != nil {
7709
+
return err
7710
+
}
7711
+
7712
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.spindle.member"))); err != nil {
7713
+
return err
7714
+
}
7715
+
if _, err := cw.WriteString(string("sh.tangled.spindle.member")); err != nil {
7716
+
return err
7717
+
}
7718
+
7719
+
// t.Subject (string) (string)
7720
+
if len("subject") > 1000000 {
7721
+
return xerrors.Errorf("Value in field \"subject\" was too long")
7722
+
}
7723
+
7724
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
7725
+
return err
7726
+
}
7727
+
if _, err := cw.WriteString(string("subject")); err != nil {
7728
+
return err
7729
+
}
7730
+
7731
+
if len(t.Subject) > 1000000 {
7732
+
return xerrors.Errorf("Value in field t.Subject was too long")
7733
+
}
7734
+
7735
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
7736
+
return err
7737
+
}
7738
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
7739
+
return err
7740
+
}
7741
+
7742
+
// t.Instance (string) (string)
7743
+
if len("instance") > 1000000 {
7744
+
return xerrors.Errorf("Value in field \"instance\" was too long")
7745
+
}
7746
+
7747
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("instance"))); err != nil {
7748
+
return err
7749
+
}
7750
+
if _, err := cw.WriteString(string("instance")); err != nil {
7751
+
return err
7752
+
}
7753
+
7754
+
if len(t.Instance) > 1000000 {
7755
+
return xerrors.Errorf("Value in field t.Instance was too long")
7756
+
}
7757
+
7758
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Instance))); err != nil {
7759
+
return err
7760
+
}
7761
+
if _, err := cw.WriteString(string(t.Instance)); err != nil {
7762
+
return err
7763
+
}
7764
+
7765
+
// t.CreatedAt (string) (string)
7766
+
if len("createdAt") > 1000000 {
7767
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
7768
+
}
7769
+
7770
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
7771
+
return err
7772
+
}
7773
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
7774
+
return err
7775
+
}
7776
+
7777
+
if len(t.CreatedAt) > 1000000 {
7778
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
7779
+
}
7780
+
7781
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
7782
+
return err
7783
+
}
7784
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7785
+
return err
7786
+
}
7787
+
return nil
7788
+
}
7789
+
7790
+
func (t *SpindleMember) UnmarshalCBOR(r io.Reader) (err error) {
7791
+
*t = SpindleMember{}
7792
+
7793
+
cr := cbg.NewCborReader(r)
7794
+
7795
+
maj, extra, err := cr.ReadHeader()
7796
+
if err != nil {
7797
+
return err
7798
+
}
7799
+
defer func() {
7800
+
if err == io.EOF {
7801
+
err = io.ErrUnexpectedEOF
7802
+
}
7803
+
}()
7804
+
7805
+
if maj != cbg.MajMap {
7806
+
return fmt.Errorf("cbor input should be of type map")
7807
+
}
7808
+
7809
+
if extra > cbg.MaxLength {
7810
+
return fmt.Errorf("SpindleMember: map struct too large (%d)", extra)
7811
+
}
7812
+
7813
+
n := extra
7814
+
7815
+
nameBuf := make([]byte, 9)
7816
+
for i := uint64(0); i < n; i++ {
7817
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7818
+
if err != nil {
7819
+
return err
7820
+
}
7821
+
7822
+
if !ok {
7823
+
// Field doesn't exist on this type, so ignore it
7824
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
7825
+
return err
7826
+
}
7827
+
continue
7828
+
}
7829
+
7830
+
switch string(nameBuf[:nameLen]) {
7831
+
// t.LexiconTypeID (string) (string)
7832
+
case "$type":
7833
+
7834
+
{
7835
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7836
+
if err != nil {
7837
+
return err
7838
+
}
7839
+
7840
+
t.LexiconTypeID = string(sval)
7841
+
}
7842
+
// t.Subject (string) (string)
7843
+
case "subject":
7844
+
7845
+
{
7846
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7847
+
if err != nil {
7848
+
return err
7849
+
}
7850
+
7851
+
t.Subject = string(sval)
7852
+
}
7853
+
// t.Instance (string) (string)
7854
+
case "instance":
7855
+
7856
+
{
7857
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7858
+
if err != nil {
7859
+
return err
7860
+
}
7861
+
7862
+
t.Instance = string(sval)
7863
+
}
7864
+
// t.CreatedAt (string) (string)
7865
+
case "createdAt":
7866
+
7867
+
{
7868
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7869
+
if err != nil {
7870
+
return err
7871
+
}
7872
+
7873
+
t.CreatedAt = string(sval)
7874
+
}
7875
+
7876
+
default:
7877
+
// Field doesn't exist on this type, so ignore it
7878
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
7879
+
return err
7880
+
}
7881
+
}
7882
+
}
7883
+
7884
+
return nil
7885
+
}
+24
api/tangled/feedreaction.go
+24
api/tangled/feedreaction.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.feed.reaction
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
FeedReactionNSID = "sh.tangled.feed.reaction"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{})
17
+
} //
18
+
// RECORDTYPE: FeedReaction
19
+
type FeedReaction struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
Reaction string `json:"reaction" cborgen:"reaction"`
23
+
Subject string `json:"subject" cborgen:"subject"`
24
+
}
+33
api/tangled/pipelinestatus.go
+33
api/tangled/pipelinestatus.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.pipeline.status
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
PipelineStatusNSID = "sh.tangled.pipeline.status"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.pipeline.status", &PipelineStatus{})
17
+
} //
18
+
// RECORDTYPE: PipelineStatus
19
+
type PipelineStatus struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.pipeline.status" cborgen:"$type,const=sh.tangled.pipeline.status"`
21
+
// createdAt: time of creation of this status update
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
// error: error message if failed
24
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
25
+
// exitCode: exit code if failed
26
+
ExitCode *int64 `json:"exitCode,omitempty" cborgen:"exitCode,omitempty"`
27
+
// pipeline: ATURI of the pipeline
28
+
Pipeline string `json:"pipeline" cborgen:"pipeline"`
29
+
// status: status of the workflow
30
+
Status string `json:"status" cborgen:"status"`
31
+
// workflow: name of the workflow within this pipeline
32
+
Workflow string `json:"workflow" cborgen:"workflow"`
33
+
}
+1
api/tangled/repopull.go
+1
api/tangled/repopull.go
+25
api/tangled/spindlemember.go
+25
api/tangled/spindlemember.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.spindle.member
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
SpindleMemberNSID = "sh.tangled.spindle.member"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.spindle.member", &SpindleMember{})
17
+
} //
18
+
// RECORDTYPE: SpindleMember
19
+
type SpindleMember struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.spindle.member" cborgen:"$type,const=sh.tangled.spindle.member"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
// instance: spindle instance that the subject is now a member of
23
+
Instance string `json:"instance" cborgen:"instance"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
+
}
+13
-15
api/tangled/tangledpipeline.go
+13
-15
api/tangled/tangledpipeline.go
···
29
29
Submodules bool `json:"submodules" cborgen:"submodules"`
30
30
}
31
31
32
-
type Pipeline_Dependencies_Elem struct {
32
+
// Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema.
33
+
type Pipeline_Dependency struct {
33
34
Packages []string `json:"packages" cborgen:"packages"`
34
35
Registry string `json:"registry" cborgen:"registry"`
35
36
}
36
37
37
38
// Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema.
38
39
type Pipeline_ManualTriggerData struct {
39
-
Inputs []*Pipeline_ManualTriggerData_Inputs_Elem `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
40
+
Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
40
41
}
41
42
42
-
type Pipeline_ManualTriggerData_Inputs_Elem struct {
43
+
// Pipeline_Pair is a "pair" in the sh.tangled.pipeline schema.
44
+
type Pipeline_Pair struct {
43
45
Key string `json:"key" cborgen:"key"`
44
46
Value string `json:"value" cborgen:"value"`
45
47
}
···
61
63
62
64
// Pipeline_Step is a "step" in the sh.tangled.pipeline schema.
63
65
type Pipeline_Step struct {
64
-
Command string `json:"command" cborgen:"command"`
65
-
Name string `json:"name" cborgen:"name"`
66
+
Command string `json:"command" cborgen:"command"`
67
+
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
68
+
Name string `json:"name" cborgen:"name"`
66
69
}
67
70
68
71
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
···
84
87
85
88
// Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema.
86
89
type Pipeline_Workflow struct {
87
-
Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"`
88
-
Dependencies []Pipeline_Dependencies_Elem `json:"dependencies" cborgen:"dependencies"`
89
-
Environment []*Pipeline_Workflow_Environment_Elem `json:"environment" cborgen:"environment"`
90
-
Name string `json:"name" cborgen:"name"`
91
-
Steps []*Pipeline_Step `json:"steps" cborgen:"steps"`
92
-
}
93
-
94
-
type Pipeline_Workflow_Environment_Elem struct {
95
-
Key string `json:"key" cborgen:"key"`
96
-
Value string `json:"value" cborgen:"value"`
90
+
Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"`
91
+
Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"`
92
+
Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"`
93
+
Name string `json:"name" cborgen:"name"`
94
+
Steps []*Pipeline_Step `json:"steps" cborgen:"steps"`
97
95
}
+2
api/tangled/tangledrepo.go
+2
api/tangled/tangledrepo.go
···
27
27
Owner string `json:"owner" cborgen:"owner"`
28
28
// source: source of the repo
29
29
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
// spindle: CI runner to send jobs to and receive results from
31
+
Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"`
30
32
}
+22
api/tangled/tangledspindle.go
+22
api/tangled/tangledspindle.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.spindle
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
SpindleNSID = "sh.tangled.spindle"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.spindle", &Spindle{})
17
+
} //
18
+
// RECORDTYPE: Spindle
19
+
type Spindle struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.spindle" cborgen:"$type,const=sh.tangled.spindle"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
}
+11
-10
appview/config/config.go
+11
-10
appview/config/config.go
···
25
25
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
26
26
}
27
27
28
-
type KnotstreamConfig struct {
28
+
type ConsumerConfig struct {
29
29
RetryInterval time.Duration `env:"RETRY_INTERVAL, default=60s"`
30
30
MaxRetryInterval time.Duration `env:"MAX_RETRY_INTERVAL, default=120m"`
31
31
ConnectionTimeout time.Duration `env:"CONNECTION_TIMEOUT, default=5s"`
···
74
74
}
75
75
76
76
type Config struct {
77
-
Core CoreConfig `env:",prefix=TANGLED_"`
78
-
Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"`
79
-
Knotstream KnotstreamConfig `env:",prefix=TANGLED_KNOTSTREAM_"`
80
-
Resend ResendConfig `env:",prefix=TANGLED_RESEND_"`
81
-
Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"`
82
-
Camo CamoConfig `env:",prefix=TANGLED_CAMO_"`
83
-
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
84
-
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
85
-
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
77
+
Core CoreConfig `env:",prefix=TANGLED_"`
78
+
Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"`
79
+
Knotstream ConsumerConfig `env:",prefix=TANGLED_KNOTSTREAM_"`
80
+
Spindlestream ConsumerConfig `env:",prefix=TANGLED_SPINDLESTREAM_"`
81
+
Resend ResendConfig `env:",prefix=TANGLED_RESEND_"`
82
+
Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"`
83
+
Camo CamoConfig `env:",prefix=TANGLED_CAMO_"`
84
+
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
+
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
+
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
86
87
}
87
88
88
89
func LoadConfig(ctx context.Context) (*Config, error) {
+3
-3
appview/db/artifact.go
+3
-3
appview/db/artifact.go
···
27
27
}
28
28
29
29
func (a *Artifact) ArtifactAt() syntax.ATURI {
30
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoPullNSID, a.Rkey))
30
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
31
31
}
32
32
33
33
func AddArtifact(e Execer, artifact Artifact) error {
···
64
64
var args []any
65
65
for _, filter := range filters {
66
66
conditions = append(conditions, filter.Condition())
67
-
args = append(args, filter.arg)
67
+
args = append(args, filter.Arg()...)
68
68
}
69
69
70
70
whereClause := ""
···
135
135
var args []any
136
136
for _, filter := range filters {
137
137
conditions = append(conditions, filter.Condition())
138
-
args = append(args, filter.arg)
138
+
args = append(args, filter.Arg()...)
139
139
}
140
140
141
141
whereClause := ""
+150
-23
appview/db/db.go
+150
-23
appview/db/db.go
···
5
5
"database/sql"
6
6
"fmt"
7
7
"log"
8
+
"reflect"
9
+
"strings"
8
10
9
11
_ "github.com/mattn/go-sqlite3"
10
12
)
···
197
199
unique(starred_by_did, repo_at)
198
200
);
199
201
202
+
create table if not exists reactions (
203
+
id integer primary key autoincrement,
204
+
reacted_by_did text not null,
205
+
thread_at text not null,
206
+
kind text not null,
207
+
rkey text not null,
208
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
209
+
unique(reacted_by_did, thread_at, kind)
210
+
);
211
+
200
212
create table if not exists emails (
201
213
id integer primary key autoincrement,
202
214
did text not null,
···
321
333
primary key (did, date)
322
334
);
323
335
336
+
create table if not exists spindles (
337
+
id integer primary key autoincrement,
338
+
owner text not null,
339
+
instance text not null,
340
+
verified text, -- time of verification
341
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
342
+
343
+
unique(owner, instance)
344
+
);
345
+
346
+
create table if not exists spindle_members (
347
+
-- identifiers for the record
348
+
id integer primary key autoincrement,
349
+
did text not null,
350
+
rkey text not null,
351
+
352
+
-- data
353
+
instance text not null,
354
+
subject text not null,
355
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
356
+
357
+
-- constraints
358
+
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
359
+
unique (did, instance, subject)
360
+
);
361
+
362
+
create table if not exists pipelines (
363
+
-- identifiers
364
+
id integer primary key autoincrement,
365
+
knot text not null,
366
+
rkey text not null,
367
+
368
+
repo_owner text not null,
369
+
repo_name text not null,
370
+
371
+
-- every pipeline must be associated with exactly one commit
372
+
sha text not null check (length(sha) = 40),
373
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
374
+
375
+
-- trigger data
376
+
trigger_id integer not null,
377
+
378
+
unique(knot, rkey),
379
+
foreign key (trigger_id) references triggers(id) on delete cascade
380
+
);
381
+
382
+
create table if not exists triggers (
383
+
-- primary key
384
+
id integer primary key autoincrement,
385
+
386
+
-- top-level fields
387
+
kind text not null,
388
+
389
+
-- pushTriggerData fields
390
+
push_ref text,
391
+
push_new_sha text check (length(push_new_sha) = 40),
392
+
push_old_sha text check (length(push_old_sha) = 40),
393
+
394
+
-- pullRequestTriggerData fields
395
+
pr_source_branch text,
396
+
pr_target_branch text,
397
+
pr_source_sha text check (length(pr_source_sha) = 40),
398
+
pr_action text
399
+
);
400
+
401
+
create table if not exists pipeline_statuses (
402
+
-- identifiers
403
+
id integer primary key autoincrement,
404
+
spindle text not null,
405
+
rkey text not null,
406
+
407
+
-- referenced pipeline. these form the (did, rkey) pair
408
+
pipeline_knot text not null,
409
+
pipeline_rkey text not null,
410
+
411
+
-- content
412
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
413
+
workflow text not null,
414
+
status text not null,
415
+
error text,
416
+
exit_code integer not null default 0,
417
+
418
+
unique (spindle, rkey),
419
+
foreign key (pipeline_knot, pipeline_rkey)
420
+
references pipelines (knot, rkey)
421
+
on delete cascade
422
+
);
423
+
324
424
create table if not exists migrations (
325
425
id integer primary key autoincrement,
326
426
name text unique
327
-
)
427
+
);
328
428
`)
329
429
if err != nil {
330
430
return nil, err
···
455
555
})
456
556
db.Exec("pragma foreign_keys = on;")
457
557
558
+
// run migrations
559
+
runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error {
560
+
tx.Exec(`
561
+
alter table repos add column spindle text;
562
+
`)
563
+
return nil
564
+
})
565
+
458
566
return &DB{db}, nil
459
567
}
460
568
···
507
615
cmp string
508
616
}
509
617
510
-
func FilterEq(key string, arg any) filter {
618
+
func newFilter(key, cmp string, arg any) filter {
511
619
return filter{
512
620
key: key,
513
621
arg: arg,
514
-
cmp: "=",
622
+
cmp: cmp,
515
623
}
516
624
}
517
625
518
-
func FilterNotEq(key string, arg any) filter {
519
-
return filter{
520
-
key: key,
521
-
arg: arg,
522
-
cmp: "<>",
626
+
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
627
+
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
628
+
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
629
+
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
630
+
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
631
+
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
632
+
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
633
+
634
+
func (f filter) Condition() string {
635
+
rv := reflect.ValueOf(f.arg)
636
+
kind := rv.Kind()
637
+
638
+
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
639
+
if kind == reflect.Slice || kind == reflect.Array {
640
+
if rv.Len() == 0 {
641
+
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
642
+
}
643
+
644
+
placeholders := make([]string, rv.Len())
645
+
for i := range placeholders {
646
+
placeholders[i] = "?"
647
+
}
648
+
649
+
return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
523
650
}
651
+
652
+
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
524
653
}
525
654
526
-
func FilterGte(key string, arg any) filter {
527
-
return filter{
528
-
key: key,
529
-
arg: arg,
530
-
cmp: ">=",
531
-
}
532
-
}
655
+
func (f filter) Arg() []any {
656
+
rv := reflect.ValueOf(f.arg)
657
+
kind := rv.Kind()
658
+
if kind == reflect.Slice || kind == reflect.Array {
659
+
if rv.Len() == 0 {
660
+
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
661
+
}
533
662
534
-
func FilterLte(key string, arg any) filter {
535
-
return filter{
536
-
key: key,
537
-
arg: arg,
538
-
cmp: "<=",
663
+
out := make([]any, rv.Len())
664
+
for i := range rv.Len() {
665
+
out[i] = rv.Index(i).Interface()
666
+
}
667
+
return out
539
668
}
540
-
}
541
669
542
-
func (f filter) Condition() string {
543
-
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
670
+
return []any{f.arg}
544
671
}
+2
-2
appview/db/issues.go
+2
-2
appview/db/issues.go
···
277
277
}
278
278
279
279
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
280
-
query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
280
+
query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
281
281
row := e.QueryRow(query, repoAt, issueId)
282
282
283
283
var issue Issue
284
284
var createdAt string
285
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
285
+
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
286
286
if err != nil {
287
287
return nil, nil, err
288
288
}
+487
appview/db/pipeline.go
+487
appview/db/pipeline.go
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"slices"
6
+
"strings"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/go-git/go-git/v5/plumbing"
11
+
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
+
"tangled.sh/tangled.sh/core/workflow"
13
+
)
14
+
15
+
type Pipeline struct {
16
+
Id int
17
+
Rkey string
18
+
Knot string
19
+
RepoOwner syntax.DID
20
+
RepoName string
21
+
TriggerId int
22
+
Sha string
23
+
Created time.Time
24
+
25
+
// populate when querying for reverse mappings
26
+
Trigger *Trigger
27
+
Statuses map[string]WorkflowStatus
28
+
}
29
+
30
+
type WorkflowStatus struct {
31
+
Data []PipelineStatus
32
+
}
33
+
34
+
func (w WorkflowStatus) Latest() PipelineStatus {
35
+
return w.Data[len(w.Data)-1]
36
+
}
37
+
38
+
// time taken by this workflow to reach an "end state"
39
+
func (w WorkflowStatus) TimeTaken() time.Duration {
40
+
var start, end *time.Time
41
+
for _, s := range w.Data {
42
+
if s.Status.IsStart() {
43
+
start = &s.Created
44
+
}
45
+
if s.Status.IsFinish() {
46
+
end = &s.Created
47
+
}
48
+
}
49
+
50
+
if start != nil && end != nil && end.After(*start) {
51
+
return end.Sub(*start)
52
+
}
53
+
54
+
return 0
55
+
}
56
+
57
+
func (p Pipeline) Counts() map[string]int {
58
+
m := make(map[string]int)
59
+
for _, w := range p.Statuses {
60
+
m[w.Latest().Status.String()] += 1
61
+
}
62
+
return m
63
+
}
64
+
65
+
func (p Pipeline) TimeTaken() time.Duration {
66
+
var s time.Duration
67
+
for _, w := range p.Statuses {
68
+
s += w.TimeTaken()
69
+
}
70
+
return s
71
+
}
72
+
73
+
func (p Pipeline) Workflows() []string {
74
+
var ws []string
75
+
for v := range p.Statuses {
76
+
ws = append(ws, v)
77
+
}
78
+
slices.Sort(ws)
79
+
return ws
80
+
}
81
+
82
+
// if we know that a spindle has picked up this pipeline, then it is Responding
83
+
func (p Pipeline) IsResponding() bool {
84
+
return len(p.Statuses) != 0
85
+
}
86
+
87
+
type Trigger struct {
88
+
Id int
89
+
Kind workflow.TriggerKind
90
+
91
+
// push trigger fields
92
+
PushRef *string
93
+
PushNewSha *string
94
+
PushOldSha *string
95
+
96
+
// pull request trigger fields
97
+
PRSourceBranch *string
98
+
PRTargetBranch *string
99
+
PRSourceSha *string
100
+
PRAction *string
101
+
}
102
+
103
+
func (t *Trigger) IsPush() bool {
104
+
return t != nil && t.Kind == workflow.TriggerKindPush
105
+
}
106
+
107
+
func (t *Trigger) IsPullRequest() bool {
108
+
return t != nil && t.Kind == workflow.TriggerKindPullRequest
109
+
}
110
+
111
+
func (t *Trigger) TargetRef() string {
112
+
if t.IsPush() {
113
+
return plumbing.ReferenceName(*t.PushRef).Short()
114
+
} else if t.IsPullRequest() {
115
+
return *t.PRTargetBranch
116
+
}
117
+
118
+
return ""
119
+
}
120
+
121
+
type PipelineStatus struct {
122
+
ID int
123
+
Spindle string
124
+
Rkey string
125
+
PipelineKnot string
126
+
PipelineRkey string
127
+
Created time.Time
128
+
Workflow string
129
+
Status spindle.StatusKind
130
+
Error *string
131
+
ExitCode int
132
+
}
133
+
134
+
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
135
+
var pipelines []Pipeline
136
+
137
+
var conditions []string
138
+
var args []any
139
+
for _, filter := range filters {
140
+
conditions = append(conditions, filter.Condition())
141
+
args = append(args, filter.Arg()...)
142
+
}
143
+
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
147
+
}
148
+
149
+
query := fmt.Sprintf(`select id, rkey, knot, repo_owner, repo_name, sha, created from pipelines %s`, whereClause)
150
+
151
+
rows, err := e.Query(query, args...)
152
+
153
+
if err != nil {
154
+
return nil, err
155
+
}
156
+
defer rows.Close()
157
+
158
+
for rows.Next() {
159
+
var pipeline Pipeline
160
+
var createdAt string
161
+
err = rows.Scan(
162
+
&pipeline.Id,
163
+
&pipeline.Rkey,
164
+
&pipeline.Knot,
165
+
&pipeline.RepoOwner,
166
+
&pipeline.RepoName,
167
+
&pipeline.Sha,
168
+
&createdAt,
169
+
)
170
+
if err != nil {
171
+
return nil, err
172
+
}
173
+
174
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
175
+
pipeline.Created = t
176
+
}
177
+
178
+
pipelines = append(pipelines, pipeline)
179
+
}
180
+
181
+
if err = rows.Err(); err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
return pipelines, nil
186
+
}
187
+
188
+
func AddPipeline(e Execer, pipeline Pipeline) error {
189
+
args := []any{
190
+
pipeline.Rkey,
191
+
pipeline.Knot,
192
+
pipeline.RepoOwner,
193
+
pipeline.RepoName,
194
+
pipeline.TriggerId,
195
+
pipeline.Sha,
196
+
}
197
+
198
+
placeholders := make([]string, len(args))
199
+
for i := range placeholders {
200
+
placeholders[i] = "?"
201
+
}
202
+
203
+
query := fmt.Sprintf(`
204
+
insert or ignore into pipelines (
205
+
rkey,
206
+
knot,
207
+
repo_owner,
208
+
repo_name,
209
+
trigger_id,
210
+
sha
211
+
) values (%s)
212
+
`, strings.Join(placeholders, ","))
213
+
214
+
_, err := e.Exec(query, args...)
215
+
216
+
return err
217
+
}
218
+
219
+
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
220
+
args := []any{
221
+
trigger.Kind,
222
+
trigger.PushRef,
223
+
trigger.PushNewSha,
224
+
trigger.PushOldSha,
225
+
trigger.PRSourceBranch,
226
+
trigger.PRTargetBranch,
227
+
trigger.PRSourceSha,
228
+
trigger.PRAction,
229
+
}
230
+
231
+
placeholders := make([]string, len(args))
232
+
for i := range placeholders {
233
+
placeholders[i] = "?"
234
+
}
235
+
236
+
query := fmt.Sprintf(`insert or ignore into triggers (
237
+
kind,
238
+
push_ref,
239
+
push_new_sha,
240
+
push_old_sha,
241
+
pr_source_branch,
242
+
pr_target_branch,
243
+
pr_source_sha,
244
+
pr_action
245
+
) values (%s)`, strings.Join(placeholders, ","))
246
+
247
+
res, err := e.Exec(query, args...)
248
+
if err != nil {
249
+
return 0, err
250
+
}
251
+
252
+
return res.LastInsertId()
253
+
}
254
+
255
+
func AddPipelineStatus(e Execer, status PipelineStatus) error {
256
+
args := []any{
257
+
status.Spindle,
258
+
status.Rkey,
259
+
status.PipelineKnot,
260
+
status.PipelineRkey,
261
+
status.Workflow,
262
+
status.Status,
263
+
status.Error,
264
+
status.ExitCode,
265
+
status.Created.Format(time.RFC3339),
266
+
}
267
+
268
+
placeholders := make([]string, len(args))
269
+
for i := range placeholders {
270
+
placeholders[i] = "?"
271
+
}
272
+
273
+
query := fmt.Sprintf(`
274
+
insert or ignore into pipeline_statuses (
275
+
spindle,
276
+
rkey,
277
+
pipeline_knot,
278
+
pipeline_rkey,
279
+
workflow,
280
+
status,
281
+
error,
282
+
exit_code,
283
+
created
284
+
) values (%s)
285
+
`, strings.Join(placeholders, ","))
286
+
287
+
_, err := e.Exec(query, args...)
288
+
return err
289
+
}
290
+
291
+
// this is a mega query, but the most useful one:
292
+
// get N pipelines, for each one get the latest status of its N workflows
293
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
294
+
var conditions []string
295
+
var args []any
296
+
for _, filter := range filters {
297
+
filter.key = "p." + filter.key // the table is aliased in the query to `p`
298
+
conditions = append(conditions, filter.Condition())
299
+
args = append(args, filter.Arg()...)
300
+
}
301
+
302
+
whereClause := ""
303
+
if conditions != nil {
304
+
whereClause = " where " + strings.Join(conditions, " and ")
305
+
}
306
+
307
+
query := fmt.Sprintf(`
308
+
select
309
+
p.id,
310
+
p.knot,
311
+
p.rkey,
312
+
p.repo_owner,
313
+
p.repo_name,
314
+
p.sha,
315
+
p.created,
316
+
t.id,
317
+
t.kind,
318
+
t.push_ref,
319
+
t.push_new_sha,
320
+
t.push_old_sha,
321
+
t.pr_source_branch,
322
+
t.pr_target_branch,
323
+
t.pr_source_sha,
324
+
t.pr_action
325
+
from
326
+
pipelines p
327
+
join
328
+
triggers t ON p.trigger_id = t.id
329
+
%s
330
+
`, whereClause)
331
+
332
+
rows, err := e.Query(query, args...)
333
+
if err != nil {
334
+
return nil, err
335
+
}
336
+
defer rows.Close()
337
+
338
+
pipelines := make(map[string]Pipeline)
339
+
for rows.Next() {
340
+
var p Pipeline
341
+
var t Trigger
342
+
var created string
343
+
344
+
err := rows.Scan(
345
+
&p.Id,
346
+
&p.Knot,
347
+
&p.Rkey,
348
+
&p.RepoOwner,
349
+
&p.RepoName,
350
+
&p.Sha,
351
+
&created,
352
+
&p.TriggerId,
353
+
&t.Kind,
354
+
&t.PushRef,
355
+
&t.PushNewSha,
356
+
&t.PushOldSha,
357
+
&t.PRSourceBranch,
358
+
&t.PRTargetBranch,
359
+
&t.PRSourceSha,
360
+
&t.PRAction,
361
+
)
362
+
if err != nil {
363
+
return nil, err
364
+
}
365
+
366
+
p.Created, err = time.Parse(time.RFC3339, created)
367
+
if err != nil {
368
+
return nil, fmt.Errorf("invalid pipeline created timestamp %q: %w", created, err)
369
+
}
370
+
371
+
t.Id = p.TriggerId
372
+
p.Trigger = &t
373
+
p.Statuses = make(map[string]WorkflowStatus)
374
+
375
+
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
376
+
pipelines[k] = p
377
+
}
378
+
379
+
// get all statuses
380
+
// the where clause here is of the form:
381
+
//
382
+
// where (pipeline_knot = k1 and pipeline_rkey = r1)
383
+
// or (pipeline_knot = k2 and pipeline_rkey = r2)
384
+
conditions = nil
385
+
args = nil
386
+
for _, p := range pipelines {
387
+
knotFilter := FilterEq("pipeline_knot", p.Knot)
388
+
rkeyFilter := FilterEq("pipeline_rkey", p.Rkey)
389
+
conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition()))
390
+
args = append(args, p.Knot)
391
+
args = append(args, p.Rkey)
392
+
}
393
+
whereClause = ""
394
+
if conditions != nil {
395
+
whereClause = "where " + strings.Join(conditions, " or ")
396
+
}
397
+
query = fmt.Sprintf(`
398
+
select
399
+
id, spindle, rkey, pipeline_knot, pipeline_rkey, created, workflow, status, error, exit_code
400
+
from
401
+
pipeline_statuses
402
+
%s
403
+
`, whereClause)
404
+
405
+
rows, err = e.Query(query, args...)
406
+
if err != nil {
407
+
return nil, err
408
+
}
409
+
defer rows.Close()
410
+
411
+
for rows.Next() {
412
+
var ps PipelineStatus
413
+
var created string
414
+
415
+
err := rows.Scan(
416
+
&ps.ID,
417
+
&ps.Spindle,
418
+
&ps.Rkey,
419
+
&ps.PipelineKnot,
420
+
&ps.PipelineRkey,
421
+
&created,
422
+
&ps.Workflow,
423
+
&ps.Status,
424
+
&ps.Error,
425
+
&ps.ExitCode,
426
+
)
427
+
if err != nil {
428
+
return nil, err
429
+
}
430
+
431
+
ps.Created, err = time.Parse(time.RFC3339, created)
432
+
if err != nil {
433
+
return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err)
434
+
}
435
+
436
+
key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey)
437
+
438
+
// extract
439
+
pipeline, ok := pipelines[key]
440
+
if !ok {
441
+
continue
442
+
}
443
+
statuses, _ := pipeline.Statuses[ps.Workflow]
444
+
if !ok {
445
+
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
446
+
}
447
+
448
+
// append
449
+
statuses.Data = append(statuses.Data, ps)
450
+
451
+
// reassign
452
+
pipeline.Statuses[ps.Workflow] = statuses
453
+
pipelines[key] = pipeline
454
+
}
455
+
456
+
var all []Pipeline
457
+
for _, p := range pipelines {
458
+
for _, s := range p.Statuses {
459
+
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
460
+
if a.Created.After(b.Created) {
461
+
return 1
462
+
}
463
+
if a.Created.Before(b.Created) {
464
+
return -1
465
+
}
466
+
if a.ID > b.ID {
467
+
return 1
468
+
}
469
+
if a.ID < b.ID {
470
+
return -1
471
+
}
472
+
return 0
473
+
})
474
+
}
475
+
all = append(all, p)
476
+
}
477
+
478
+
// sort pipelines by date
479
+
slices.SortFunc(all, func(a, b Pipeline) int {
480
+
if a.Created.After(b.Created) {
481
+
return -1
482
+
}
483
+
return 1
484
+
})
485
+
486
+
return all, nil
487
+
}
+9
-3
appview/db/pulls.go
+9
-3
appview/db/pulls.go
···
87
87
if p.PullSource != nil {
88
88
s := p.PullSource.AsRecord()
89
89
source = &s
90
+
source.Sha = p.LatestSha()
90
91
}
91
92
92
93
record := tangled.RepoPull{
···
162
163
func (p *Pull) LatestPatch() string {
163
164
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
165
return latestSubmission.Patch
166
+
}
167
+
168
+
func (p *Pull) LatestSha() string {
169
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
170
+
return latestSubmission.SourceRev
165
171
}
166
172
167
173
func (p *Pull) PullAt() syntax.ATURI {
···
311
317
var args []any
312
318
for _, filter := range filters {
313
319
conditions = append(conditions, filter.Condition())
314
-
args = append(args, filter.arg)
320
+
args = append(args, filter.Arg()...)
315
321
}
316
322
317
323
whereClause := ""
···
866
872
867
873
for _, filter := range filters {
868
874
conditions = append(conditions, filter.Condition())
869
-
args = append(args, filter.arg)
875
+
args = append(args, filter.Arg()...)
870
876
}
871
877
872
878
whereClause := ""
···
891
897
892
898
for _, filter := range filters {
893
899
conditions = append(conditions, filter.Condition())
894
-
args = append(args, filter.arg)
900
+
args = append(args, filter.Arg()...)
895
901
}
896
902
897
903
whereClause := ""
+1
-1
appview/db/punchcard.go
+1
-1
appview/db/punchcard.go
+141
appview/db/reaction.go
+141
appview/db/reaction.go
···
1
+
package db
2
+
3
+
import (
4
+
"log"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
)
9
+
10
+
type ReactionKind string
11
+
12
+
const (
13
+
Like ReactionKind = "๐"
14
+
Unlike = "๐"
15
+
Laugh = "๐"
16
+
Celebration = "๐"
17
+
Confused = "๐ซค"
18
+
Heart = "โค๏ธ"
19
+
Rocket = "๐"
20
+
Eyes = "๐"
21
+
)
22
+
23
+
func (rk ReactionKind) String() string {
24
+
return string(rk)
25
+
}
26
+
27
+
var OrderedReactionKinds = []ReactionKind{
28
+
Like,
29
+
Unlike,
30
+
Laugh,
31
+
Celebration,
32
+
Confused,
33
+
Heart,
34
+
Rocket,
35
+
Eyes,
36
+
}
37
+
38
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
39
+
k, ok := (map[string]ReactionKind{
40
+
"๐": Like,
41
+
"๐": Unlike,
42
+
"๐": Laugh,
43
+
"๐": Celebration,
44
+
"๐ซค": Confused,
45
+
"โค๏ธ": Heart,
46
+
"๐": Rocket,
47
+
"๐": Eyes,
48
+
})[raw]
49
+
return k, ok
50
+
}
51
+
52
+
type Reaction struct {
53
+
ReactedByDid string
54
+
ThreadAt syntax.ATURI
55
+
Created time.Time
56
+
Rkey string
57
+
Kind ReactionKind
58
+
}
59
+
60
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
61
+
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
62
+
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
63
+
return err
64
+
}
65
+
66
+
// Get a reaction record
67
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
68
+
query := `
69
+
select reacted_by_did, thread_at, created, rkey
70
+
from reactions
71
+
where reacted_by_did = ? and thread_at = ? and kind = ?`
72
+
row := e.QueryRow(query, reactedByDid, threadAt, kind)
73
+
74
+
var reaction Reaction
75
+
var created string
76
+
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
77
+
if err != nil {
78
+
return nil, err
79
+
}
80
+
81
+
createdAtTime, err := time.Parse(time.RFC3339, created)
82
+
if err != nil {
83
+
log.Println("unable to determine followed at time")
84
+
reaction.Created = time.Now()
85
+
} else {
86
+
reaction.Created = createdAtTime
87
+
}
88
+
89
+
return &reaction, nil
90
+
}
91
+
92
+
// Remove a reaction
93
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
94
+
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
95
+
return err
96
+
}
97
+
98
+
// Remove a reaction
99
+
func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error {
100
+
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey)
101
+
return err
102
+
}
103
+
104
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
105
+
count := 0
106
+
err := e.QueryRow(
107
+
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
108
+
if err != nil {
109
+
return 0, err
110
+
}
111
+
return count, nil
112
+
}
113
+
114
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
115
+
countMap := map[ReactionKind]int{}
116
+
for _, kind := range OrderedReactionKinds {
117
+
count, err := GetReactionCount(e, threadAt, kind)
118
+
if err != nil {
119
+
return map[ReactionKind]int{}, nil
120
+
}
121
+
countMap[kind] = count
122
+
}
123
+
return countMap, nil
124
+
}
125
+
126
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
127
+
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
128
+
return false
129
+
} else {
130
+
return true
131
+
}
132
+
}
133
+
134
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
135
+
statusMap := map[ReactionKind]bool{}
136
+
for _, kind := range OrderedReactionKinds {
137
+
count := GetReactionStatus(e, userDid, threadAt, kind)
138
+
statusMap[kind] = count
139
+
}
140
+
return statusMap
141
+
}
+210
-7
appview/db/repos.go
+210
-7
appview/db/repos.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
"strings"
6
7
"time"
7
8
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
18
19
Created time.Time
19
20
AtUri string
20
21
Description string
22
+
Spindle string
21
23
22
24
// optionally, populate this when querying for reverse mappings
23
25
RepoStats *RepoStats
···
69
71
return repos, nil
70
72
}
71
73
74
+
func GetRepos(e Execer, filters ...filter) ([]Repo, error) {
75
+
repoMap := make(map[syntax.ATURI]Repo)
76
+
77
+
var conditions []string
78
+
var args []any
79
+
for _, filter := range filters {
80
+
conditions = append(conditions, filter.Condition())
81
+
args = append(args, filter.Arg()...)
82
+
}
83
+
84
+
whereClause := ""
85
+
if conditions != nil {
86
+
whereClause = " where " + strings.Join(conditions, " and ")
87
+
}
88
+
89
+
repoQuery := fmt.Sprintf(
90
+
`select
91
+
did,
92
+
name,
93
+
knot,
94
+
rkey,
95
+
created,
96
+
description,
97
+
source,
98
+
spindle
99
+
from
100
+
repos r
101
+
%s`,
102
+
whereClause,
103
+
)
104
+
rows, err := e.Query(repoQuery, args...)
105
+
106
+
if err != nil {
107
+
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
108
+
}
109
+
110
+
for rows.Next() {
111
+
var repo Repo
112
+
var createdAt string
113
+
var description, source, spindle sql.NullString
114
+
115
+
err := rows.Scan(
116
+
&repo.Did,
117
+
&repo.Name,
118
+
&repo.Knot,
119
+
&repo.Rkey,
120
+
&createdAt,
121
+
&description,
122
+
&source,
123
+
&spindle,
124
+
)
125
+
if err != nil {
126
+
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
127
+
}
128
+
129
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
130
+
repo.Created = t
131
+
}
132
+
if description.Valid {
133
+
repo.Description = description.String
134
+
}
135
+
if source.Valid {
136
+
repo.Source = source.String
137
+
}
138
+
if spindle.Valid {
139
+
repo.Spindle = spindle.String
140
+
}
141
+
142
+
repoMap[repo.RepoAt()] = repo
143
+
}
144
+
145
+
if err = rows.Err(); err != nil {
146
+
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
147
+
}
148
+
149
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
150
+
args = make([]any, len(repoMap))
151
+
for _, r := range repoMap {
152
+
args = append(args, r.RepoAt())
153
+
}
154
+
155
+
starCountQuery := fmt.Sprintf(
156
+
`select
157
+
repo_at, count(1)
158
+
from stars
159
+
where repo_at in (%s)
160
+
group by repo_at`,
161
+
inClause,
162
+
)
163
+
rows, err = e.Query(starCountQuery, args...)
164
+
if err != nil {
165
+
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
166
+
}
167
+
for rows.Next() {
168
+
var repoat string
169
+
var count int
170
+
if err := rows.Scan(&repoat, &count); err != nil {
171
+
continue
172
+
}
173
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
174
+
r.RepoStats.StarCount = count
175
+
}
176
+
}
177
+
if err = rows.Err(); err != nil {
178
+
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
179
+
}
180
+
181
+
issueCountQuery := fmt.Sprintf(
182
+
`select
183
+
repo_at,
184
+
count(case when open = 1 then 1 end) as open_count,
185
+
count(case when open = 0 then 1 end) as closed_count
186
+
from issues
187
+
where repo_at in (%s)
188
+
group by repo_at`,
189
+
inClause,
190
+
)
191
+
rows, err = e.Query(issueCountQuery, args...)
192
+
if err != nil {
193
+
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
194
+
}
195
+
for rows.Next() {
196
+
var repoat string
197
+
var open, closed int
198
+
if err := rows.Scan(&repoat, &open, &closed); err != nil {
199
+
continue
200
+
}
201
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
202
+
r.RepoStats.IssueCount.Open = open
203
+
r.RepoStats.IssueCount.Closed = closed
204
+
}
205
+
}
206
+
if err = rows.Err(); err != nil {
207
+
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
208
+
}
209
+
210
+
pullCountQuery := fmt.Sprintf(
211
+
`select
212
+
repo_at,
213
+
count(case when state = ? then 1 end) as open_count,
214
+
count(case when state = ? then 1 end) as merged_count,
215
+
count(case when state = ? then 1 end) as closed_count,
216
+
count(case when state = ? then 1 end) as deleted_count
217
+
from pulls
218
+
where repo_at in (%s)
219
+
group by repo_at`,
220
+
inClause,
221
+
)
222
+
args = append([]any{
223
+
PullOpen,
224
+
PullMerged,
225
+
PullClosed,
226
+
PullDeleted,
227
+
}, args...)
228
+
rows, err = e.Query(
229
+
pullCountQuery,
230
+
args...,
231
+
)
232
+
if err != nil {
233
+
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
234
+
}
235
+
for rows.Next() {
236
+
var repoat string
237
+
var open, merged, closed, deleted int
238
+
if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
239
+
continue
240
+
}
241
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
242
+
r.RepoStats.PullCount.Open = open
243
+
r.RepoStats.PullCount.Merged = merged
244
+
r.RepoStats.PullCount.Closed = closed
245
+
r.RepoStats.PullCount.Deleted = deleted
246
+
}
247
+
}
248
+
if err = rows.Err(); err != nil {
249
+
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
250
+
}
251
+
252
+
var repos []Repo
253
+
for _, r := range repoMap {
254
+
repos = append(repos, r)
255
+
}
256
+
257
+
return repos, nil
258
+
}
259
+
72
260
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
73
261
var repos []Repo
74
262
···
138
326
139
327
func GetRepo(e Execer, did, name string) (*Repo, error) {
140
328
var repo Repo
141
-
var nullableDescription sql.NullString
329
+
var description, spindle sql.NullString
142
330
143
-
row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where did = ? and name = ?`, did, name)
331
+
row := e.QueryRow(`
332
+
select did, name, knot, created, at_uri, description, spindle
333
+
from repos
334
+
where did = ? and name = ?
335
+
`,
336
+
did,
337
+
name,
338
+
)
144
339
145
340
var createdAt string
146
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil {
341
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil {
147
342
return nil, err
148
343
}
149
344
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
150
345
repo.Created = createdAtTime
151
346
152
-
if nullableDescription.Valid {
153
-
repo.Description = nullableDescription.String
154
-
} else {
155
-
repo.Description = ""
347
+
if description.Valid {
348
+
repo.Description = description.String
349
+
}
350
+
351
+
if spindle.Valid {
352
+
repo.Spindle = spindle.String
156
353
}
157
354
158
355
return &repo, nil
···
302
499
func UpdateDescription(e Execer, repoAt, newDescription string) error {
303
500
_, err := e.Exec(
304
501
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
502
+
return err
503
+
}
504
+
505
+
func UpdateSpindle(e Execer, repoAt, spindle string) error {
506
+
_, err := e.Exec(
507
+
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
305
508
return err
306
509
}
307
510
+232
appview/db/spindle.go
+232
appview/db/spindle.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"strings"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
type Spindle struct {
13
+
Id int
14
+
Owner syntax.DID
15
+
Instance string
16
+
Verified *time.Time
17
+
Created time.Time
18
+
}
19
+
20
+
type SpindleMember struct {
21
+
Id int
22
+
Did syntax.DID // owner of the record
23
+
Rkey string // rkey of the record
24
+
Instance string
25
+
Subject syntax.DID // the member being added
26
+
Created time.Time
27
+
}
28
+
29
+
func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
30
+
var spindles []Spindle
31
+
32
+
var conditions []string
33
+
var args []any
34
+
for _, filter := range filters {
35
+
conditions = append(conditions, filter.Condition())
36
+
args = append(args, filter.Arg()...)
37
+
}
38
+
39
+
whereClause := ""
40
+
if conditions != nil {
41
+
whereClause = " where " + strings.Join(conditions, " and ")
42
+
}
43
+
44
+
query := fmt.Sprintf(
45
+
`select id, owner, instance, verified, created
46
+
from spindles
47
+
%s
48
+
order by created
49
+
`,
50
+
whereClause,
51
+
)
52
+
53
+
rows, err := e.Query(query, args...)
54
+
55
+
if err != nil {
56
+
return nil, err
57
+
}
58
+
defer rows.Close()
59
+
60
+
for rows.Next() {
61
+
var spindle Spindle
62
+
var createdAt string
63
+
var verified sql.NullString
64
+
65
+
if err := rows.Scan(
66
+
&spindle.Id,
67
+
&spindle.Owner,
68
+
&spindle.Instance,
69
+
&verified,
70
+
&createdAt,
71
+
); err != nil {
72
+
return nil, err
73
+
}
74
+
75
+
spindle.Created, err = time.Parse(time.RFC3339, createdAt)
76
+
if err != nil {
77
+
spindle.Created = time.Now()
78
+
}
79
+
80
+
if verified.Valid {
81
+
t, err := time.Parse(time.RFC3339, verified.String)
82
+
if err != nil {
83
+
now := time.Now()
84
+
spindle.Verified = &now
85
+
}
86
+
spindle.Verified = &t
87
+
}
88
+
89
+
spindles = append(spindles, spindle)
90
+
}
91
+
92
+
return spindles, nil
93
+
}
94
+
95
+
// if there is an existing spindle with the same instance, this returns an error
96
+
func AddSpindle(e Execer, spindle Spindle) error {
97
+
_, err := e.Exec(
98
+
`insert into spindles (owner, instance) values (?, ?)`,
99
+
spindle.Owner,
100
+
spindle.Instance,
101
+
)
102
+
return err
103
+
}
104
+
105
+
func VerifySpindle(e Execer, filters ...filter) (int64, error) {
106
+
var conditions []string
107
+
var args []any
108
+
for _, filter := range filters {
109
+
conditions = append(conditions, filter.Condition())
110
+
args = append(args, filter.Arg()...)
111
+
}
112
+
113
+
whereClause := ""
114
+
if conditions != nil {
115
+
whereClause = " where " + strings.Join(conditions, " and ")
116
+
}
117
+
118
+
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
119
+
120
+
res, err := e.Exec(query, args...)
121
+
if err != nil {
122
+
return 0, err
123
+
}
124
+
125
+
return res.RowsAffected()
126
+
}
127
+
128
+
func DeleteSpindle(e Execer, filters ...filter) error {
129
+
var conditions []string
130
+
var args []any
131
+
for _, filter := range filters {
132
+
conditions = append(conditions, filter.Condition())
133
+
args = append(args, filter.Arg()...)
134
+
}
135
+
136
+
whereClause := ""
137
+
if conditions != nil {
138
+
whereClause = " where " + strings.Join(conditions, " and ")
139
+
}
140
+
141
+
query := fmt.Sprintf(`delete from spindles %s`, whereClause)
142
+
143
+
_, err := e.Exec(query, args...)
144
+
return err
145
+
}
146
+
147
+
func AddSpindleMember(e Execer, member SpindleMember) error {
148
+
_, err := e.Exec(
149
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
150
+
member.Did,
151
+
member.Rkey,
152
+
member.Instance,
153
+
member.Subject,
154
+
)
155
+
return err
156
+
}
157
+
158
+
func RemoveSpindleMember(e Execer, filters ...filter) error {
159
+
var conditions []string
160
+
var args []any
161
+
for _, filter := range filters {
162
+
conditions = append(conditions, filter.Condition())
163
+
args = append(args, filter.Arg()...)
164
+
}
165
+
166
+
whereClause := ""
167
+
if conditions != nil {
168
+
whereClause = " where " + strings.Join(conditions, " and ")
169
+
}
170
+
171
+
query := fmt.Sprintf(`delete from spindle_members %s`, whereClause)
172
+
173
+
_, err := e.Exec(query, args...)
174
+
return err
175
+
}
176
+
177
+
func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
178
+
var members []SpindleMember
179
+
180
+
var conditions []string
181
+
var args []any
182
+
for _, filter := range filters {
183
+
conditions = append(conditions, filter.Condition())
184
+
args = append(args, filter.Arg()...)
185
+
}
186
+
187
+
whereClause := ""
188
+
if conditions != nil {
189
+
whereClause = " where " + strings.Join(conditions, " and ")
190
+
}
191
+
192
+
query := fmt.Sprintf(
193
+
`select id, did, rkey, instance, subject, created
194
+
from spindle_members
195
+
%s
196
+
order by created
197
+
`,
198
+
whereClause,
199
+
)
200
+
201
+
rows, err := e.Query(query, args...)
202
+
203
+
if err != nil {
204
+
return nil, err
205
+
}
206
+
defer rows.Close()
207
+
208
+
for rows.Next() {
209
+
var member SpindleMember
210
+
var createdAt string
211
+
212
+
if err := rows.Scan(
213
+
&member.Id,
214
+
&member.Did,
215
+
&member.Rkey,
216
+
&member.Instance,
217
+
&member.Subject,
218
+
&createdAt,
219
+
); err != nil {
220
+
return nil, err
221
+
}
222
+
223
+
member.Created, err = time.Parse(time.RFC3339, createdAt)
224
+
if err != nil {
225
+
member.Created = time.Now()
226
+
}
227
+
228
+
members = append(members, member)
229
+
}
230
+
231
+
return members, nil
232
+
}
+9
appview/idresolver/resolver.go
+9
appview/idresolver/resolver.go
+286
-42
appview/ingester.go
+286
-42
appview/ingester.go
···
4
4
"context"
5
5
"encoding/json"
6
6
"fmt"
7
-
"log"
7
+
"log/slog"
8
8
"time"
9
9
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
···
12
12
"github.com/go-git/go-git/v5/plumbing"
13
13
"github.com/ipfs/go-cid"
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
+
"tangled.sh/tangled.sh/core/appview/config"
15
16
"tangled.sh/tangled.sh/core/appview/db"
17
+
"tangled.sh/tangled.sh/core/appview/idresolver"
18
+
"tangled.sh/tangled.sh/core/appview/spindleverify"
16
19
"tangled.sh/tangled.sh/core/rbac"
17
20
)
18
21
19
-
type Ingester func(ctx context.Context, e *models.Event) error
22
+
type Ingester struct {
23
+
Db db.DbWrapper
24
+
Enforcer *rbac.Enforcer
25
+
IdResolver *idresolver.Resolver
26
+
Config *config.Config
27
+
Logger *slog.Logger
28
+
}
29
+
30
+
type processFunc func(ctx context.Context, e *models.Event) error
20
31
21
-
func Ingest(d db.DbWrapper, enforcer *rbac.Enforcer) Ingester {
32
+
func (i *Ingester) Ingest() processFunc {
22
33
return func(ctx context.Context, e *models.Event) error {
23
34
var err error
24
35
defer func() {
25
36
eventTime := e.TimeUS
26
37
lastTimeUs := eventTime + 1
27
-
if err := d.SaveLastTimeUs(lastTimeUs); err != nil {
38
+
if err := i.Db.SaveLastTimeUs(lastTimeUs); err != nil {
28
39
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
29
40
}
30
41
}()
31
42
32
-
if e.Kind != models.EventKindCommit {
33
-
return nil
43
+
l := i.Logger.With("kind", e.Kind)
44
+
switch e.Kind {
45
+
case models.EventKindAccount:
46
+
if !e.Account.Active && *e.Account.Status == "deactivated" {
47
+
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
48
+
}
49
+
case models.EventKindIdentity:
50
+
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
51
+
case models.EventKindCommit:
52
+
switch e.Commit.Collection {
53
+
case tangled.GraphFollowNSID:
54
+
err = i.ingestFollow(e)
55
+
case tangled.FeedStarNSID:
56
+
err = i.ingestStar(e)
57
+
case tangled.PublicKeyNSID:
58
+
err = i.ingestPublicKey(e)
59
+
case tangled.RepoArtifactNSID:
60
+
err = i.ingestArtifact(e)
61
+
case tangled.ActorProfileNSID:
62
+
err = i.ingestProfile(e)
63
+
case tangled.SpindleMemberNSID:
64
+
err = i.ingestSpindleMember(e)
65
+
case tangled.SpindleNSID:
66
+
err = i.ingestSpindle(e)
67
+
}
68
+
l = i.Logger.With("nsid", e.Commit.Collection)
34
69
}
35
70
36
-
switch e.Commit.Collection {
37
-
case tangled.GraphFollowNSID:
38
-
ingestFollow(&d, e)
39
-
case tangled.FeedStarNSID:
40
-
ingestStar(&d, e)
41
-
case tangled.PublicKeyNSID:
42
-
ingestPublicKey(&d, e)
43
-
case tangled.RepoArtifactNSID:
44
-
ingestArtifact(&d, e, enforcer)
45
-
case tangled.ActorProfileNSID:
46
-
ingestProfile(&d, e)
71
+
if err != nil {
72
+
l.Error("error ingesting record", "err", err)
47
73
}
48
74
49
75
return err
50
76
}
51
77
}
52
78
53
-
func ingestStar(d *db.DbWrapper, e *models.Event) error {
79
+
func (i *Ingester) ingestStar(e *models.Event) error {
54
80
var err error
55
81
did := e.Did
56
82
83
+
l := i.Logger.With("handler", "ingestStar")
84
+
l = l.With("nsid", e.Commit.Collection)
85
+
57
86
switch e.Commit.Operation {
58
87
case models.CommitOperationCreate, models.CommitOperationUpdate:
59
88
var subjectUri syntax.ATURI
···
62
91
record := tangled.FeedStar{}
63
92
err := json.Unmarshal(raw, &record)
64
93
if err != nil {
65
-
log.Println("invalid record")
94
+
l.Error("invalid record", "err", err)
66
95
return err
67
96
}
68
97
69
98
subjectUri, err = syntax.ParseATURI(record.Subject)
70
99
if err != nil {
71
-
log.Println("invalid record")
100
+
l.Error("invalid record", "err", err)
72
101
return err
73
102
}
74
-
err = db.AddStar(d, did, subjectUri, e.Commit.RKey)
103
+
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
75
104
case models.CommitOperationDelete:
76
-
err = db.DeleteStarByRkey(d, did, e.Commit.RKey)
105
+
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
77
106
}
78
107
79
108
if err != nil {
···
83
112
return nil
84
113
}
85
114
86
-
func ingestFollow(d *db.DbWrapper, e *models.Event) error {
115
+
func (i *Ingester) ingestFollow(e *models.Event) error {
87
116
var err error
88
117
did := e.Did
89
118
119
+
l := i.Logger.With("handler", "ingestFollow")
120
+
l = l.With("nsid", e.Commit.Collection)
121
+
90
122
switch e.Commit.Operation {
91
123
case models.CommitOperationCreate, models.CommitOperationUpdate:
92
124
raw := json.RawMessage(e.Commit.Record)
93
125
record := tangled.GraphFollow{}
94
126
err = json.Unmarshal(raw, &record)
95
127
if err != nil {
96
-
log.Println("invalid record")
128
+
l.Error("invalid record", "err", err)
97
129
return err
98
130
}
99
131
100
132
subjectDid := record.Subject
101
-
err = db.AddFollow(d, did, subjectDid, e.Commit.RKey)
133
+
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
102
134
case models.CommitOperationDelete:
103
-
err = db.DeleteFollowByRkey(d, did, e.Commit.RKey)
135
+
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
104
136
}
105
137
106
138
if err != nil {
···
110
142
return nil
111
143
}
112
144
113
-
func ingestPublicKey(d *db.DbWrapper, e *models.Event) error {
145
+
func (i *Ingester) ingestPublicKey(e *models.Event) error {
114
146
did := e.Did
115
147
var err error
116
148
149
+
l := i.Logger.With("handler", "ingestPublicKey")
150
+
l = l.With("nsid", e.Commit.Collection)
151
+
117
152
switch e.Commit.Operation {
118
153
case models.CommitOperationCreate, models.CommitOperationUpdate:
119
-
log.Println("processing add of pubkey")
154
+
l.Debug("processing add of pubkey")
120
155
raw := json.RawMessage(e.Commit.Record)
121
156
record := tangled.PublicKey{}
122
157
err = json.Unmarshal(raw, &record)
123
158
if err != nil {
124
-
log.Printf("invalid record: %s", err)
159
+
l.Error("invalid record", "err", err)
125
160
return err
126
161
}
127
162
128
163
name := record.Name
129
164
key := record.Key
130
-
err = db.AddPublicKey(d, did, name, key, e.Commit.RKey)
165
+
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
131
166
case models.CommitOperationDelete:
132
-
log.Println("processing delete of pubkey")
133
-
err = db.DeletePublicKeyByRkey(d, did, e.Commit.RKey)
167
+
l.Debug("processing delete of pubkey")
168
+
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
134
169
}
135
170
136
171
if err != nil {
···
140
175
return nil
141
176
}
142
177
143
-
func ingestArtifact(d *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error {
178
+
func (i *Ingester) ingestArtifact(e *models.Event) error {
144
179
did := e.Did
145
180
var err error
146
181
182
+
l := i.Logger.With("handler", "ingestArtifact")
183
+
l = l.With("nsid", e.Commit.Collection)
184
+
147
185
switch e.Commit.Operation {
148
186
case models.CommitOperationCreate, models.CommitOperationUpdate:
149
187
raw := json.RawMessage(e.Commit.Record)
150
188
record := tangled.RepoArtifact{}
151
189
err = json.Unmarshal(raw, &record)
152
190
if err != nil {
153
-
log.Printf("invalid record: %s", err)
191
+
l.Error("invalid record", "err", err)
154
192
return err
155
193
}
156
194
···
159
197
return err
160
198
}
161
199
162
-
repo, err := db.GetRepoByAtUri(d, repoAt.String())
200
+
repo, err := db.GetRepoByAtUri(i.Db, repoAt.String())
163
201
if err != nil {
164
202
return err
165
203
}
166
204
167
-
ok, err := enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push")
205
+
ok, err := i.Enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push")
168
206
if err != nil || !ok {
169
207
return err
170
208
}
···
186
224
MimeType: record.Artifact.MimeType,
187
225
}
188
226
189
-
err = db.AddArtifact(d, artifact)
227
+
err = db.AddArtifact(i.Db, artifact)
190
228
case models.CommitOperationDelete:
191
-
err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
229
+
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
192
230
}
193
231
194
232
if err != nil {
···
198
236
return nil
199
237
}
200
238
201
-
func ingestProfile(d *db.DbWrapper, e *models.Event) error {
239
+
func (i *Ingester) ingestProfile(e *models.Event) error {
202
240
did := e.Did
203
241
var err error
204
242
243
+
l := i.Logger.With("handler", "ingestProfile")
244
+
l = l.With("nsid", e.Commit.Collection)
245
+
205
246
if e.Commit.RKey != "self" {
206
247
return fmt.Errorf("ingestProfile only ingests `self` record")
207
248
}
···
212
253
record := tangled.ActorProfile{}
213
254
err = json.Unmarshal(raw, &record)
214
255
if err != nil {
215
-
log.Printf("invalid record: %s", err)
256
+
l.Error("invalid record", "err", err)
216
257
return err
217
258
}
218
259
···
259
300
PinnedRepos: pinned,
260
301
}
261
302
262
-
ddb, ok := d.Execer.(*db.DB)
303
+
ddb, ok := i.Db.Execer.(*db.DB)
263
304
if !ok {
264
305
return fmt.Errorf("failed to index profile record, invalid db cast")
265
306
}
···
276
317
277
318
err = db.UpsertProfile(tx, &profile)
278
319
case models.CommitOperationDelete:
279
-
err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
320
+
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
280
321
}
281
322
282
323
if err != nil {
···
285
326
286
327
return nil
287
328
}
329
+
330
+
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
331
+
did := e.Did
332
+
var err error
333
+
334
+
l := i.Logger.With("handler", "ingestSpindleMember")
335
+
l = l.With("nsid", e.Commit.Collection)
336
+
337
+
switch e.Commit.Operation {
338
+
case models.CommitOperationCreate:
339
+
raw := json.RawMessage(e.Commit.Record)
340
+
record := tangled.SpindleMember{}
341
+
err = json.Unmarshal(raw, &record)
342
+
if err != nil {
343
+
l.Error("invalid record", "err", err)
344
+
return err
345
+
}
346
+
347
+
// only spindle owner can invite to spindles
348
+
ok, err := i.Enforcer.IsSpindleInviteAllowed(did, record.Instance)
349
+
if err != nil || !ok {
350
+
return fmt.Errorf("failed to enforce permissions: %w", err)
351
+
}
352
+
353
+
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
354
+
if err != nil {
355
+
return err
356
+
}
357
+
358
+
if memberId.Handle.IsInvalidHandle() {
359
+
return err
360
+
}
361
+
362
+
ddb, ok := i.Db.Execer.(*db.DB)
363
+
if !ok {
364
+
return fmt.Errorf("failed to index profile record, invalid db cast")
365
+
}
366
+
367
+
err = db.AddSpindleMember(ddb, db.SpindleMember{
368
+
Did: syntax.DID(did),
369
+
Rkey: e.Commit.RKey,
370
+
Instance: record.Instance,
371
+
Subject: memberId.DID,
372
+
})
373
+
if !ok {
374
+
return fmt.Errorf("failed to add to db: %w", err)
375
+
}
376
+
377
+
err = i.Enforcer.AddSpindleMember(record.Instance, memberId.DID.String())
378
+
if err != nil {
379
+
return fmt.Errorf("failed to update ACLs: %w", err)
380
+
}
381
+
case models.CommitOperationDelete:
382
+
rkey := e.Commit.RKey
383
+
384
+
ddb, ok := i.Db.Execer.(*db.DB)
385
+
if !ok {
386
+
return fmt.Errorf("failed to index profile record, invalid db cast")
387
+
}
388
+
389
+
// get record from db first
390
+
members, err := db.GetSpindleMembers(
391
+
ddb,
392
+
db.FilterEq("did", did),
393
+
db.FilterEq("rkey", rkey),
394
+
)
395
+
if err != nil || len(members) != 1 {
396
+
return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members))
397
+
}
398
+
member := members[0]
399
+
400
+
tx, err := ddb.Begin()
401
+
if err != nil {
402
+
return fmt.Errorf("failed to start txn: %w", err)
403
+
}
404
+
405
+
// remove record by rkey && update enforcer
406
+
if err = db.RemoveSpindleMember(
407
+
tx,
408
+
db.FilterEq("did", did),
409
+
db.FilterEq("rkey", rkey),
410
+
); err != nil {
411
+
return fmt.Errorf("failed to remove from db: %w", err)
412
+
}
413
+
414
+
// update enforcer
415
+
err = i.Enforcer.RemoveSpindleMember(member.Instance, member.Subject.String())
416
+
if err != nil {
417
+
return fmt.Errorf("failed to update ACLs: %w", err)
418
+
}
419
+
420
+
if err = tx.Commit(); err != nil {
421
+
return fmt.Errorf("failed to commit txn: %w", err)
422
+
}
423
+
424
+
if err = i.Enforcer.E.SavePolicy(); err != nil {
425
+
return fmt.Errorf("failed to save ACLs: %w", err)
426
+
}
427
+
}
428
+
429
+
return nil
430
+
}
431
+
432
+
func (i *Ingester) ingestSpindle(e *models.Event) error {
433
+
did := e.Did
434
+
var err error
435
+
436
+
l := i.Logger.With("handler", "ingestSpindle")
437
+
l = l.With("nsid", e.Commit.Collection)
438
+
439
+
switch e.Commit.Operation {
440
+
case models.CommitOperationCreate:
441
+
raw := json.RawMessage(e.Commit.Record)
442
+
record := tangled.Spindle{}
443
+
err = json.Unmarshal(raw, &record)
444
+
if err != nil {
445
+
l.Error("invalid record", "err", err)
446
+
return err
447
+
}
448
+
449
+
instance := e.Commit.RKey
450
+
451
+
ddb, ok := i.Db.Execer.(*db.DB)
452
+
if !ok {
453
+
return fmt.Errorf("failed to index profile record, invalid db cast")
454
+
}
455
+
456
+
err := db.AddSpindle(ddb, db.Spindle{
457
+
Owner: syntax.DID(did),
458
+
Instance: instance,
459
+
})
460
+
if err != nil {
461
+
l.Error("failed to add spindle to db", "err", err, "instance", instance)
462
+
return err
463
+
}
464
+
465
+
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
466
+
if err != nil {
467
+
l.Error("failed to add spindle to db", "err", err, "instance", instance)
468
+
return err
469
+
}
470
+
471
+
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
472
+
if err != nil {
473
+
return fmt.Errorf("failed to mark verified: %w", err)
474
+
}
475
+
476
+
return nil
477
+
478
+
case models.CommitOperationDelete:
479
+
instance := e.Commit.RKey
480
+
481
+
ddb, ok := i.Db.Execer.(*db.DB)
482
+
if !ok {
483
+
return fmt.Errorf("failed to index profile record, invalid db cast")
484
+
}
485
+
486
+
// get record from db first
487
+
spindles, err := db.GetSpindles(
488
+
ddb,
489
+
db.FilterEq("owner", did),
490
+
db.FilterEq("instance", instance),
491
+
)
492
+
if err != nil || len(spindles) != 1 {
493
+
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
494
+
}
495
+
496
+
tx, err := ddb.Begin()
497
+
if err != nil {
498
+
return err
499
+
}
500
+
defer func() {
501
+
tx.Rollback()
502
+
i.Enforcer.E.LoadPolicy()
503
+
}()
504
+
505
+
err = db.DeleteSpindle(
506
+
tx,
507
+
db.FilterEq("owner", did),
508
+
db.FilterEq("instance", instance),
509
+
)
510
+
if err != nil {
511
+
return err
512
+
}
513
+
514
+
err = i.Enforcer.RemoveSpindle(instance)
515
+
if err != nil {
516
+
return err
517
+
}
518
+
519
+
err = tx.Commit()
520
+
if err != nil {
521
+
return err
522
+
}
523
+
524
+
err = i.Enforcer.E.SavePolicy()
525
+
if err != nil {
526
+
return err
527
+
}
528
+
}
529
+
530
+
return nil
531
+
}
+16
appview/issues/issues.go
+16
appview/issues/issues.go
···
11
11
12
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
13
"github.com/bluesky-social/indigo/atproto/data"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
15
lexutil "github.com/bluesky-social/indigo/lex/util"
15
16
"github.com/go-chi/chi/v5"
16
17
"github.com/posthog/posthog-go"
···
79
80
return
80
81
}
81
82
83
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt))
84
+
if err != nil {
85
+
log.Println("failed to get issue reactions")
86
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
87
+
}
88
+
89
+
userReactions := map[db.ReactionKind]bool{}
90
+
if user != nil {
91
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt))
92
+
}
93
+
82
94
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
83
95
if err != nil {
84
96
log.Println("failed to resolve issue owner", err)
···
106
118
107
119
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
108
120
DidHandleMap: didHandleMap,
121
+
122
+
OrderedReactionKinds: db.OrderedReactionKinds,
123
+
Reactions: reactionCountMap,
124
+
UserReacted: userReactions,
109
125
})
110
126
111
127
}
+2
-1
appview/middleware/middleware.go
+2
-1
appview/middleware/middleware.go
···
192
192
if err != nil {
193
193
// invalid did or handle
194
194
log.Println("failed to resolve did/handle:", err)
195
-
w.WriteHeader(http.StatusNotFound)
195
+
mw.pages.Error404(w)
196
196
return
197
197
}
198
198
···
225
225
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
226
226
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
227
227
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
228
+
ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle)
228
229
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
229
230
next.ServeHTTP(w, req.WithContext(ctx))
230
231
})
+1
-1
appview/oauth/handler/handler.go
+1
-1
appview/oauth/handler/handler.go
···
336
336
defaultKnot := "knot1.tangled.sh"
337
337
338
338
log.Printf("adding %s to default knot", did)
339
-
err := o.enforcer.AddMember(defaultKnot, did)
339
+
err := o.enforcer.AddKnotMember(defaultKnot, did)
340
340
if err != nil {
341
341
log.Println("failed to add user to knot1.tangled.sh: ", err)
342
342
return
+92
-7
appview/pages/funcmap.go
+92
-7
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
+
"crypto/hmac"
5
+
"crypto/sha256"
6
+
"encoding/hex"
4
7
"errors"
5
8
"fmt"
6
9
"html"
···
19
22
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
23
)
21
24
22
-
func funcMap() template.FuncMap {
25
+
func (p *Pages) funcMap() template.FuncMap {
23
26
return template.FuncMap{
24
27
"split": func(s string) []string {
25
28
return strings.Split(s, "\n")
···
49
52
"sub": func(a, b int) int {
50
53
return a - b
51
54
},
55
+
"f64": func(a int) float64 {
56
+
return float64(a)
57
+
},
58
+
"addf64": func(a, b float64) float64 {
59
+
return a + b
60
+
},
61
+
"subf64": func(a, b float64) float64 {
62
+
return a - b
63
+
},
64
+
"mulf64": func(a, b float64) float64 {
65
+
return a * b
66
+
},
67
+
"divf64": func(a, b float64) float64 {
68
+
if b == 0 {
69
+
return 0
70
+
}
71
+
return a / b
72
+
},
73
+
"negf64": func(a float64) float64 {
74
+
return -a
75
+
},
52
76
"cond": func(cond interface{}, a, b string) string {
53
77
if cond == nil {
54
78
return b
···
81
105
s = append(s, values...)
82
106
return s
83
107
},
84
-
"timeFmt": humanize.Time,
85
-
"longTimeFmt": func(t time.Time) string {
86
-
return t.Format("2006-01-02 * 3:04 PM")
87
-
},
88
-
"commaFmt": humanize.Comma,
89
-
"shortTimeFmt": func(t time.Time) string {
108
+
"commaFmt": humanize.Comma,
109
+
"relTimeFmt": humanize.Time,
110
+
"shortRelTimeFmt": func(t time.Time) string {
90
111
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
91
112
{time.Second, "now", time.Second},
92
113
{2 * time.Second, "1s %s", 1},
···
105
126
{math.MaxInt64, "a long while %s", 1},
106
127
})
107
128
},
129
+
"longTimeFmt": func(t time.Time) string {
130
+
return t.Format("Jan 2, 2006, 3:04 PM MST")
131
+
},
132
+
"iso8601DateTimeFmt": func(t time.Time) string {
133
+
return t.Format("2006-01-02T15:04:05-07:00")
134
+
},
135
+
"iso8601DurationFmt": func(duration time.Duration) string {
136
+
days := int64(duration.Hours() / 24)
137
+
hours := int64(math.Mod(duration.Hours(), 24))
138
+
minutes := int64(math.Mod(duration.Minutes(), 60))
139
+
seconds := int64(math.Mod(duration.Seconds(), 60))
140
+
return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds)
141
+
},
142
+
"durationFmt": func(duration time.Duration) string {
143
+
return durationFmt(duration, [4]string{"d", "hr", "min", "s"})
144
+
},
145
+
"longDurationFmt": func(duration time.Duration) string {
146
+
return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"})
147
+
},
108
148
"byteFmt": humanize.Bytes,
109
149
"length": func(slice any) int {
110
150
v := reflect.ValueOf(slice)
···
178
218
}
179
219
return dict, nil
180
220
},
221
+
"deref": func(v any) any {
222
+
val := reflect.ValueOf(v)
223
+
if val.Kind() == reflect.Ptr && !val.IsNil() {
224
+
return val.Elem().Interface()
225
+
}
226
+
return nil
227
+
},
181
228
"i": func(name string, classes ...string) template.HTML {
182
229
data, err := icon(name, classes)
183
230
if err != nil {
···
192
239
u, _ := url.PathUnescape(s)
193
240
return u
194
241
},
242
+
243
+
"tinyAvatar": p.tinyAvatar,
195
244
}
196
245
}
197
246
247
+
func (p *Pages) tinyAvatar(handle string) string {
248
+
handle = strings.TrimPrefix(handle, "@")
249
+
secret := p.avatar.SharedSecret
250
+
h := hmac.New(sha256.New, []byte(secret))
251
+
h.Write([]byte(handle))
252
+
signature := hex.EncodeToString(h.Sum(nil))
253
+
return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle)
254
+
}
255
+
198
256
func icon(name string, classes []string) (template.HTML, error) {
199
257
iconPath := filepath.Join("static", "icons", name)
200
258
···
220
278
modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
221
279
return template.HTML(modifiedSVG), nil
222
280
}
281
+
282
+
func durationFmt(duration time.Duration, names [4]string) string {
283
+
days := int64(duration.Hours() / 24)
284
+
hours := int64(math.Mod(duration.Hours(), 24))
285
+
minutes := int64(math.Mod(duration.Minutes(), 60))
286
+
seconds := int64(math.Mod(duration.Seconds(), 60))
287
+
288
+
chunks := []struct {
289
+
name string
290
+
amount int64
291
+
}{
292
+
{names[0], days},
293
+
{names[1], hours},
294
+
{names[2], minutes},
295
+
{names[3], seconds},
296
+
}
297
+
298
+
parts := []string{}
299
+
300
+
for _, chunk := range chunks {
301
+
if chunk.amount != 0 {
302
+
parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
303
+
}
304
+
}
305
+
306
+
return strings.Join(parts, " ")
307
+
}
+127
-23
appview/pages/pages.go
+127
-23
appview/pages/pages.go
···
40
40
41
41
type Pages struct {
42
42
t map[string]*template.Template
43
+
avatar config.AvatarConfig
43
44
dev bool
44
45
embedFS embed.FS
45
46
templateDir string // Path to templates on disk for dev mode
···
57
58
p := &Pages{
58
59
t: make(map[string]*template.Template),
59
60
dev: config.Core.Dev,
61
+
avatar: config.Avatar,
60
62
embedFS: Files,
61
63
rctx: rctx,
62
64
templateDir: "appview/pages",
···
90
92
name := strings.TrimPrefix(path, "templates/")
91
93
name = strings.TrimSuffix(name, ".html")
92
94
tmpl, err := template.New(name).
93
-
Funcs(funcMap()).
95
+
Funcs(p.funcMap()).
94
96
ParseFS(p.embedFS, path)
95
97
if err != nil {
96
98
log.Fatalf("setting up fragment: %v", err)
···
131
133
allPaths = append(allPaths, fragmentPaths...)
132
134
allPaths = append(allPaths, path)
133
135
tmpl, err := template.New(name).
134
-
Funcs(funcMap()).
136
+
Funcs(p.funcMap()).
135
137
ParseFS(p.embedFS, allPaths...)
136
138
if err != nil {
137
139
return fmt.Errorf("setting up template: %w", err)
···
185
187
}
186
188
187
189
// Create a new template
188
-
tmpl := template.New(name).Funcs(funcMap())
190
+
tmpl := template.New(name).Funcs(p.funcMap())
189
191
190
192
// Parse layouts
191
193
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
···
291
293
return p.execute("knot", w, params)
292
294
}
293
295
296
+
type SpindlesParams struct {
297
+
LoggedInUser *oauth.User
298
+
Spindles []db.Spindle
299
+
}
300
+
301
+
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
302
+
return p.execute("spindles/index", w, params)
303
+
}
304
+
305
+
type SpindleListingParams struct {
306
+
db.Spindle
307
+
}
308
+
309
+
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
310
+
return p.executePlain("spindles/fragments/spindleListing", w, params)
311
+
}
312
+
313
+
type SpindleDashboardParams struct {
314
+
LoggedInUser *oauth.User
315
+
Spindle db.Spindle
316
+
Members []string
317
+
Repos map[string][]db.Repo
318
+
DidHandleMap map[string]string
319
+
}
320
+
321
+
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
322
+
return p.execute("spindles/dashboard", w, params)
323
+
}
324
+
294
325
type NewRepoParams struct {
295
326
LoggedInUser *oauth.User
296
327
Knots []string
···
417
448
Raw bool
418
449
EmailToDidOrHandle map[string]string
419
450
VerifiedCommits commitverify.VerifiedCommits
420
-
Languages *types.RepoLanguageResponse
451
+
Languages []types.RepoLanguageDetails
452
+
Pipelines map[string]db.Pipeline
421
453
types.RepoIndexResponse
422
454
}
423
455
···
456
488
Active string
457
489
EmailToDidOrHandle map[string]string
458
490
VerifiedCommits commitverify.VerifiedCommits
491
+
Pipelines map[string]db.Pipeline
459
492
}
460
493
461
494
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
468
501
RepoInfo repoinfo.RepoInfo
469
502
Active string
470
503
EmailToDidOrHandle map[string]string
504
+
Pipeline *db.Pipeline
471
505
472
506
// singular because it's always going to be just one
473
507
VerifiedCommit commitverify.VerifiedCommits
···
616
650
}
617
651
618
652
type RepoSettingsParams struct {
619
-
LoggedInUser *oauth.User
620
-
RepoInfo repoinfo.RepoInfo
621
-
Collaborators []Collaborator
622
-
Active string
623
-
Branches []types.Branch
653
+
LoggedInUser *oauth.User
654
+
RepoInfo repoinfo.RepoInfo
655
+
Collaborators []Collaborator
656
+
Active string
657
+
Branches []types.Branch
658
+
Spindles []string
659
+
CurrentSpindle string
624
660
// TODO: use repoinfo.roles
625
661
IsCollaboratorInviteAllowed bool
626
662
}
···
654
690
IssueOwnerHandle string
655
691
DidHandleMap map[string]string
656
692
693
+
OrderedReactionKinds []db.ReactionKind
694
+
Reactions map[db.ReactionKind]int
695
+
UserReacted map[db.ReactionKind]bool
696
+
657
697
State string
698
+
}
699
+
700
+
type ThreadReactionFragmentParams struct {
701
+
ThreadAt syntax.ATURI
702
+
Kind db.ReactionKind
703
+
Count int
704
+
IsReacted bool
705
+
}
706
+
707
+
func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
708
+
return p.executePlain("repo/fragments/reaction", w, params)
658
709
}
659
710
660
711
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
761
812
AbandonedPulls []*db.Pull
762
813
MergeCheck types.MergeCheckResponse
763
814
ResubmitCheck ResubmitResult
815
+
Pipelines map[string]db.Pipeline
816
+
817
+
OrderedReactionKinds []db.ReactionKind
818
+
Reactions map[db.ReactionKind]int
819
+
UserReacted map[db.ReactionKind]bool
764
820
}
765
821
766
822
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
769
825
}
770
826
771
827
type RepoPullPatchParams struct {
772
-
LoggedInUser *oauth.User
773
-
DidHandleMap map[string]string
774
-
RepoInfo repoinfo.RepoInfo
775
-
Pull *db.Pull
776
-
Stack db.Stack
777
-
Diff *types.NiceDiff
778
-
Round int
779
-
Submission *db.PullSubmission
828
+
LoggedInUser *oauth.User
829
+
DidHandleMap map[string]string
830
+
RepoInfo repoinfo.RepoInfo
831
+
Pull *db.Pull
832
+
Stack db.Stack
833
+
Diff *types.NiceDiff
834
+
Round int
835
+
Submission *db.PullSubmission
836
+
OrderedReactionKinds []db.ReactionKind
780
837
}
781
838
782
839
// this name is a mouthful
···
785
842
}
786
843
787
844
type RepoPullInterdiffParams struct {
788
-
LoggedInUser *oauth.User
789
-
DidHandleMap map[string]string
790
-
RepoInfo repoinfo.RepoInfo
791
-
Pull *db.Pull
792
-
Round int
793
-
Interdiff *patchutil.InterdiffResult
845
+
LoggedInUser *oauth.User
846
+
DidHandleMap map[string]string
847
+
RepoInfo repoinfo.RepoInfo
848
+
Pull *db.Pull
849
+
Round int
850
+
Interdiff *patchutil.InterdiffResult
851
+
OrderedReactionKinds []db.ReactionKind
794
852
}
795
853
796
854
// this name is a mouthful
···
926
984
927
985
func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error {
928
986
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
987
+
}
988
+
989
+
type PipelinesParams struct {
990
+
LoggedInUser *oauth.User
991
+
RepoInfo repoinfo.RepoInfo
992
+
Pipelines []db.Pipeline
993
+
Active string
994
+
}
995
+
996
+
func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
997
+
params.Active = "pipelines"
998
+
return p.executeRepo("repo/pipelines/pipelines", w, params)
999
+
}
1000
+
1001
+
type LogBlockParams struct {
1002
+
Id int
1003
+
Name string
1004
+
Command string
1005
+
Collapsed bool
1006
+
}
1007
+
1008
+
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1009
+
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1010
+
}
1011
+
1012
+
type LogLineParams struct {
1013
+
Id int
1014
+
Content string
1015
+
}
1016
+
1017
+
func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
1018
+
return p.executePlain("repo/pipelines/fragments/logLine", w, params)
1019
+
}
1020
+
1021
+
type WorkflowParams struct {
1022
+
LoggedInUser *oauth.User
1023
+
RepoInfo repoinfo.RepoInfo
1024
+
Pipeline db.Pipeline
1025
+
Workflow string
1026
+
LogUrl string
1027
+
Active string
1028
+
}
1029
+
1030
+
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1031
+
params.Active = "pipelines"
1032
+
return p.executeRepo("repo/pipelines/workflow", w, params)
929
1033
}
930
1034
931
1035
func (p *Pages) Static() http.Handler {
+2
appview/pages/repoinfo/repoinfo.go
+2
appview/pages/repoinfo/repoinfo.go
···
40
40
{"overview", "/", "square-chart-gantt"},
41
41
{"issues", "/issues", "circle-dot"},
42
42
{"pulls", "/pulls", "git-pull-request"},
43
+
{"pipelines", "/pipelines", "layers-2"},
43
44
}
44
45
45
46
if r.Roles.SettingsAllowed() {
···
55
56
OwnerHandle string
56
57
Description string
57
58
Knot string
59
+
Spindle string
58
60
RepoAt syntax.ATURI
59
61
IsStarred bool
60
62
Stats db.RepoStats
+2
-2
appview/pages/templates/knot.html
+2
-2
appview/pages/templates/knot.html
···
26
26
</dd>
27
27
28
28
<dt class="font-bold">opened</dt>
29
-
<dd>{{ .Registration.Created | timeFmt }}</dd>
29
+
<dd>{{ template "repo/fragments/time" .Registration.Created }}</dd>
30
30
31
31
{{ if .Registration.Registered }}
32
32
<dt class="font-bold">registered</dt>
33
-
<dd>{{ .Registration.Registered | timeFmt }}</dd>
33
+
<dd>{{ template "repo/fragments/time" .Registration.Registered }}</dd>
34
34
{{ else }}
35
35
<dt class="font-bold">status</dt>
36
36
<dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block">
+4
-4
appview/pages/templates/knots.html
+4
-4
appview/pages/templates/knots.html
···
20
20
required
21
21
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
22
22
>
23
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
23
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit">
24
24
<span>generate key</span>
25
25
<span id="generate-knot-key-spinner" class="group">
26
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
26
+
{{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
27
</span>
28
28
</button>
29
29
<div id="settings-knots-error" class="error dark:text-red-400"></div>
···
44
44
</a>
45
45
</div>
46
46
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
47
-
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p>
47
+
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ template "repo/fragments/time" .Registered }}</p>
48
48
</div>
49
49
</div>
50
50
{{ end }}
···
70
70
</div>
71
71
</div>
72
72
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
73
-
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p>
73
+
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ template "repo/fragments/time" .Created }}</p>
74
74
</div>
75
75
<div class="flex gap-2 items-center">
76
76
<button
+1
appview/pages/templates/layouts/base.html
+1
appview/pages/templates/layouts/base.html
···
9
9
/>
10
10
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
11
11
<script src="/static/htmx.min.js"></script>
12
+
<script src="/static/htmx-ext-ws.min.js"></script>
12
13
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
14
15
{{ block "extrameta" . }}{{ end }}
+26
-23
appview/pages/templates/layouts/repobase.html
+26
-23
appview/pages/templates/layouts/repobase.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
-
{{ if .RepoInfo.Source }}
6
-
<p class="text-sm">
7
-
<div class="flex items-center">
8
-
{{ i "git-fork" "w-3 h-3 mr-1"}}
9
-
forked from
10
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
-
</div>
13
-
</p>
14
-
{{ end }}
15
-
<div class="text-lg flex items-center justify-between">
16
-
<div>
17
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
-
<span class="select-none">/</span>
19
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
-
</div>
4
+
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
+
{{ if .RepoInfo.Source }}
6
+
<p class="text-sm">
7
+
<div class="flex items-center">
8
+
{{ i "git-fork" "w-3 h-3 mr-1"}}
9
+
forked from
10
+
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
+
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
+
</div>
13
+
</p>
14
+
{{ end }}
15
+
<div class="text-lg flex items-center justify-between">
16
+
<div>
17
+
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
+
<span class="select-none">/</span>
19
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
+
</div>
21
+
22
+
{{ template "repo/fragments/repoActions" .RepoInfo }}
23
+
</div>
24
+
{{ template "repo/fragments/repoDescription" . }}
25
+
</section>
21
26
22
-
{{ template "repo/fragments/repoActions" .RepoInfo }}
23
-
</div>
24
-
{{ template "repo/fragments/repoDescription" . }}
25
-
</section>
26
-
<section class="min-h-screen flex flex-col drop-shadow-sm">
27
+
<section
28
+
class="min-h-screen w-full flex flex-col drop-shadow-sm"
29
+
>
27
30
<nav class="w-full pl-4 overflow-auto">
28
31
<div class="flex z-60">
29
32
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
61
64
</div>
62
65
</nav>
63
66
<section
64
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"
67
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
65
68
>
66
69
{{ block "repoContent" . }}{{ end }}
67
70
</section>
+12
-10
appview/pages/templates/layouts/topbar.html
+12
-10
appview/pages/templates/layouts/topbar.html
···
1
1
{{ define "layouts/topbar" }}
2
2
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
-
<div class="container flex justify-between p-0">
3
+
<div class="container flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
6
tangled<sub>alpha</sub>
···
19
19
{{ i "code" "size-4" }} source
20
20
</a>
21
21
</div>
22
-
<div id="right-items" class="flex gap-2">
22
+
<div id="right-items" class="flex items-center gap-4">
23
23
{{ with .LoggedInUser }}
24
-
<a href="/repo/new" hx-boost="true">
25
-
{{ i "plus" "w-6 h-6" }}
24
+
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
25
+
{{ i "plus" "w-4 h-4" }}
26
26
</a>
27
27
{{ block "dropDown" . }} {{ end }}
28
28
{{ else }}
···
36
36
{{ define "dropDown" }}
37
37
<details class="relative inline-block text-left">
38
38
<summary
39
-
class="cursor-pointer list-none"
39
+
class="cursor-pointer list-none flex items-center"
40
40
>
41
-
{{ didOrHandle .Did .Handle }}
41
+
{{ $user := didOrHandle .Did .Handle }}
42
+
{{ template "user/fragments/picHandleLink" $user }}
42
43
</summary>
43
44
<div
44
45
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
45
46
>
46
-
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
47
+
<a href="/{{ $user }}">profile</a>
47
48
<a href="/knots">knots</a>
49
+
<a href="/spindles">spindles</a>
48
50
<a href="/settings">settings</a>
49
-
<a href="#"
50
-
hx-post="/logout"
51
-
hx-swap="none"
51
+
<a href="#"
52
+
hx-post="/logout"
53
+
hx-swap="none"
52
54
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
53
55
logout
54
56
</a>
+2
-2
appview/pages/templates/repo/branches.html
+2
-2
appview/pages/templates/repo/branches.html
···
59
59
</td>
60
60
<td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
61
61
{{ if .Commit }}
62
-
{{ .Commit.Committer.When | timeFmt }}
62
+
{{ template "repo/fragments/time" .Commit.Committer.When }}
63
63
{{ end }}
64
64
</td>
65
65
</tr>
···
98
98
</a>
99
99
</span>
100
100
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
101
-
<span>{{ .Commit.Committer.When | timeFmt }}</span>
101
+
{{ template "repo/fragments/time" .Commit.Committer.When }}
102
102
</div>
103
103
{{ end }}
104
104
</div>
+8
-2
appview/pages/templates/repo/commit.html
+8
-2
appview/pages/templates/repo/commit.html
···
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
36
36
<span class="px-1 select-none before:content-['\00B7']"></span>
37
-
{{ timeFmt $commit.Author.When }}
37
+
{{ template "repo/fragments/time" $commit.Author.When }}
38
38
<span class="px-1 select-none before:content-['\00B7']"></span>
39
39
</p>
40
40
···
59
59
<div class="flex items-center gap-2 my-2">
60
60
{{ i "user" "w-4 h-4" }}
61
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a>
62
+
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
63
</div>
64
64
<div class="my-1 pt-2 text-xs border-t">
65
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
68
68
</div>
69
69
</div>
70
70
{{ end }}
71
+
72
+
<div class="text-sm">
73
+
{{ if $.Pipeline }}
74
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $.Pipeline "RepoInfo" $.RepoInfo) }}
75
+
{{ end }}
76
+
</div>
71
77
</div>
72
78
73
79
</section>
+19
-20
appview/pages/templates/repo/compare/new.html
+19
-20
appview/pages/templates/repo/compare/new.html
···
7
7
{{ end }}
8
8
9
9
{{ define "repoAfter" }}
10
-
<section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto">
11
-
<div class="flex flex-col items-center">
12
-
<p class="text-center text-black dark:text-white">
13
-
Recently updated branches in this repository:
14
-
</p>
15
-
{{ block "recentBranchList" $ }} {{ end }}
16
-
</div>
17
-
</section>
18
-
{{ end }}
19
-
20
-
{{ define "recentBranchList" }}
21
-
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
22
-
{{ range $br := take .Branches 5 }}
23
-
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
24
-
<div class="flex items-center justify-between p-2">
25
-
{{ $br.Name }}
26
-
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
10
+
{{ $brs := take .Branches 5 }}
11
+
{{ if $brs }}
12
+
<section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto">
13
+
<div class="flex flex-col items-center">
14
+
<p class="text-center text-black dark:text-white">
15
+
Recently updated branches in this repository:
16
+
</p>
17
+
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
18
+
{{ range $br := $brs }}
19
+
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
20
+
<div class="flex items-center justify-between p-2">
21
+
{{ $br.Name }}
22
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
23
+
</div>
24
+
</a>
25
+
{{ end }}
26
+
</div>
27
27
</div>
28
-
</a>
29
-
{{ end }}
30
-
</div>
28
+
</section>
29
+
{{ end }}
31
30
{{ end }}
+2
-2
appview/pages/templates/repo/empty.html
+2
-2
appview/pages/templates/repo/empty.html
···
14
14
</p>
15
15
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
16
16
{{ range $br := .BranchesTrunc }}
17
-
<a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name}}" class="no-underline hover:no-underline">
17
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline">
18
18
<div class="flex items-center justify-between p-2">
19
19
{{ $br.Name }}
20
-
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
20
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
21
21
</div>
22
22
</a>
23
23
{{ end }}
+2
-2
appview/pages/templates/repo/fragments/artifact.html
+2
-2
appview/pages/templates/repo/fragments/artifact.html
···
10
10
</div>
11
11
12
12
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
13
-
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span>
14
-
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span>
13
+
<span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span>
14
+
<span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span>
15
15
16
16
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
17
17
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
+34
appview/pages/templates/repo/fragments/reaction.html
+34
appview/pages/templates/repo/fragments/reaction.html
···
1
+
{{ define "repo/fragments/reaction" }}
2
+
<button
3
+
id="reactIndi-{{ .Kind }}"
4
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
+
leading-4 px-3 gap-1
6
+
{{ if eq .Count 0 }}
7
+
hidden
8
+
{{ end }}
9
+
{{ if .IsReacted }}
10
+
bg-sky-100
11
+
border-sky-400
12
+
dark:bg-sky-900
13
+
dark:border-sky-500
14
+
{{ else }}
15
+
border-gray-200
16
+
hover:bg-gray-50
17
+
hover:border-gray-300
18
+
dark:border-gray-700
19
+
dark:hover:bg-gray-700
20
+
dark:hover:border-gray-600
21
+
{{ end }}
22
+
"
23
+
{{ if .IsReacted }}
24
+
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
25
+
{{ else }}
26
+
hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
27
+
{{ end }}
28
+
hx-swap="outerHTML"
29
+
hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})"
30
+
hx-disabled-elt="this"
31
+
>
32
+
<span>{{ .Kind }}</span> <span>{{ .Count }}</span>
33
+
</button>
34
+
{{ end }}
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
···
1
+
{{ define "repo/fragments/reactionsPopUp" }}
2
+
<details
3
+
id="reactionsPopUp"
4
+
class="relative inline-block"
5
+
>
6
+
<summary
7
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
8
+
hover:bg-gray-50
9
+
hover:border-gray-300
10
+
dark:hover:bg-gray-700
11
+
dark:hover:border-gray-600
12
+
cursor-pointer list-none"
13
+
>
14
+
{{ i "smile" "size-4" }}
15
+
</summary>
16
+
<div
17
+
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
18
+
>
19
+
{{ range $kind := . }}
20
+
<button
21
+
id="reactBtn-{{ $kind }}"
22
+
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
23
+
hx-on:click="this.parentElement.parentElement.removeAttribute('open')"
24
+
>
25
+
{{ $kind }}
26
+
</button>
27
+
{{ end }}
28
+
</div>
29
+
</details>
30
+
{{ end }}
+19
appview/pages/templates/repo/fragments/time.html
+19
appview/pages/templates/repo/fragments/time.html
···
1
+
{{ define "repo/fragments/timeWrapper" }}
2
+
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
3
+
{{ end }}
4
+
5
+
{{ define "repo/fragments/time" }}
6
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
7
+
{{ end }}
8
+
9
+
{{ define "repo/fragments/shortTime" }}
10
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
11
+
{{ end }}
12
+
13
+
{{ define "repo/fragments/shortTimeAgo" }}
14
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
15
+
{{ end }}
16
+
17
+
{{ define "repo/fragments/duration" }}
18
+
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
19
+
{{ end }}
+43
-28
appview/pages/templates/repo/index.html
+43
-28
appview/pages/templates/repo/index.html
···
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
8
8
{{ end }}
9
9
10
-
11
10
{{ define "repoContent" }}
12
11
<main>
12
+
{{ if .Languages }}
13
+
{{ block "repoLanguages" . }}{{ end }}
14
+
{{ end }}
13
15
<div class="flex items-center justify-between pb-5">
14
16
{{ block "branchSelector" . }}{{ end }}
15
17
<div class="flex md:hidden items-center gap-4">
···
30
32
</div>
31
33
</main>
32
34
{{ end }}
35
+
36
+
{{ define "repoLanguages" }}
37
+
<div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t">
38
+
{{ range $value := .Languages }}
39
+
<div
40
+
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
41
+
class="h-[4px] rounded-full"
42
+
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
43
+
></div>
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
33
48
34
49
{{ define "branchSelector" }}
35
50
<div class="flex gap-2 items-center items-stretch justify-center">
···
134
149
</a>
135
150
136
151
{{ if .LastCommit }}
137
-
<time class="text-xs text-gray-500 dark:text-gray-400"
138
-
>{{ timeFmt .LastCommit.When }}</time
139
-
>
152
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
140
153
{{ end }}
141
154
</div>
142
155
</div>
···
157
170
</a>
158
171
159
172
{{ if .LastCommit }}
160
-
<time class="text-xs text-gray-500 dark:text-gray-400"
161
-
>{{ timeFmt .LastCommit.When }}</time
162
-
>
173
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
163
174
{{ end }}
164
175
</div>
165
176
</div>
···
222
233
</div>
223
234
</div>
224
235
236
+
<!-- commit info bar -->
225
237
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center">
226
238
{{ $verified := $.VerifiedCommits.IsVerified .Hash.String }}
227
239
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
···
238
250
</a>
239
251
</span>
240
252
<span
241
-
class="mx-2 before:content-['ยท'] before:select-none"
253
+
class="mx-1 before:content-['ยท'] before:select-none"
242
254
></span>
243
255
<span>
244
256
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
···
250
262
{{ end }}"
251
263
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
252
264
>{{ if $didOrHandle }}
253
-
{{ $didOrHandle }}
265
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
254
266
{{ else }}
255
267
{{ .Author.Name }}
256
268
{{ end }}</a
257
269
>
258
270
</span>
259
-
<div
260
-
class="inline-block px-1 select-none after:content-['ยท']"
261
-
></div>
262
-
<span>{{ timeFmt .Committer.When }}</span>
263
-
{{ $tagsForCommit := index $.TagMap .Hash.String }}
264
-
{{ if gt (len $tagsForCommit) 0 }}
265
-
<div
266
-
class="inline-block px-1 select-none after:content-['ยท']"
267
-
></div>
268
-
{{ end }}
269
-
{{ range $tagsForCommit }}
270
-
<span
271
-
class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"
272
-
>
273
-
{{ . }}
274
-
</span>
275
-
{{ end }}
271
+
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
272
+
{{ template "repo/fragments/time" .Committer.When }}
273
+
274
+
<!-- tags/branches -->
275
+
{{ $tagsForCommit := index $.TagMap .Hash.String }}
276
+
{{ if gt (len $tagsForCommit) 0 }}
277
+
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
278
+
{{ end }}
279
+
{{ range $tagsForCommit }}
280
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-[2px] inline-flex items-center">
281
+
{{ . }}
282
+
</span>
283
+
{{ end }}
284
+
285
+
<!-- ci status -->
286
+
{{ $pipeline := index $.Pipelines .Hash.String }}
287
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
288
+
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
289
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "RepoInfo" $.RepoInfo "Pipeline" $pipeline) }}
290
+
{{ end }}
276
291
</div>
277
292
</div>
278
293
{{ end }}
···
301
316
</a>
302
317
{{ if .Commit }}
303
318
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
304
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time>
319
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
305
320
{{ end }}
306
321
{{ if .IsDefault }}
307
322
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
···
347
362
</div>
348
363
<div>
349
364
{{ with .Tag }}
350
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time>
365
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span>
351
366
{{ end }}
352
367
{{ if eq $idx 0 }}
353
368
{{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }}
+1
-1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+1
-1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+9
-9
appview/pages/templates/repo/issues/fragments/issueComment.html
+9
-9
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
1
{{ define "repo/issues/fragments/issueComment" }}
2
2
{{ with .Comment }}
3
3
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
6
+
{{ template "user/fragments/picHandleLink" $owner }}
7
7
8
8
<span class="before:content-['ยท']"></span>
9
9
<a
···
11
11
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
12
12
id="{{ .CommentId }}">
13
13
{{ if .Deleted }}
14
-
deleted {{ .Deleted | timeFmt }}
14
+
deleted {{ template "repo/fragments/time" .Deleted }}
15
15
{{ else if .Edited }}
16
-
edited {{ .Edited | timeFmt }}
16
+
edited {{ template "repo/fragments/time" .Edited }}
17
17
{{ else }}
18
-
{{ .Created | timeFmt }}
18
+
{{ template "repo/fragments/time" .Created }}
19
19
{{ end }}
20
20
</a>
21
-
21
+
22
22
<!-- show user "hats" -->
23
23
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
24
24
{{ if $isIssueAuthor }}
···
29
29
30
30
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
31
{{ if and $isCommentOwner (not .Deleted) }}
32
-
<button
33
-
class="btn px-2 py-1 text-sm"
32
+
<button
33
+
class="btn px-2 py-1 text-sm"
34
34
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
35
35
hx-swap="outerHTML"
36
36
hx-target="#comment-container-{{.CommentId}}"
37
37
>
38
38
{{ i "pencil" "w-4 h-4" }}
39
39
</button>
40
-
<button
40
+
<button
41
41
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
42
42
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
43
hx-confirm="Are you sure you want to delete your comment?"
+37
-27
appview/pages/templates/repo/issues/issue.html
+37
-27
appview/pages/templates/repo/issues/issue.html
···
4
4
{{ define "extrameta" }}
5
5
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
6
{{ $url := printf "https://tangled.sh/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
-
7
+
8
8
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
9
9
{{ end }}
10
10
···
30
30
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
31
<span class="text-white">{{ .State }}</span>
32
32
</div>
33
-
<span class="text-gray-500 dark:text-gray-400 text-sm">
33
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
34
opened by
35
35
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
37
-
>{{ $owner }}</a
38
-
>
39
-
<span class="px-1 select-none before:content-['\00B7']"></span>
40
-
<time title="{{ .Issue.Created | longTimeFmt }}">
41
-
{{ .Issue.Created | timeFmt }}
42
-
</time>
36
+
{{ template "user/fragments/picHandleLink" $owner }}
37
+
<span class="select-none before:content-['\00B7']"></span>
38
+
{{ template "repo/fragments/time" .Issue.Created }}
43
39
</span>
44
40
</div>
45
41
···
48
44
{{ .Issue.Body | markdown }}
49
45
</article>
50
46
{{ end }}
47
+
48
+
<div class="flex items-center gap-2 mt-2">
49
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
50
+
{{ range $kind := .OrderedReactionKinds }}
51
+
{{
52
+
template "repo/fragments/reaction"
53
+
(dict
54
+
"Kind" $kind
55
+
"Count" (index $.Reactions $kind)
56
+
"IsReacted" (index $.UserReacted $kind)
57
+
"ThreadAt" $.Issue.IssueAt)
58
+
}}
59
+
{{ end }}
60
+
</div>
51
61
</section>
52
62
{{ end }}
53
63
···
71
81
72
82
{{ define "newComment" }}
73
83
{{ if .LoggedInUser }}
74
-
<form
75
-
id="comment-form"
84
+
<form
85
+
id="comment-form"
76
86
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
77
87
hx-on::after-request="if(event.detail.successful) this.reset()"
78
88
>
79
89
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
80
90
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
81
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
91
+
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
82
92
</div>
83
93
<textarea
84
94
id="comment-textarea"
···
90
100
<div id="issue-comment"></div>
91
101
<div id="issue-action" class="error"></div>
92
102
</div>
93
-
103
+
94
104
<div class="flex gap-2 mt-2">
95
-
<button
105
+
<button
96
106
id="comment-button"
97
107
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
98
108
type="submit"
···
109
119
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
110
120
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
111
121
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
112
-
<button
122
+
<button
113
123
id="close-button"
114
-
type="button"
124
+
type="button"
115
125
class="btn flex items-center gap-2"
116
126
hx-indicator="#close-spinner"
117
127
hx-trigger="click"
···
122
132
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
123
133
</span>
124
134
</button>
125
-
<div
126
-
id="close-with-comment"
127
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
128
-
hx-trigger="click from:#close-button"
135
+
<div
136
+
id="close-with-comment"
137
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
138
+
hx-trigger="click from:#close-button"
129
139
hx-disabled-elt="#close-with-comment"
130
140
hx-target="#issue-comment"
131
141
hx-indicator="#close-spinner"
···
133
143
hx-swap="none"
134
144
>
135
145
</div>
136
-
<div
137
-
id="close-issue"
146
+
<div
147
+
id="close-issue"
138
148
hx-disabled-elt="#close-issue"
139
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
140
-
hx-trigger="click from:#close-button"
149
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
150
+
hx-trigger="click from:#close-button"
141
151
hx-target="#issue-action"
142
152
hx-indicator="#close-spinner"
143
153
hx-swap="none"
···
155
165
});
156
166
</script>
157
167
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
158
-
<button
159
-
type="button"
168
+
<button
169
+
type="button"
160
170
class="btn flex items-center gap-2"
161
171
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
162
172
hx-indicator="#reopen-spinner"
···
206
216
});
207
217
</script>
208
218
</div>
209
-
</form>
219
+
</form>
210
220
{{ else }}
211
221
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
212
222
<a href="/login" class="underline">login</a> to join the discussion
+7
-9
appview/pages/templates/repo/issues/issues.html
+7
-9
appview/pages/templates/repo/issues/issues.html
···
3
3
{{ define "extrameta" }}
4
4
{{ $title := "issues"}}
5
5
{{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }}
6
-
6
+
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
8
{{ end }}
9
9
···
27
27
</div>
28
28
<a
29
29
href="/{{ .RepoInfo.FullName }}/issues/new"
30
-
class="btn text-sm flex items-center justify-center gap-2 no-underline hover:no-underline"
30
+
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
31
31
>
32
32
{{ i "circle-plus" "w-4 h-4" }}
33
33
<span>new</span>
···
49
49
<span class="text-gray-500">#{{ .IssueId }}</span>
50
50
</a>
51
51
</div>
52
-
<p class="text-sm text-gray-500 dark:text-gray-400">
52
+
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
53
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
54
{{ $icon := "ban" }}
55
55
{{ $state := "closed" }}
···
64
64
<span class="text-white dark:text-white">{{ $state }}</span>
65
65
</span>
66
66
67
-
<span>
68
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
69
-
<a href="/{{ $owner }}">{{ $owner }}</a>
67
+
<span class="ml-1">
68
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
69
+
{{ template "user/fragments/picHandleLink" $owner }}
70
70
</span>
71
71
72
72
<span class="before:content-['ยท']">
73
-
<time>
74
-
{{ .Created | timeFmt }}
75
-
</time>
73
+
{{ template "repo/fragments/time" .Created }}
76
74
</span>
77
75
78
76
<span class="before:content-['ยท']">
+5
-4
appview/pages/templates/repo/issues/new.html
+5
-4
appview/pages/templates/repo/issues/new.html
···
23
23
></textarea>
24
24
</div>
25
25
<div>
26
-
<button type="submit" class="btn flex items-center gap-2">
27
-
create
28
-
<span id="spinner" class="group">
29
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
26
+
<button type="submit" class="btn-create flex items-center gap-2">
27
+
{{ i "circle-plus" "w-4 h-4" }}
28
+
create issue
29
+
<span id="create-pull-spinner" class="group">
30
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
30
31
</span>
31
32
</button>
32
33
</div>
+68
-55
appview/pages/templates/repo/log.html
+68
-55
appview/pages/templates/repo/log.html
···
20
20
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th>
21
21
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th>
22
22
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th>
23
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th>
23
24
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th>
24
25
</tr>
25
26
</thead>
···
30
31
<td class=" py-3 align-top">
31
32
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
32
33
{{ if $didOrHandle }}
33
-
<a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a>
34
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
34
35
{{ else }}
35
36
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
36
37
{{ end }}
···
57
58
{{ i "folder-code" "w-4 h-4" }}
58
59
</a>
59
60
</div>
61
+
60
62
</td>
61
63
<td class=" py-3 align-top">
62
-
<div>
63
-
<div class="flex items-center justify-start">
64
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
65
-
{{ if gt (len $messageParts) 1 }}
66
-
<button class="ml-2 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>
67
-
{{ end }}
68
-
69
-
70
-
{{ if index $.TagMap $commit.Hash.String }}
71
-
{{ range $tag := index $.TagMap $commit.Hash.String }}
72
-
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
73
-
{{ $tag }}
74
-
</span>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
</div>
79
-
64
+
<div class="flex items-center justify-start gap-2">
65
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
80
66
{{ 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>
67
+
<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>
82
68
{{ end }}
69
+
70
+
{{ if index $.TagMap $commit.Hash.String }}
71
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
72
+
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
73
+
{{ $tag }}
74
+
</span>
75
+
{{ end }}
76
+
{{ end }}
77
+
</div>
78
+
79
+
{{ if gt (len $messageParts) 1 }}
80
+
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
81
+
{{ end }}
83
82
</td>
84
-
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td>
83
+
<td class="py-3 align-top">
84
+
<!-- ci status -->
85
+
{{ $pipeline := index $.Pipelines .Hash.String }}
86
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
87
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
88
+
{{ end }}
89
+
</td>
90
+
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $commit.Committer.When }}</td>
85
91
</tr>
86
92
{{ end }}
87
93
</tbody>
···
94
100
<div id="commit-message">
95
101
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
96
102
<div class="text-base cursor-pointer">
97
-
<div>
98
-
<div class="flex items-center justify-between">
99
-
<div class="flex-1">
100
-
<div class="inline">
101
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
102
-
class="inline no-underline hover:underline dark:text-white">
103
-
{{ index $messageParts 0 }}
104
-
</a>
105
-
{{ if gt (len $messageParts) 1 }}
106
-
<button
107
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2"
108
-
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
109
-
{{ i "ellipsis" "w-3 h-3" }}
110
-
</button>
111
-
{{ end }}
103
+
<div class="flex items-center justify-between">
104
+
<div class="flex-1">
105
+
<div class="inline-flex items-end">
106
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
107
+
class="inline no-underline hover:underline dark:text-white">
108
+
{{ index $messageParts 0 }}
109
+
</a>
110
+
{{ if gt (len $messageParts) 1 }}
111
+
<button
112
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2"
113
+
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
114
+
{{ i "ellipsis" "w-3 h-3" }}
115
+
</button>
116
+
{{ end }}
112
117
113
-
{{ if index $.TagMap $commit.Hash.String }}
114
-
{{ range $tag := index $.TagMap $commit.Hash.String }}
115
-
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
116
-
{{ $tag }}
117
-
</span>
118
-
{{ end }}
118
+
{{ if index $.TagMap $commit.Hash.String }}
119
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
120
+
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
121
+
{{ $tag }}
122
+
</span>
119
123
{{ end }}
120
-
</div>
121
-
122
-
{{ if gt (len $messageParts) 1 }}
123
-
<p class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300">
124
-
{{ nl2br (index $messageParts 1) }}
125
-
</p>
126
124
{{ end }}
127
125
</div>
128
-
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}"
129
-
class="p-1 mr-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
130
-
title="Browse repository at this commit">
131
-
{{ i "folder-code" "w-4 h-4" }}
132
-
</a>
126
+
127
+
{{ if gt (len $messageParts) 1 }}
128
+
<p class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300">
129
+
{{ nl2br (index $messageParts 1) }}
130
+
</p>
131
+
{{ end }}
133
132
</div>
133
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}"
134
+
class="p-1 mr-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
135
+
title="Browse repository at this commit">
136
+
{{ i "folder-code" "w-4 h-4" }}
137
+
</a>
134
138
</div>
135
139
</div>
136
140
</div>
···
155
159
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
156
160
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
157
161
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
158
-
{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
162
+
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
159
163
</a>
160
164
</span>
161
165
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
162
-
<span>{{ shortTimeFmt $commit.Committer.When }}</span>
166
+
<span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span>
167
+
168
+
<!-- ci status -->
169
+
{{ $pipeline := index $.Pipelines .Hash.String }}
170
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
171
+
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
172
+
<span class="text-sm">
173
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
174
+
</span>
175
+
{{ end }}
163
176
</div>
164
177
</div>
165
178
{{ end }}
+8
-7
appview/pages/templates/repo/new.html
+8
-7
appview/pages/templates/repo/new.html
···
60
60
</fieldset>
61
61
62
62
<div class="space-y-2">
63
-
<button type="submit" class="btn flex gap-2 items-center">
64
-
create repo
65
-
<span id="spinner" class="group">
66
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
-
</span>
68
-
</button>
69
-
<div id="repo" class="error"></div>
63
+
<button type="submit" class="btn-create flex items-center gap-2">
64
+
{{ i "book-plus" "w-4 h-4" }}
65
+
create repo
66
+
<span id="create-pull-spinner" class="group">
67
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
+
</span>
69
+
</button>
70
+
<div id="repo" class="error"></div>
70
71
</div>
71
72
</form>
72
73
</div>
+15
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+15
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
1
+
{{ define "repo/pipelines/fragments/logBlock" }}
2
+
<div id="lines" hx-swap-oob="beforeend">
3
+
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group bg-gray-100 pb-2 px-2 dark:bg-gray-900">
4
+
<summary class="sticky top-0 pt-2 group-open:pb-2 list-none cursor-pointer bg-gray-100 dark:bg-gray-900 hover:text-gray-500 hover:dark:text-gray-400">
5
+
<div class="group-open:hidden flex items-center gap-1">
6
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
+
</div>
8
+
<div class="hidden group-open:flex items-center gap-1">
9
+
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
+
</div>
11
+
</summary>
12
+
<div class="font-mono whitespace-pre overflow-x-auto"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
13
+
</details>
14
+
</div>
15
+
{{ end }}
+4
appview/pages/templates/repo/pipelines/fragments/logLine.html
+4
appview/pages/templates/repo/pipelines/fragments/logLine.html
+74
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
+74
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
···
1
+
{{ define "repo/pipelines/fragments/pipelineSymbol" }}
2
+
<div class="cursor-pointer">
3
+
{{ $c := .Counts }}
4
+
{{ $statuses := .Statuses }}
5
+
{{ $total := len $statuses }}
6
+
{{ $success := index $c "success" }}
7
+
{{ $fail := index $c "failed" }}
8
+
{{ $timeout := index $c "timeout" }}
9
+
{{ $empty := eq $total 0 }}
10
+
{{ $allPass := eq $success $total }}
11
+
{{ $allFail := eq $fail $total }}
12
+
{{ $allTimeout := eq $timeout $total }}
13
+
14
+
{{ if $empty }}
15
+
<div class="flex gap-1 items-center">
16
+
{{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }}
17
+
<span>0/{{ $total }}</span>
18
+
</div>
19
+
{{ else if $allPass }}
20
+
<div class="flex gap-1 items-center">
21
+
{{ i "check" "size-4 text-green-600" }}
22
+
<span>{{ $total }}/{{ $total }}</span>
23
+
</div>
24
+
{{ else if $allFail }}
25
+
<div class="flex gap-1 items-center">
26
+
{{ i "x" "size-4 text-red-600" }}
27
+
<span>0/{{ $total }}</span>
28
+
</div>
29
+
{{ else if $allTimeout }}
30
+
<div class="flex gap-1 items-center">
31
+
{{ i "clock-alert" "size-4 text-orange-400" }}
32
+
<span>0/{{ $total }}</span>
33
+
</div>
34
+
{{ else }}
35
+
{{ $radius := f64 8 }}
36
+
{{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }}
37
+
{{ $offset := 0.0 }}
38
+
<div class="flex gap-1 items-center">
39
+
<svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20">
40
+
<circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/>
41
+
42
+
{{ range $kind, $count := $c }}
43
+
{{ $color := "" }}
44
+
{{ if or (eq $kind "pending") (eq $kind "running") }}
45
+
{{ $color = "#eab308" }} {{/* amber-500 */}}
46
+
{{ else if eq $kind "success" }}
47
+
{{ $color = "#10b981" }} {{/* green-500 */}}
48
+
{{ else if eq $kind "cancelled" }}
49
+
{{ $color = "#6b7280" }} {{/* gray-500 */}}
50
+
{{ else if eq $kind "timeout" }}
51
+
{{ $color = "#fb923c" }} {{/* orange-400 */}}
52
+
{{ else }}
53
+
{{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}}
54
+
{{ end }}
55
+
56
+
{{ $percent := divf64 (f64 $count) (f64 $total) }}
57
+
{{ $length := mulf64 $percent $circumference }}
58
+
59
+
<circle
60
+
cx="10" cy="10" r="{{ $radius }}"
61
+
fill="none"
62
+
stroke="{{ $color }}"
63
+
stroke-width="2"
64
+
stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}"
65
+
stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}"
66
+
/>
67
+
{{ $offset = addf64 $offset $length }}
68
+
{{ end }}
69
+
</svg>
70
+
<span>{{ $success }}/{{ $total }}</span>
71
+
</div>
72
+
{{ end }}
73
+
</div>
74
+
{{ end }}
+12
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
+12
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
···
1
+
{{ define "repo/pipelines/fragments/pipelineSymbolLong" }}
2
+
{{ $pipeline := .Pipeline }}
3
+
{{ $repoinfo := .RepoInfo }}
4
+
<div class="relative inline-block">
5
+
<details class="relative">
6
+
<summary class="cursor-pointer list-none">
7
+
{{ template "repo/pipelines/fragments/pipelineSymbol" .Pipeline }}
8
+
</summary>
9
+
{{ template "repo/pipelines/fragments/tooltip" $ }}
10
+
</details>
11
+
</div>
12
+
{{ end }}
+35
appview/pages/templates/repo/pipelines/fragments/tooltip.html
+35
appview/pages/templates/repo/pipelines/fragments/tooltip.html
···
1
+
{{ define "repo/pipelines/fragments/tooltip" }}
2
+
{{ $repoinfo := .RepoInfo }}
3
+
{{ $pipeline := .Pipeline }}
4
+
{{ $id := $pipeline.Id }}
5
+
<div class="absolute z-[9999] bg-white dark:bg-gray-900 text-black dark:text-white rounded shadow-sm w-80 top-full mt-2 p-2">
6
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
7
+
{{ range $name, $all := $pipeline.Statuses }}
8
+
<a href="/{{ $repoinfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="hover:no-underline">
9
+
<div class="flex items-center justify-between p-2">
10
+
{{ $lastStatus := $all.Latest }}
11
+
{{ $kind := $lastStatus.Status.String }}
12
+
13
+
<div id="left" class="flex items-center gap-2 flex-shrink-0">
14
+
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
15
+
{{ $name }}
16
+
</div>
17
+
<div id="right" class="flex items-center gap-2 flex-shrink-0">
18
+
<span class="font-bold">{{ $kind }}</span>
19
+
{{ if .TimeTaken }}
20
+
{{ template "repo/fragments/duration" .TimeTaken }}
21
+
{{ else }}
22
+
{{ template "repo/fragments/shortTimeAgo" $pipeline.Created }}
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
</a>
27
+
{{ else }}
28
+
<div class="flex items-center gap-2 p-2 italic text-gray-600 dark:text-gray-400 ">
29
+
{{ i "hourglass" "size-4" }}
30
+
Waiting for spindle ...
31
+
</div>
32
+
{{ end }}
33
+
</div>
34
+
</div>
35
+
{{ end }}
+29
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
+29
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
···
1
+
{{ define "repo/pipelines/fragments/workflowSymbol" }}
2
+
{{ $lastStatus := .Latest }}
3
+
{{ $kind := $lastStatus.Status.String }}
4
+
5
+
{{ $icon := "dot" }}
6
+
{{ $color := "text-gray-600 dark:text-gray-500" }}
7
+
8
+
{{ if eq $kind "pending" }}
9
+
{{ $icon = "circle-dashed" }}
10
+
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
11
+
{{ else if eq $kind "running" }}
12
+
{{ $icon = "circle-dashed" }}
13
+
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
14
+
{{ else if eq $kind "success" }}
15
+
{{ $icon = "check" }}
16
+
{{ $color = "text-green-600 dark:text-green-500" }}
17
+
{{ else if eq $kind "cancelled" }}
18
+
{{ $icon = "circle-slash" }}
19
+
{{ $color = "text-gray-600 dark:text-gray-500" }}
20
+
{{ else if eq $kind "timeout" }}
21
+
{{ $icon = "clock-alert" }}
22
+
{{ $color = "text-orange-400 dark:text-orange-300" }}
23
+
{{ else }}
24
+
{{ $icon = "x" }}
25
+
{{ $color = "text-red-600 dark:text-red-500" }}
26
+
{{ end }}
27
+
28
+
{{ i $icon "size-4" $color }}
29
+
{{ end }}
+102
appview/pages/templates/repo/pipelines/pipelines.html
+102
appview/pages/templates/repo/pipelines/pipelines.html
···
1
+
{{ define "title" }}pipelines · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $title := "pipelines"}}
5
+
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
6
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
+
{{ end }}
8
+
9
+
{{ define "repoContent" }}
10
+
<div class="flex justify-between items-center gap-4">
11
+
<div class="w-full flex flex-col gap-2">
12
+
{{ range .Pipelines }}
13
+
{{ block "pipeline" (list $ .) }} {{ end }}
14
+
{{ else }}
15
+
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
16
+
No pipelines run for this repository.
17
+
</p>
18
+
{{ end }}
19
+
</div>
20
+
</div>
21
+
{{ end }}
22
+
23
+
24
+
{{ define "pipeline" }}
25
+
{{ $root := index . 0 }}
26
+
{{ $p := index . 1 }}
27
+
<div class="py-2 bg-white dark:bg-gray-800 dark:text-white">
28
+
{{ block "pipelineHeader" $ }} {{ end }}
29
+
</div>
30
+
{{ end }}
31
+
32
+
{{ define "pipelineHeader" }}
33
+
{{ $root := index . 0 }}
34
+
{{ $p := index . 1 }}
35
+
{{ with $p }}
36
+
<div class="grid grid-cols-6 md:grid-cols-12 gap-2 items-center w-full">
37
+
<div class="text-sm md:text-base col-span-1">
38
+
{{ .Trigger.Kind.String }}
39
+
</div>
40
+
41
+
<div class="col-span-2 md:col-span-7 flex items-center gap-4">
42
+
{{ $target := .Trigger.TargetRef }}
43
+
{{ $workflows := .Workflows }}
44
+
{{ $link := "" }}
45
+
{{ if .IsResponding }}
46
+
{{ $link = printf "/%s/pipelines/%s/workflow/%d" $root.RepoInfo.FullName .Id (index $workflows 0) }}
47
+
{{ end }}
48
+
{{ if .Trigger.IsPush }}
49
+
<span class="font-bold">{{ $target }}</span>
50
+
<span class="hidden md:inline-flex gap-2 items-center font-mono text-sm">
51
+
{{ $old := deref .Trigger.PushOldSha }}
52
+
{{ $new := deref .Trigger.PushNewSha }}
53
+
54
+
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $new }}">{{ slice $new 0 8 }}</a>
55
+
{{ i "arrow-left" "size-4" }}
56
+
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $old }}">{{ slice $old 0 8 }}</a>
57
+
</span>
58
+
{{ else if .Trigger.IsPullRequest }}
59
+
{{ $sha := deref .Trigger.PRSourceSha }}
60
+
<span class="inline-flex gap-2 items-center">
61
+
<span class="font-bold">{{ $target }}</span>
62
+
{{ i "arrow-left" "size-4" }}
63
+
{{ .Trigger.PRSourceBranch }}
64
+
<span class="text-sm font-mono">
65
+
@
66
+
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
67
+
</span>
68
+
</span>
69
+
{{ end }}
70
+
</div>
71
+
72
+
<div class="text-sm md:text-base col-span-1">
73
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" . "RepoInfo" $root.RepoInfo) }}
74
+
</div>
75
+
76
+
<div class="text-sm md:text-base col-span-1 text-right">
77
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
78
+
</div>
79
+
80
+
{{ $t := .TimeTaken }}
81
+
<div class="text-sm md:text-base col-span-1 text-right">
82
+
{{ if $t }}
83
+
<time title="{{ $t }}">{{ $t | durationFmt }}</time>
84
+
{{ else }}
85
+
<time>--</time>
86
+
{{ end }}
87
+
</div>
88
+
89
+
<div class="col-span-1 flex justify-end">
90
+
{{ if $link }}
91
+
<a class="md:hidden" href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}">
92
+
{{ i "arrow-up-right" "size-4" }}
93
+
</a>
94
+
<a class="hidden md:inline underline" href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}">
95
+
view
96
+
</a>
97
+
{{ end }}
98
+
</div>
99
+
100
+
</div>
101
+
{{ end }}
102
+
{{ end }}
+62
appview/pages/templates/repo/pipelines/workflow.html
+62
appview/pages/templates/repo/pipelines/workflow.html
···
1
+
{{ define "title" }} {{ .Workflow }} · pipeline {{ .Pipeline.Id }} · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $title := "pipelines"}}
5
+
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
6
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
+
{{ end }}
8
+
9
+
{{ define "repoContent" }}
10
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2 mt-2">
11
+
<div class="col-span-1">
12
+
{{ block "sidebar" . }} {{ end }}
13
+
</div>
14
+
<div class="col-span-1 md:col-span-3">
15
+
{{ block "logs" . }} {{ end }}
16
+
</div>
17
+
</section>
18
+
{{ end }}
19
+
20
+
{{ define "repoAfter" }}
21
+
{{ end }}
22
+
23
+
{{ define "sidebar" }}
24
+
{{ $active := .Workflow }}
25
+
{{ with .Pipeline }}
26
+
{{ $id := .Id }}
27
+
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
28
+
{{ range $name, $all := .Statuses }}
29
+
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
30
+
<div
31
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
{{ $lastStatus := $all.Latest }}
33
+
{{ $kind := $lastStatus.Status.String }}
34
+
35
+
<div id="left" class="flex items-center gap-2 flex-shrink-0">
36
+
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
37
+
{{ $name }}
38
+
</div>
39
+
<div id="right" class="flex items-center gap-2 flex-shrink-0">
40
+
<span class="font-bold">{{ $kind }}</span>
41
+
{{ if .TimeTaken }}
42
+
{{ template "repo/fragments/duration" .TimeTaken }}
43
+
{{ else }}
44
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
45
+
{{ end }}
46
+
</div>
47
+
</div>
48
+
</a>
49
+
{{ end }}
50
+
</div>
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
{{ define "logs" }}
55
+
<div id="log-stream"
56
+
class="text-sm"
57
+
hx-ext="ws"
58
+
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
59
+
<div id="lines" class="flex flex-col gap-2">
60
+
</div>
61
+
</div>
62
+
{{ end }}
+19
-5
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+19
-5
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
26
26
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
27
27
<span class="text-white">{{ .Pull.State.String }}</span>
28
28
</div>
29
-
<span class="text-gray-500 dark:text-gray-400 text-sm">
29
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
30
30
opened by
31
31
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
33
-
>{{ $owner }}</a
34
-
>
32
+
{{ template "user/fragments/picHandleLink" $owner }}
35
33
<span class="select-none before:content-['\00B7']"></span>
36
-
<time>{{ .Pull.Created | timeFmt }}</time>
34
+
{{ template "repo/fragments/time" .Pull.Created }}
37
35
38
36
<span class="select-none before:content-['\00B7']"></span>
39
37
<span>
···
62
60
<article id="body" class="mt-8 prose dark:prose-invert">
63
61
{{ .Pull.Body | markdown }}
64
62
</article>
63
+
{{ end }}
64
+
65
+
{{ with .OrderedReactionKinds }}
66
+
<div class="flex items-center gap-2 mt-2">
67
+
{{ template "repo/fragments/reactionsPopUp" . }}
68
+
{{ range $kind := . }}
69
+
{{
70
+
template "repo/fragments/reaction"
71
+
(dict
72
+
"Kind" $kind
73
+
"Count" (index $.Reactions $kind)
74
+
"IsReacted" (index $.UserReacted $kind)
75
+
"ThreadAt" $.Pull.PullAt)
76
+
}}
77
+
{{ end }}
78
+
</div>
65
79
{{ end }}
66
80
</section>
67
81
+3
-4
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+3
-4
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
1
1
{{ define "repo/pulls/fragments/pullNewComment" }}
2
-
<div
3
-
id="pull-comment-card-{{ .RoundNumber }}"
2
+
<div
3
+
id="pull-comment-card-{{ .RoundNumber }}"
4
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
6
+
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
···
38
38
</form>
39
39
</div>
40
40
{{ end }}
41
-
+3
-2
appview/pages/templates/repo/pulls/fragments/pullStack.html
+3
-2
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
10
10
{{ i "chevrons-down-up" "w-4 h-4" }}
11
11
</span>
12
12
STACK
13
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ len .Stack }}</span>
13
+
<span class="bg-gray-200 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Stack }}</span>
14
14
</span>
15
15
</summary>
16
16
{{ block "pullList" (list .Stack $) }} {{ end }}
···
41
41
<div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
42
42
{{ range $pull := $list }}
43
43
{{ $isCurrent := false }}
44
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
44
45
{{ with $root.Pull }}
45
46
{{ $isCurrent = eq $pull.PullId $root.Pull.PullId }}
46
47
{{ end }}
···
52
53
</div>
53
54
{{ end }}
54
55
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
55
-
{{ template "repo/pulls/fragments/summarizedHeader" $pull }}
56
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
56
57
</div>
57
58
</div>
58
59
</a>
+36
-26
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+36
-26
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
1
1
{{ define "repo/pulls/fragments/summarizedHeader" }}
2
-
<div class="flex text-sm items-center justify-between w-full">
3
-
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
4
-
<div class="flex-shrink-0">
5
-
{{ template "repo/pulls/fragments/summarizedPullState" .State }}
2
+
{{ $pull := index . 0 }}
3
+
{{ $pipeline := index . 1 }}
4
+
{{ with $pull }}
5
+
<div class="flex text-sm items-center justify-between w-full">
6
+
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
7
+
<div class="flex-shrink-0">
8
+
{{ template "repo/pulls/fragments/summarizedPullState" .State }}
9
+
</div>
10
+
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
11
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
12
+
{{ .Title }}
13
+
</span>
6
14
</div>
7
-
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
8
-
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
9
-
{{ .Title }}
10
-
</span>
11
-
</div>
12
15
13
-
<div class="flex-shrink-0">
14
-
{{ $latestRound := .LastRoundNumber }}
15
-
{{ $lastSubmission := index .Submissions $latestRound }}
16
-
{{ $commentCount := len $lastSubmission.Comments }}
17
-
<span>
18
-
<div class="inline-flex items-center gap-2">
19
-
{{ i "message-square" "w-3 h-3 md:hidden" }}
20
-
{{ $commentCount }}
21
-
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
22
-
</div>
23
-
</span>
24
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
25
-
<span>
26
-
<span class="hidden md:inline">round</span>
27
-
<span class="font-mono">#{{ $latestRound }}</span>
28
-
</span>
16
+
<div class="flex-shrink-0 flex items-center">
17
+
{{ $latestRound := .LastRoundNumber }}
18
+
{{ $lastSubmission := index .Submissions $latestRound }}
19
+
{{ $commentCount := len $lastSubmission.Comments }}
20
+
{{ if $pipeline }}
21
+
<div class="inline-flex items-center gap-2">
22
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
23
+
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
24
+
</div>
25
+
{{ end }}
26
+
<span>
27
+
<div class="inline-flex items-center gap-2">
28
+
{{ i "message-square" "w-3 h-3 md:hidden" }}
29
+
{{ $commentCount }}
30
+
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
31
+
</div>
32
+
</span>
33
+
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
34
+
<span>
35
+
<span class="hidden md:inline">round</span>
36
+
<span class="font-mono">#{{ $latestRound }}</span>
37
+
</span>
38
+
</div>
29
39
</div>
30
-
</div>
40
+
{{ end }}
31
41
{{ end }}
32
42
+1
-1
appview/pages/templates/repo/pulls/new.html
+1
-1
appview/pages/templates/repo/pulls/new.html
···
141
141
</div>
142
142
143
143
<div class="flex justify-start items-center gap-2 mt-4">
144
-
<button type="submit" class="btn flex items-center gap-2">
144
+
<button type="submit" class="btn-create flex items-center gap-2">
145
145
{{ i "git-pull-request-create" "w-4 h-4" }}
146
146
create pull
147
147
<span id="create-pull-spinner" class="group">
+44
-10
appview/pages/templates/repo/pulls/pull.html
+44
-10
appview/pages/templates/repo/pulls/pull.html
···
5
5
{{ define "extrameta" }}
6
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
7
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
-
8
+
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
10
{{ end }}
11
11
···
46
46
</div>
47
47
<!-- round summary -->
48
48
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
-
<span>
49
+
<span class="gap-1 flex items-center">
50
50
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
51
51
{{ $re := "re" }}
52
52
{{ if eq .RoundNumber 0 }}
53
53
{{ $re = "" }}
54
54
{{ end }}
55
55
<span class="hidden md:inline">{{$re}}submitted</span>
56
-
by <a href="/{{ $owner }}">{{ $owner }}</a>
56
+
by {{ template "user/fragments/picHandleLink" $owner }}
57
57
<span class="select-none before:content-['\00B7']"></span>
58
-
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a>
58
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a>
59
59
<span class="select-none before:content-['ยท']"></span>
60
60
{{ $s := "s" }}
61
61
{{ if eq (len .Comments) 1 }}
···
68
68
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
69
69
hx-boost="true"
70
70
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
71
-
{{ i "file-diff" "w-4 h-4" }}
71
+
{{ i "file-diff" "w-4 h-4" }}
72
72
<span class="hidden md:inline">diff</span>
73
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
74
</a>
···
150
150
{{ if gt $cidx 0 }}
151
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
152
{{ end }}
153
-
<div class="text-sm text-gray-500 dark:text-gray-400">
154
-
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
155
-
<a href="/{{$owner}}">{{$owner}}</a>
153
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
154
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
155
+
{{ template "user/fragments/picHandleLink" $owner }}
156
156
<span class="before:content-['ยท']"></span>
157
-
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a>
157
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a>
158
158
</div>
159
159
<div class="prose dark:prose-invert">
160
160
{{ $c.Body | markdown }}
161
161
</div>
162
162
</div>
163
163
{{ end }}
164
+
165
+
{{ block "pipelineStatus" (list $ .) }} {{ end }}
164
166
165
167
{{ if eq $lastIdx .RoundNumber }}
166
168
{{ block "mergeStatus" $ }} {{ end }}
···
260
262
{{ end }}
261
263
{{ end }}
262
264
263
-
{{ define "commits" }}
265
+
{{ define "pipelineStatus" }}
266
+
{{ $root := index . 0 }}
267
+
{{ $submission := index . 1 }}
268
+
{{ $pipeline := index $root.Pipelines $submission.SourceRev }}
269
+
{{ with $pipeline }}
270
+
{{ $id := .Id }}
271
+
{{ if .Statuses }}
272
+
<div class="max-w-80 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
273
+
{{ range $name, $all := .Statuses }}
274
+
<a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
275
+
<div
276
+
class="flex gap-2 items-center justify-between p-2">
277
+
{{ $lastStatus := $all.Latest }}
278
+
{{ $kind := $lastStatus.Status.String }}
279
+
280
+
<div id="left" class="flex items-center gap-2 flex-shrink-0">
281
+
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
282
+
{{ $name }}
283
+
</div>
284
+
<div id="right" class="flex items-center gap-2 flex-shrink-0">
285
+
<span class="font-bold">{{ $kind }}</span>
286
+
{{ if .TimeTaken }}
287
+
{{ template "repo/fragments/duration" .TimeTaken }}
288
+
{{ else }}
289
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
290
+
{{ end }}
291
+
</div>
292
+
</div>
293
+
</a>
294
+
{{ end }}
295
+
</div>
296
+
{{ end }}
297
+
{{ end }}
264
298
{{ end }}
+7
-9
appview/pages/templates/repo/pulls/pulls.html
+7
-9
appview/pages/templates/repo/pulls/pulls.html
···
3
3
{{ define "extrameta" }}
4
4
{{ $title := "pulls"}}
5
5
{{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }}
6
-
6
+
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
8
{{ end }}
9
9
···
34
34
</div>
35
35
<a
36
36
href="/{{ .RepoInfo.FullName }}/pulls/new"
37
-
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"
37
+
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
38
38
>
39
39
{{ i "git-pull-request-create" "w-4 h-4" }}
40
40
<span>new</span>
···
54
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
55
</a>
56
56
</div>
57
-
<p class="text-sm text-gray-500 dark:text-gray-400">
57
+
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
60
{{ $icon := "ban" }}
···
75
75
<span class="text-white">{{ .State.String }}</span>
76
76
</span>
77
77
78
-
<span>
79
-
<a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a>
78
+
<span class="ml-1">
79
+
{{ template "user/fragments/picHandleLink" $owner }}
80
80
</span>
81
81
82
82
<span class="before:content-['ยท']">
83
-
<time>
84
-
{{ .Created | timeFmt }}
85
-
</time>
83
+
{{ template "repo/fragments/time" .Created }}
86
84
</span>
87
85
88
86
<span class="before:content-['ยท']">
···
156
154
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
157
155
<div class="flex gap-2 items-center px-6">
158
156
<div class="flex-grow min-w-0 w-full py-2">
159
-
{{ template "repo/pulls/fragments/summarizedHeader" $pull }}
157
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }}
160
158
</div>
161
159
</div>
162
160
</a>
+36
-2
appview/pages/templates/repo/settings.html
+36
-2
appview/pages/templates/repo/settings.html
···
81
81
</div>
82
82
</form>
83
83
84
+
{{ if .RepoInfo.Roles.IsOwner }}
85
+
<form
86
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle"
87
+
class="mt-6 group"
88
+
>
89
+
<label for="spindle">spindle</label>
90
+
<div class="flex gap-2 items-center">
91
+
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
92
+
<option
93
+
value=""
94
+
selected
95
+
>
96
+
None
97
+
</option>
98
+
{{ range .Spindles }}
99
+
<option
100
+
value="{{ . }}"
101
+
class="py-1"
102
+
{{ if eq . $.CurrentSpindle }}
103
+
selected
104
+
{{ end }}
105
+
>
106
+
{{ . }}
107
+
</option>
108
+
{{ end }}
109
+
</select>
110
+
<button class="btn my-2 flex gap-2 items-center" type="submit">
111
+
<span>save</span>
112
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
113
+
</button>
114
+
</div>
115
+
</form>
116
+
{{ end }}
117
+
84
118
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
85
119
<form
86
120
hx-confirm="Are you sure you want to delete this repository?"
···
89
123
hx-indicator="#delete-repo-spinner"
90
124
>
91
125
<label for="branch">delete repository</label>
92
-
<button class="btn my-2 flex gap-2 items-center" type="text">
126
+
<button class="btn my-2 flex items-center" type="text">
93
127
<span>delete</span>
94
128
<span id="delete-repo-spinner" class="group">
95
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
129
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
96
130
</span>
97
131
</button>
98
132
<span>
+9
-3
appview/pages/templates/repo/tree.html
+9
-3
appview/pages/templates/repo/tree.html
···
11
11
{{ template "repo/fragments/meta" . }}
12
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
13
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
-
14
+
15
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
16
{{ end }}
17
17
···
63
63
</div>
64
64
</a>
65
65
{{ if .LastCommit}}
66
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
66
+
<div class="flex items-end gap-2">
67
+
<span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span>
68
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
69
+
</div>
67
70
{{ end }}
68
71
</div>
69
72
</div>
···
80
83
</div>
81
84
</a>
82
85
{{ if .LastCommit}}
83
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
86
+
<div class="flex items-end gap-2">
87
+
<span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span>
88
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
89
+
</div>
84
90
{{ end }}
85
91
</div>
86
92
</div>
+2
-2
appview/pages/templates/settings.html
+2
-2
appview/pages/templates/settings.html
···
39
39
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
40
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
41
</div>
42
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
43
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
44
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
45
</div>
···
112
112
{{ end }}
113
113
</div>
114
114
</div>
115
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p>
115
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
116
116
</div>
117
117
<div class="flex gap-2 items-center">
118
118
{{ if not .Verified }}
+119
appview/pages/templates/spindles/dashboard.html
+119
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) }}
10
+
{{ if .Spindle.Verified }}
11
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
12
+
{{ if $isOwner }}
13
+
{{ template "spindles/fragments/addMemberModal" .Spindle }}
14
+
{{ end }}
15
+
{{ else }}
16
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
17
+
{{ if $isOwner }}
18
+
{{ block "retryButton" .Spindle }} {{ end }}
19
+
{{ end }}
20
+
{{ end }}
21
+
22
+
{{ if $isOwner }}
23
+
{{ block "deleteButton" .Spindle }} {{ end }}
24
+
{{ end }}
25
+
</div>
26
+
</div>
27
+
<div id="operation-error" class="dark:text-red-400"></div>
28
+
</div>
29
+
30
+
{{ if .Members }}
31
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
32
+
<div class="flex flex-col gap-2">
33
+
{{ block "member" . }} {{ end }}
34
+
</div>
35
+
</section>
36
+
{{ end }}
37
+
{{ end }}
38
+
39
+
40
+
{{ define "member" }}
41
+
{{ range .Members }}
42
+
<div>
43
+
<div class="flex justify-between items-center">
44
+
<div class="flex items-center gap-2">
45
+
{{ i "user" "size-4" }}
46
+
{{ $user := index $.DidHandleMap . }}
47
+
<a href="/{{ $user }}">{{ $user }}</a>
48
+
</div>
49
+
{{ if ne $.LoggedInUser.Did . }}
50
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
51
+
{{ end }}
52
+
</div>
53
+
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
54
+
{{ $repos := index $.Repos . }}
55
+
{{ range $repos }}
56
+
<div class="flex gap-2 items-center">
57
+
{{ i "book-marked" "size-4" }}
58
+
<a href="/{{ .Did }}/{{ .Name }}">
59
+
{{ .Name }}
60
+
</a>
61
+
</div>
62
+
{{ else }}
63
+
<div class="text-gray-500 dark:text-gray-400">
64
+
No repositories configured yet.
65
+
</div>
66
+
{{ end }}
67
+
</div>
68
+
</div>
69
+
{{ end }}
70
+
{{ end }}
71
+
72
+
{{ define "deleteButton" }}
73
+
<button
74
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
75
+
title="Delete spindle"
76
+
hx-delete="/spindles/{{ .Instance }}"
77
+
hx-swap="outerHTML"
78
+
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
79
+
hx-headers='{"shouldRedirect": "true"}'
80
+
>
81
+
{{ i "trash-2" "w-5 h-5" }}
82
+
<span class="hidden md:inline">delete</span>
83
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
84
+
</button>
85
+
{{ end }}
86
+
87
+
88
+
{{ define "retryButton" }}
89
+
<button
90
+
class="btn gap-2 group"
91
+
title="Retry spindle verification"
92
+
hx-post="/spindles/{{ .Instance }}/retry"
93
+
hx-swap="none"
94
+
hx-headers='{"shouldRefresh": "true"}'
95
+
>
96
+
{{ i "rotate-ccw" "w-5 h-5" }}
97
+
<span class="hidden md:inline">retry</span>
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</button>
100
+
{{ end }}
101
+
102
+
103
+
{{ define "removeMemberButton" }}
104
+
{{ $root := index . 0 }}
105
+
{{ $member := index . 1 }}
106
+
<button
107
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
108
+
title="Remove member"
109
+
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
110
+
hx-swap="none"
111
+
hx-vals='{"member": "{{$member}}" }'
112
+
hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?"
113
+
>
114
+
{{ i "user-minus" "w-4 h-4" }}
115
+
remove
116
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
117
+
</button>
118
+
{{ end }}
119
+
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
···
1
+
{{ define "spindles/fragments/addMemberModal" }}
2
+
<button
3
+
class="btn gap-2 group"
4
+
title="Add member to this spindle"
5
+
popovertarget="add-member-{{ .Instance }}"
6
+
popovertargetaction="toggle"
7
+
>
8
+
{{ i "user-plus" "w-5 h-5" }}
9
+
<span class="hidden md:inline">add member</span>
10
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
11
+
</button>
12
+
13
+
<div
14
+
id="add-member-{{ .Instance }}"
15
+
popover
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
17
+
{{ block "addMemberPopover" . }} {{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "addMemberPopover" }}
22
+
<form
23
+
hx-post="/spindles/{{ .Instance }}/add"
24
+
hx-indicator="#spinner"
25
+
hx-swap="none"
26
+
class="flex flex-col gap-2"
27
+
>
28
+
<label for="member-did-{{ .Id }}" class="uppercase p-0">
29
+
ADD MEMBER
30
+
</label>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
+
<input
33
+
type="text"
34
+
id="member-did-{{ .Id }}"
35
+
name="member"
36
+
required
37
+
placeholder="@foo.bsky.social"
38
+
/>
39
+
<div class="flex gap-2 pt-2">
40
+
<button
41
+
type="button"
42
+
popovertarget="add-member-{{ .Instance }}"
43
+
popovertargetaction="hide"
44
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
45
+
>
46
+
{{ i "x" "size-4" }} cancel
47
+
</button>
48
+
<button type="submit" class="btn w-1/2 flex items-center">
49
+
<span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span>
50
+
<span id="spinner" class="group">
51
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
+
</span>
53
+
</button>
54
+
</div>
55
+
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
+
</form>
57
+
{{ end }}
+70
appview/pages/templates/spindles/fragments/spindleListing.html
+70
appview/pages/templates/spindles/fragments/spindleListing.html
···
1
+
{{ define "spindles/fragments/spindleListing" }}
2
+
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
+
{{ block "leftSide" . }} {{ end }}
4
+
{{ block "rightSide" . }} {{ end }}
5
+
</div>
6
+
{{ end }}
7
+
8
+
{{ define "leftSide" }}
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
+
{{ .Instance }}
13
+
<span class="text-gray-500">
14
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
15
+
</span>
16
+
</a>
17
+
{{ else }}
18
+
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
19
+
{{ i "hard-drive" "w-4 h-4" }}
20
+
{{ .Instance }}
21
+
<span class="text-gray-500">
22
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
23
+
</span>
24
+
</div>
25
+
{{ end }}
26
+
{{ end }}
27
+
28
+
{{ define "rightSide" }}
29
+
<div id="right-side" class="flex gap-2">
30
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
31
+
{{ if .Verified }}
32
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
33
+
{{ template "spindles/fragments/addMemberModal" . }}
34
+
{{ else }}
35
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
36
+
{{ block "retryButton" . }} {{ end }}
37
+
{{ end }}
38
+
{{ block "deleteButton" . }} {{ end }}
39
+
</div>
40
+
{{ end }}
41
+
42
+
{{ define "deleteButton" }}
43
+
<button
44
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
45
+
title="Delete spindle"
46
+
hx-delete="/spindles/{{ .Instance }}"
47
+
hx-swap="outerHTML"
48
+
hx-target="#spindle-{{.Id}}"
49
+
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
50
+
>
51
+
{{ i "trash-2" "w-5 h-5" }}
52
+
<span class="hidden md:inline">delete</span>
53
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
54
+
</button>
55
+
{{ end }}
56
+
57
+
58
+
{{ define "retryButton" }}
59
+
<button
60
+
class="btn gap-2 group"
61
+
title="Retry spindle verification"
62
+
hx-post="/spindles/{{ .Instance }}/retry"
63
+
hx-swap="none"
64
+
hx-target="#spindle-{{.Id}}"
65
+
>
66
+
{{ i "rotate-ccw" "w-5 h-5" }}
67
+
<span class="hidden md:inline">retry</span>
68
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
+
</button>
70
+
{{ end }}
+70
appview/pages/templates/spindles/index.html
+70
appview/pages/templates/spindles/index.html
···
1
+
{{ define "title" }}spindles{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
+
</div>
7
+
8
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
9
+
<div class="flex flex-col gap-6">
10
+
{{ block "all" . }} {{ end }}
11
+
{{ block "register" . }} {{ end }}
12
+
</div>
13
+
</section>
14
+
{{ end }}
15
+
16
+
{{ define "all" }}
17
+
<section class="rounded w-full flex flex-col gap-2">
18
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2>
19
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
20
+
{{ range $spindle := .Spindles }}
21
+
{{ template "spindles/fragments/spindleListing" . }}
22
+
{{ else }}
23
+
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
24
+
no spindles registered yet
25
+
</div>
26
+
{{ end }}
27
+
</div>
28
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
29
+
</section>
30
+
{{ end }}
31
+
32
+
{{ define "register" }}
33
+
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
34
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2>
35
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p>
36
+
<form
37
+
hx-post="/spindles/register"
38
+
class="max-w-2xl mb-2 space-y-4"
39
+
hx-indicator="#register-button"
40
+
hx-swap="none"
41
+
>
42
+
<div class="flex gap-2">
43
+
<input
44
+
type="text"
45
+
id="instance"
46
+
name="instance"
47
+
placeholder="spindle.example.com"
48
+
required
49
+
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"
50
+
>
51
+
<button
52
+
type="submit"
53
+
id="register-button"
54
+
class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
55
+
>
56
+
<span class="inline-flex items-center gap-2">
57
+
{{ i "plus" "w-4 h-4" }}
58
+
register
59
+
</span>
60
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
61
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
62
+
</span>
63
+
</button>
64
+
</div>
65
+
66
+
<div id="register-error" class="dark:text-red-400"></div>
67
+
</form>
68
+
69
+
</section>
70
+
{{ end }}
+15
-31
appview/pages/templates/timeline.html
+15
-31
appview/pages/templates/timeline.html
···
60
60
{{ if .Repo }}
61
61
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
62
62
<div class="flex items-center">
63
-
<p class="text-gray-600 dark:text-gray-300">
64
-
<a
65
-
href="/{{ $userHandle }}"
66
-
class="no-underline hover:underline"
67
-
>{{ $userHandle | truncateAt30 }}</a
68
-
>
63
+
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
64
+
{{ template "user/fragments/picHandleLink" $userHandle }}
69
65
{{ if .Source }}
70
66
forked
71
67
<a
72
68
href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}"
73
69
class="no-underline hover:underline"
74
70
>
75
-
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}
76
-
</a>
71
+
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a
72
+
>
77
73
to
78
74
<a
79
75
href="/{{ $userHandle }}/{{ .Repo.Name }}"
···
88
84
>{{ .Repo.Name }}</a
89
85
>
90
86
{{ end }}
91
-
<time
87
+
<span
92
88
class="text-gray-700 dark:text-gray-400 text-xs"
93
-
>{{ .Repo.Created | timeFmt }}</time
89
+
>{{ template "repo/fragments/time" .Repo.Created }}</span
94
90
>
95
91
</p>
96
92
</div>
···
98
94
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
99
95
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
100
96
<div class="flex items-center">
101
-
<p class="text-gray-600 dark:text-gray-300">
102
-
<a
103
-
href="/{{ $userHandle }}"
104
-
class="no-underline hover:underline"
105
-
>{{ $userHandle | truncateAt30 }}</a
106
-
>
97
+
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
98
+
{{ template "user/fragments/picHandleLink" $userHandle }}
107
99
followed
108
-
<a
109
-
href="/{{ $subjectHandle }}"
110
-
class="no-underline hover:underline"
111
-
>{{ $subjectHandle | truncateAt30 }}</a
112
-
>
113
-
<time
100
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
101
+
<span
114
102
class="text-gray-700 dark:text-gray-400 text-xs"
115
-
>{{ .Follow.FollowedAt | timeFmt }}</time
103
+
>{{ template "repo/fragments/time" .Follow.FollowedAt }}</span
116
104
>
117
105
</p>
118
106
</div>
···
120
108
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
121
109
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
122
110
<div class="flex items-center">
123
-
<p class="text-gray-600 dark:text-gray-300">
124
-
<a
125
-
href="/{{ $starrerHandle }}"
126
-
class="no-underline hover:underline"
127
-
>{{ $starrerHandle | truncateAt30 }}</a
128
-
>
111
+
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
112
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
129
113
starred
130
114
<a
131
115
href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}"
132
116
class="no-underline hover:underline"
133
117
>{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a
134
118
>
135
-
<time
119
+
<span
136
120
class="text-gray-700 dark:text-gray-400 text-xs"
137
-
>{{ .Star.Created | timeFmt }}</time
121
+
>{{ template "repo/fragments/time" .Star.Created }}</spa
138
122
>
139
123
</p>
140
124
</div>
+8
appview/pages/templates/user/fragments/picHandle.html
+8
appview/pages/templates/user/fragments/picHandle.html
+5
appview/pages/templates/user/fragments/picHandleLink.html
+5
appview/pages/templates/user/fragments/picHandleLink.html
+337
appview/pipelines/pipelines.go
+337
appview/pipelines/pipelines.go
···
1
+
package pipelines
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"log/slog"
8
+
"net/http"
9
+
"strings"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/appview/config"
13
+
"tangled.sh/tangled.sh/core/appview/db"
14
+
"tangled.sh/tangled.sh/core/appview/idresolver"
15
+
"tangled.sh/tangled.sh/core/appview/oauth"
16
+
"tangled.sh/tangled.sh/core/appview/pages"
17
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
+
"tangled.sh/tangled.sh/core/eventconsumer"
19
+
"tangled.sh/tangled.sh/core/log"
20
+
"tangled.sh/tangled.sh/core/rbac"
21
+
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
+
23
+
"github.com/go-chi/chi/v5"
24
+
"github.com/gorilla/websocket"
25
+
"github.com/posthog/posthog-go"
26
+
)
27
+
28
+
type Pipelines struct {
29
+
repoResolver *reporesolver.RepoResolver
30
+
idResolver *idresolver.Resolver
31
+
config *config.Config
32
+
oauth *oauth.OAuth
33
+
pages *pages.Pages
34
+
spindlestream *eventconsumer.Consumer
35
+
db *db.DB
36
+
enforcer *rbac.Enforcer
37
+
posthog posthog.Client
38
+
logger *slog.Logger
39
+
}
40
+
41
+
func New(
42
+
oauth *oauth.OAuth,
43
+
repoResolver *reporesolver.RepoResolver,
44
+
pages *pages.Pages,
45
+
spindlestream *eventconsumer.Consumer,
46
+
idResolver *idresolver.Resolver,
47
+
db *db.DB,
48
+
config *config.Config,
49
+
posthog posthog.Client,
50
+
enforcer *rbac.Enforcer,
51
+
) *Pipelines {
52
+
logger := log.New("pipelines")
53
+
54
+
return &Pipelines{oauth: oauth,
55
+
repoResolver: repoResolver,
56
+
pages: pages,
57
+
idResolver: idResolver,
58
+
config: config,
59
+
spindlestream: spindlestream,
60
+
db: db,
61
+
posthog: posthog,
62
+
enforcer: enforcer,
63
+
logger: logger,
64
+
}
65
+
}
66
+
67
+
func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) {
68
+
user := p.oauth.GetUser(r)
69
+
l := p.logger.With("handler", "Index")
70
+
71
+
f, err := p.repoResolver.Resolve(r)
72
+
if err != nil {
73
+
l.Error("failed to get repo and knot", "err", err)
74
+
return
75
+
}
76
+
77
+
repoInfo := f.RepoInfo(user)
78
+
79
+
ps, err := db.GetPipelineStatuses(
80
+
p.db,
81
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
82
+
db.FilterEq("repo_name", repoInfo.Name),
83
+
db.FilterEq("knot", repoInfo.Knot),
84
+
)
85
+
if err != nil {
86
+
l.Error("failed to query db", "err", err)
87
+
return
88
+
}
89
+
90
+
p.pages.Pipelines(w, pages.PipelinesParams{
91
+
LoggedInUser: user,
92
+
RepoInfo: repoInfo,
93
+
Pipelines: ps,
94
+
})
95
+
}
96
+
97
+
func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) {
98
+
user := p.oauth.GetUser(r)
99
+
l := p.logger.With("handler", "Workflow")
100
+
101
+
f, err := p.repoResolver.Resolve(r)
102
+
if err != nil {
103
+
l.Error("failed to get repo and knot", "err", err)
104
+
return
105
+
}
106
+
107
+
repoInfo := f.RepoInfo(user)
108
+
109
+
pipelineId := chi.URLParam(r, "pipeline")
110
+
if pipelineId == "" {
111
+
l.Error("empty pipeline ID")
112
+
return
113
+
}
114
+
115
+
workflow := chi.URLParam(r, "workflow")
116
+
if workflow == "" {
117
+
l.Error("empty workflow name")
118
+
return
119
+
}
120
+
121
+
ps, err := db.GetPipelineStatuses(
122
+
p.db,
123
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
124
+
db.FilterEq("repo_name", repoInfo.Name),
125
+
db.FilterEq("knot", repoInfo.Knot),
126
+
db.FilterEq("id", pipelineId),
127
+
)
128
+
if err != nil {
129
+
l.Error("failed to query db", "err", err)
130
+
return
131
+
}
132
+
133
+
if len(ps) != 1 {
134
+
l.Error("invalid number of pipelines", "len", len(ps))
135
+
return
136
+
}
137
+
138
+
singlePipeline := ps[0]
139
+
140
+
p.pages.Workflow(w, pages.WorkflowParams{
141
+
LoggedInUser: user,
142
+
RepoInfo: repoInfo,
143
+
Pipeline: singlePipeline,
144
+
Workflow: workflow,
145
+
})
146
+
}
147
+
148
+
var upgrader = websocket.Upgrader{
149
+
ReadBufferSize: 1024,
150
+
WriteBufferSize: 1024,
151
+
}
152
+
153
+
func (p *Pipelines) Logs(w http.ResponseWriter, r *http.Request) {
154
+
l := p.logger.With("handler", "logs")
155
+
156
+
clientConn, err := upgrader.Upgrade(w, r, nil)
157
+
if err != nil {
158
+
l.Error("websocket upgrade failed", "err", err)
159
+
return
160
+
}
161
+
defer func() {
162
+
_ = clientConn.WriteControl(
163
+
websocket.CloseMessage,
164
+
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"),
165
+
time.Now().Add(time.Second),
166
+
)
167
+
clientConn.Close()
168
+
}()
169
+
170
+
ctx, cancel := context.WithCancel(r.Context())
171
+
defer cancel()
172
+
173
+
user := p.oauth.GetUser(r)
174
+
f, err := p.repoResolver.Resolve(r)
175
+
if err != nil {
176
+
l.Error("failed to get repo and knot", "err", err)
177
+
http.Error(w, "bad repo/knot", http.StatusBadRequest)
178
+
return
179
+
}
180
+
181
+
repoInfo := f.RepoInfo(user)
182
+
183
+
pipelineId := chi.URLParam(r, "pipeline")
184
+
workflow := chi.URLParam(r, "workflow")
185
+
if pipelineId == "" || workflow == "" {
186
+
http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest)
187
+
return
188
+
}
189
+
190
+
ps, err := db.GetPipelineStatuses(
191
+
p.db,
192
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
193
+
db.FilterEq("repo_name", repoInfo.Name),
194
+
db.FilterEq("knot", repoInfo.Knot),
195
+
db.FilterEq("id", pipelineId),
196
+
)
197
+
if err != nil || len(ps) != 1 {
198
+
l.Error("pipeline query failed", "err", err, "count", len(ps))
199
+
http.Error(w, "pipeline not found", http.StatusNotFound)
200
+
return
201
+
}
202
+
203
+
singlePipeline := ps[0]
204
+
spindle := repoInfo.Spindle
205
+
knot := repoInfo.Knot
206
+
rkey := singlePipeline.Rkey
207
+
208
+
if spindle == "" || knot == "" || rkey == "" {
209
+
http.Error(w, "invalid repo info", http.StatusBadRequest)
210
+
return
211
+
}
212
+
213
+
scheme := "wss"
214
+
if p.config.Core.Dev {
215
+
scheme = "ws"
216
+
}
217
+
218
+
url := scheme + "://" + strings.Join([]string{spindle, "logs", knot, rkey, workflow}, "/")
219
+
l = l.With("url", url)
220
+
l.Info("logs endpoint hit")
221
+
222
+
spindleConn, _, err := websocket.DefaultDialer.Dial(url, nil)
223
+
if err != nil {
224
+
l.Error("websocket dial failed", "err", err)
225
+
http.Error(w, "failed to connect to log stream", http.StatusBadGateway)
226
+
return
227
+
}
228
+
defer spindleConn.Close()
229
+
230
+
// create a channel for incoming messages
231
+
evChan := make(chan logEvent, 100)
232
+
// start a goroutine to read from spindle
233
+
go readLogs(spindleConn, evChan)
234
+
235
+
stepIdx := 0
236
+
var fragment bytes.Buffer
237
+
for {
238
+
select {
239
+
case <-ctx.Done():
240
+
l.Info("client disconnected")
241
+
return
242
+
243
+
case ev, ok := <-evChan:
244
+
if !ok {
245
+
continue
246
+
}
247
+
248
+
if ev.err != nil && ev.isCloseError() {
249
+
l.Debug("graceful shutdown, tail complete", "err", err)
250
+
return
251
+
}
252
+
if ev.err != nil {
253
+
l.Error("error reading from spindle", "err", err)
254
+
return
255
+
}
256
+
257
+
var logLine spindlemodel.LogLine
258
+
if err = json.Unmarshal(ev.msg, &logLine); err != nil {
259
+
l.Error("failed to parse logline", "err", err)
260
+
continue
261
+
}
262
+
263
+
fragment.Reset()
264
+
265
+
switch logLine.Kind {
266
+
case spindlemodel.LogKindControl:
267
+
// control messages create a new step block
268
+
stepIdx++
269
+
collapsed := false
270
+
if logLine.StepKind == spindlemodel.StepKindSystem {
271
+
collapsed = true
272
+
}
273
+
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
274
+
Id: stepIdx,
275
+
Name: logLine.Content,
276
+
Command: logLine.StepCommand,
277
+
Collapsed: collapsed,
278
+
})
279
+
case spindlemodel.LogKindData:
280
+
// data messages simply insert new log lines into current step
281
+
err = p.pages.LogLine(&fragment, pages.LogLineParams{
282
+
Id: stepIdx,
283
+
Content: logLine.Content,
284
+
})
285
+
}
286
+
if err != nil {
287
+
l.Error("failed to render log line", "err", err)
288
+
return
289
+
}
290
+
291
+
if err = clientConn.WriteMessage(websocket.TextMessage, fragment.Bytes()); err != nil {
292
+
l.Error("error writing to client", "err", err)
293
+
return
294
+
}
295
+
296
+
case <-time.After(30 * time.Second):
297
+
l.Debug("sent keepalive")
298
+
if err = clientConn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
299
+
l.Error("failed to write control", "err", err)
300
+
return
301
+
}
302
+
}
303
+
}
304
+
}
305
+
306
+
// either a message or an error
307
+
type logEvent struct {
308
+
msg []byte
309
+
err error
310
+
}
311
+
312
+
func (ev *logEvent) isCloseError() bool {
313
+
return websocket.IsCloseError(
314
+
ev.err,
315
+
websocket.CloseNormalClosure,
316
+
websocket.CloseGoingAway,
317
+
websocket.CloseAbnormalClosure,
318
+
)
319
+
}
320
+
321
+
// read logs from spindle and pass through to chan
322
+
func readLogs(conn *websocket.Conn, ch chan logEvent) {
323
+
defer close(ch)
324
+
325
+
for {
326
+
if conn == nil {
327
+
return
328
+
}
329
+
330
+
_, msg, err := conn.ReadMessage()
331
+
if err != nil {
332
+
ch <- logEvent{err: err}
333
+
return
334
+
}
335
+
ch <- logEvent{msg: msg}
336
+
}
337
+
}
+17
appview/pipelines/router.go
+17
appview/pipelines/router.go
···
1
+
package pipelines
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/go-chi/chi/v5"
7
+
"tangled.sh/tangled.sh/core/appview/middleware"
8
+
)
9
+
10
+
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
11
+
r := chi.NewRouter()
12
+
r.Get("/", p.Index)
13
+
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
14
+
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
15
+
16
+
return r
17
+
}
+70
-13
appview/pulls/pulls.go
+70
-13
appview/pulls/pulls.go
···
167
167
resubmitResult = s.resubmitCheck(f, pull, stack)
168
168
}
169
169
170
+
repoInfo := f.RepoInfo(user)
171
+
172
+
m := make(map[string]db.Pipeline)
173
+
174
+
var shas []string
175
+
for _, s := range pull.Submissions {
176
+
shas = append(shas, s.SourceRev)
177
+
}
178
+
for _, p := range stack {
179
+
shas = append(shas, p.LatestSha())
180
+
}
181
+
for _, p := range abandonedPulls {
182
+
shas = append(shas, p.LatestSha())
183
+
}
184
+
185
+
ps, err := db.GetPipelineStatuses(
186
+
s.db,
187
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
188
+
db.FilterEq("repo_name", repoInfo.Name),
189
+
db.FilterEq("knot", repoInfo.Knot),
190
+
db.FilterIn("sha", shas),
191
+
)
192
+
if err != nil {
193
+
log.Printf("failed to fetch pipeline statuses: %s", err)
194
+
// non-fatal
195
+
}
196
+
197
+
for _, p := range ps {
198
+
m[p.Sha] = p
199
+
}
200
+
201
+
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
202
+
if err != nil {
203
+
log.Println("failed to get pull reactions")
204
+
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
205
+
}
206
+
207
+
userReactions := map[db.ReactionKind]bool{}
208
+
if user != nil {
209
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
210
+
}
211
+
170
212
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
171
213
LoggedInUser: user,
172
-
RepoInfo: f.RepoInfo(user),
214
+
RepoInfo: repoInfo,
173
215
DidHandleMap: didHandleMap,
174
216
Pull: pull,
175
217
Stack: stack,
176
218
AbandonedPulls: abandonedPulls,
177
219
MergeCheck: mergeCheckResponse,
178
220
ResubmitCheck: resubmitResult,
221
+
Pipelines: m,
222
+
223
+
OrderedReactionKinds: db.OrderedReactionKinds,
224
+
Reactions: reactionCountMap,
225
+
UserReacted: userReactions,
179
226
})
180
227
}
181
228
···
447
494
}
448
495
}
449
496
450
-
w.Header().Set("Content-Type", "text/plain")
497
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
451
498
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
452
499
}
453
500
···
798
845
sourceBranch string,
799
846
isStacked bool,
800
847
) {
801
-
pullSource := &db.PullSource{
802
-
Branch: sourceBranch,
803
-
}
804
-
recordPullSource := &tangled.RepoPull_Source{
805
-
Branch: sourceBranch,
806
-
}
807
-
808
848
// Generate a patch using /compare
809
849
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
810
850
if err != nil {
···
828
868
return
829
869
}
830
870
871
+
pullSource := &db.PullSource{
872
+
Branch: sourceBranch,
873
+
}
874
+
recordPullSource := &tangled.RepoPull_Source{
875
+
Branch: sourceBranch,
876
+
Sha: comparison.Rev2,
877
+
}
878
+
831
879
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
832
880
}
833
881
···
914
962
return
915
963
}
916
964
917
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
965
+
pullSource := &db.PullSource{
918
966
Branch: sourceBranch,
919
967
RepoAt: &forkAtUri,
920
-
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
968
+
}
969
+
recordPullSource := &tangled.RepoPull_Source{
970
+
Branch: sourceBranch,
971
+
Repo: &fork.AtUri,
972
+
Sha: sourceRev,
973
+
}
974
+
975
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
921
976
}
922
977
923
978
func (s *Pulls) createPullRequest(
···
934
989
) {
935
990
if isStacked {
936
991
// creates a series of PRs, each linking to the previous, identified by jj's change-id
937
-
s.createStackedPulLRequest(
992
+
s.createStackedPullRequest(
938
993
w,
939
994
r,
940
995
f,
···
1049
1104
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1050
1105
}
1051
1106
1052
-
func (s *Pulls) createStackedPulLRequest(
1107
+
func (s *Pulls) createStackedPullRequest(
1053
1108
w http.ResponseWriter,
1054
1109
r *http.Request,
1055
1110
f *reporesolver.ResolvedRepo,
···
1566
1621
if pull.IsBranchBased() {
1567
1622
recordPullSource = &tangled.RepoPull_Source{
1568
1623
Branch: pull.PullSource.Branch,
1624
+
Sha: sourceRev,
1569
1625
}
1570
1626
}
1571
1627
if pull.IsForkBased() {
···
1573
1629
recordPullSource = &tangled.RepoPull_Source{
1574
1630
Branch: pull.PullSource.Branch,
1575
1631
Repo: &repoAt,
1632
+
Sha: sourceRev,
1576
1633
}
1577
1634
}
1578
1635
+271
appview/repo/index.go
+271
appview/repo/index.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"slices"
9
+
"sort"
10
+
"strings"
11
+
12
+
"tangled.sh/tangled.sh/core/appview/commitverify"
13
+
"tangled.sh/tangled.sh/core/appview/db"
14
+
"tangled.sh/tangled.sh/core/appview/oauth"
15
+
"tangled.sh/tangled.sh/core/appview/pages"
16
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
17
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
+
"tangled.sh/tangled.sh/core/knotclient"
19
+
"tangled.sh/tangled.sh/core/types"
20
+
21
+
"github.com/go-chi/chi/v5"
22
+
"github.com/go-enry/go-enry/v2"
23
+
)
24
+
25
+
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
26
+
ref := chi.URLParam(r, "ref")
27
+
f, err := rp.repoResolver.Resolve(r)
28
+
if err != nil {
29
+
log.Println("failed to fully resolve repo", err)
30
+
return
31
+
}
32
+
33
+
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
34
+
if err != nil {
35
+
log.Printf("failed to create unsigned client for %s", f.Knot)
36
+
rp.pages.Error503(w)
37
+
return
38
+
}
39
+
40
+
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
41
+
if err != nil {
42
+
rp.pages.Error503(w)
43
+
log.Println("failed to reach knotserver", err)
44
+
return
45
+
}
46
+
47
+
tagMap := make(map[string][]string)
48
+
for _, tag := range result.Tags {
49
+
hash := tag.Hash
50
+
if tag.Tag != nil {
51
+
hash = tag.Tag.Target.String()
52
+
}
53
+
tagMap[hash] = append(tagMap[hash], tag.Name)
54
+
}
55
+
56
+
for _, branch := range result.Branches {
57
+
hash := branch.Hash
58
+
tagMap[hash] = append(tagMap[hash], branch.Name)
59
+
}
60
+
61
+
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
62
+
if a.Name == result.Ref {
63
+
return -1
64
+
}
65
+
if a.IsDefault {
66
+
return -1
67
+
}
68
+
if b.IsDefault {
69
+
return 1
70
+
}
71
+
if a.Commit != nil && b.Commit != nil {
72
+
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
73
+
return 1
74
+
} else {
75
+
return -1
76
+
}
77
+
}
78
+
return strings.Compare(a.Name, b.Name) * -1
79
+
})
80
+
81
+
commitCount := len(result.Commits)
82
+
branchCount := len(result.Branches)
83
+
tagCount := len(result.Tags)
84
+
fileCount := len(result.Files)
85
+
86
+
commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
87
+
commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
88
+
tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
89
+
branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
90
+
91
+
emails := uniqueEmails(commitsTrunc)
92
+
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
93
+
if err != nil {
94
+
log.Println("failed to get email to did map", err)
95
+
}
96
+
97
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
98
+
if err != nil {
99
+
log.Println(err)
100
+
}
101
+
102
+
user := rp.oauth.GetUser(r)
103
+
repoInfo := f.RepoInfo(user)
104
+
105
+
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
106
+
if err != nil {
107
+
log.Printf("failed to get registration key for %s: %s", f.Knot, err)
108
+
rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
109
+
}
110
+
111
+
signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
112
+
if err != nil {
113
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
114
+
return
115
+
}
116
+
117
+
var forkInfo *types.ForkInfo
118
+
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
119
+
forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
120
+
if err != nil {
121
+
log.Printf("Failed to fetch fork information: %v", err)
122
+
return
123
+
}
124
+
}
125
+
126
+
languageInfo, err := getLanguageInfo(f, signedClient, ref)
127
+
if err != nil {
128
+
log.Printf("failed to compute language percentages: %s", err)
129
+
// non-fatal
130
+
}
131
+
132
+
var shas []string
133
+
for _, c := range commitsTrunc {
134
+
shas = append(shas, c.Hash.String())
135
+
}
136
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
137
+
if err != nil {
138
+
log.Printf("failed to fetch pipeline statuses: %s", err)
139
+
// non-fatal
140
+
}
141
+
142
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
143
+
LoggedInUser: user,
144
+
RepoInfo: repoInfo,
145
+
TagMap: tagMap,
146
+
RepoIndexResponse: *result,
147
+
CommitsTrunc: commitsTrunc,
148
+
TagsTrunc: tagsTrunc,
149
+
ForkInfo: forkInfo,
150
+
BranchesTrunc: branchesTrunc,
151
+
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
152
+
VerifiedCommits: vc,
153
+
Languages: languageInfo,
154
+
Pipelines: pipelines,
155
+
})
156
+
return
157
+
}
158
+
159
+
func getLanguageInfo(
160
+
f *reporesolver.ResolvedRepo,
161
+
signedClient *knotclient.SignedClient,
162
+
ref string,
163
+
) ([]types.RepoLanguageDetails, error) {
164
+
repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref)
165
+
if err != nil {
166
+
return []types.RepoLanguageDetails{}, err
167
+
}
168
+
if repoLanguages == nil {
169
+
repoLanguages = &types.RepoLanguageResponse{Languages: make(map[string]int64)}
170
+
}
171
+
172
+
var totalSize int64
173
+
for _, fileSize := range repoLanguages.Languages {
174
+
totalSize += fileSize
175
+
}
176
+
177
+
var languageStats []types.RepoLanguageDetails
178
+
var otherPercentage float32 = 0
179
+
180
+
for lang, size := range repoLanguages.Languages {
181
+
percentage := (float32(size) / float32(totalSize)) * 100
182
+
183
+
if percentage <= 0.5 {
184
+
otherPercentage += percentage
185
+
continue
186
+
}
187
+
188
+
color := enry.GetColor(lang)
189
+
190
+
languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color})
191
+
}
192
+
193
+
sort.Slice(languageStats, func(i, j int) bool {
194
+
if languageStats[i].Name == enry.OtherLanguage {
195
+
return false
196
+
}
197
+
if languageStats[j].Name == enry.OtherLanguage {
198
+
return true
199
+
}
200
+
if languageStats[i].Percentage != languageStats[j].Percentage {
201
+
return languageStats[i].Percentage > languageStats[j].Percentage
202
+
}
203
+
return languageStats[i].Name < languageStats[j].Name
204
+
})
205
+
206
+
return languageStats, nil
207
+
}
208
+
209
+
func getForkInfo(
210
+
repoInfo repoinfo.RepoInfo,
211
+
rp *Repo,
212
+
f *reporesolver.ResolvedRepo,
213
+
user *oauth.User,
214
+
signedClient *knotclient.SignedClient,
215
+
) (*types.ForkInfo, error) {
216
+
if user == nil {
217
+
return nil, nil
218
+
}
219
+
220
+
forkInfo := types.ForkInfo{
221
+
IsFork: repoInfo.Source != nil,
222
+
Status: types.UpToDate,
223
+
}
224
+
225
+
if !forkInfo.IsFork {
226
+
forkInfo.IsFork = false
227
+
return &forkInfo, nil
228
+
}
229
+
230
+
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
231
+
if err != nil {
232
+
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
233
+
return nil, err
234
+
}
235
+
236
+
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
237
+
if err != nil {
238
+
log.Println("failed to reach knotserver", err)
239
+
return nil, err
240
+
}
241
+
242
+
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
243
+
return branch.Name == f.Ref
244
+
}) {
245
+
forkInfo.Status = types.MissingBranch
246
+
return &forkInfo, nil
247
+
}
248
+
249
+
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
250
+
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
251
+
log.Printf("failed to update tracking branch: %s", err)
252
+
return nil, err
253
+
}
254
+
255
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
256
+
257
+
var status types.AncestorCheckResponse
258
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
259
+
if err != nil {
260
+
log.Printf("failed to check if fork is ahead/behind: %s", err)
261
+
return nil, err
262
+
}
263
+
264
+
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
265
+
log.Printf("failed to decode fork status: %s", err)
266
+
return nil, err
267
+
}
268
+
269
+
forkInfo.Status = status.Status
270
+
return &forkInfo, nil
271
+
}
+155
-216
appview/repo/repo.go
+155
-216
appview/repo/repo.go
···
1
1
package repo
2
2
3
3
import (
4
+
"context"
4
5
"database/sql"
5
6
"encoding/json"
6
7
"errors"
···
25
26
"tangled.sh/tangled.sh/core/appview/oauth"
26
27
"tangled.sh/tangled.sh/core/appview/pages"
27
28
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
29
29
"tangled.sh/tangled.sh/core/appview/reporesolver"
30
+
"tangled.sh/tangled.sh/core/eventconsumer"
30
31
"tangled.sh/tangled.sh/core/knotclient"
31
32
"tangled.sh/tangled.sh/core/patchutil"
32
33
"tangled.sh/tangled.sh/core/rbac"
···
42
43
)
43
44
44
45
type Repo struct {
45
-
repoResolver *reporesolver.RepoResolver
46
-
idResolver *idresolver.Resolver
47
-
config *config.Config
48
-
oauth *oauth.OAuth
49
-
pages *pages.Pages
50
-
db *db.DB
51
-
enforcer *rbac.Enforcer
52
-
posthog posthog.Client
46
+
repoResolver *reporesolver.RepoResolver
47
+
idResolver *idresolver.Resolver
48
+
config *config.Config
49
+
oauth *oauth.OAuth
50
+
pages *pages.Pages
51
+
spindlestream *eventconsumer.Consumer
52
+
db *db.DB
53
+
enforcer *rbac.Enforcer
54
+
posthog posthog.Client
53
55
}
54
56
55
57
func New(
56
58
oauth *oauth.OAuth,
57
59
repoResolver *reporesolver.RepoResolver,
58
60
pages *pages.Pages,
61
+
spindlestream *eventconsumer.Consumer,
59
62
idResolver *idresolver.Resolver,
60
63
db *db.DB,
61
64
config *config.Config,
···
63
66
enforcer *rbac.Enforcer,
64
67
) *Repo {
65
68
return &Repo{oauth: oauth,
66
-
repoResolver: repoResolver,
67
-
pages: pages,
68
-
idResolver: idResolver,
69
-
config: config,
70
-
db: db,
71
-
posthog: posthog,
72
-
enforcer: enforcer,
73
-
}
74
-
}
75
-
76
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
77
-
ref := chi.URLParam(r, "ref")
78
-
f, err := rp.repoResolver.Resolve(r)
79
-
if err != nil {
80
-
log.Println("failed to fully resolve repo", err)
81
-
return
82
-
}
83
-
84
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
85
-
if err != nil {
86
-
log.Printf("failed to create unsigned client for %s", f.Knot)
87
-
rp.pages.Error503(w)
88
-
return
89
-
}
90
-
91
-
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
92
-
if err != nil {
93
-
rp.pages.Error503(w)
94
-
log.Println("failed to reach knotserver", err)
95
-
return
96
-
}
97
-
98
-
tagMap := make(map[string][]string)
99
-
for _, tag := range result.Tags {
100
-
hash := tag.Hash
101
-
if tag.Tag != nil {
102
-
hash = tag.Tag.Target.String()
103
-
}
104
-
tagMap[hash] = append(tagMap[hash], tag.Name)
105
-
}
106
-
107
-
for _, branch := range result.Branches {
108
-
hash := branch.Hash
109
-
tagMap[hash] = append(tagMap[hash], branch.Name)
110
-
}
111
-
112
-
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
113
-
if a.Name == result.Ref {
114
-
return -1
115
-
}
116
-
if a.IsDefault {
117
-
return -1
118
-
}
119
-
if b.IsDefault {
120
-
return 1
121
-
}
122
-
if a.Commit != nil && b.Commit != nil {
123
-
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
124
-
return 1
125
-
} else {
126
-
return -1
127
-
}
128
-
}
129
-
return strings.Compare(a.Name, b.Name) * -1
130
-
})
131
-
132
-
commitCount := len(result.Commits)
133
-
branchCount := len(result.Branches)
134
-
tagCount := len(result.Tags)
135
-
fileCount := len(result.Files)
136
-
137
-
commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
138
-
commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
139
-
tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
140
-
branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
141
-
142
-
emails := uniqueEmails(commitsTrunc)
143
-
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
144
-
if err != nil {
145
-
log.Println("failed to get email to did map", err)
146
-
}
147
-
148
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
149
-
if err != nil {
150
-
log.Println(err)
69
+
repoResolver: repoResolver,
70
+
pages: pages,
71
+
idResolver: idResolver,
72
+
config: config,
73
+
spindlestream: spindlestream,
74
+
db: db,
75
+
posthog: posthog,
76
+
enforcer: enforcer,
151
77
}
152
-
153
-
user := rp.oauth.GetUser(r)
154
-
repoInfo := f.RepoInfo(user)
155
-
156
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
157
-
if err != nil {
158
-
log.Printf("failed to get registration key for %s: %s", f.Knot, err)
159
-
rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
160
-
}
161
-
162
-
signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
163
-
if err != nil {
164
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
165
-
return
166
-
}
167
-
168
-
var forkInfo *types.ForkInfo
169
-
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
170
-
forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
171
-
if err != nil {
172
-
log.Printf("Failed to fetch fork information: %v", err)
173
-
return
174
-
}
175
-
}
176
-
177
-
repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref)
178
-
if err != nil {
179
-
log.Printf("failed to compute language percentages: %s", err)
180
-
// non-fatal
181
-
}
182
-
183
-
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
184
-
LoggedInUser: user,
185
-
RepoInfo: repoInfo,
186
-
TagMap: tagMap,
187
-
RepoIndexResponse: *result,
188
-
CommitsTrunc: commitsTrunc,
189
-
TagsTrunc: tagsTrunc,
190
-
ForkInfo: forkInfo,
191
-
BranchesTrunc: branchesTrunc,
192
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
193
-
VerifiedCommits: vc,
194
-
Languages: repoLanguages,
195
-
})
196
-
return
197
-
}
198
-
199
-
func getForkInfo(
200
-
repoInfo repoinfo.RepoInfo,
201
-
rp *Repo,
202
-
f *reporesolver.ResolvedRepo,
203
-
user *oauth.User,
204
-
signedClient *knotclient.SignedClient,
205
-
) (*types.ForkInfo, error) {
206
-
if user == nil {
207
-
return nil, nil
208
-
}
209
-
210
-
forkInfo := types.ForkInfo{
211
-
IsFork: repoInfo.Source != nil,
212
-
Status: types.UpToDate,
213
-
}
214
-
215
-
if !forkInfo.IsFork {
216
-
forkInfo.IsFork = false
217
-
return &forkInfo, nil
218
-
}
219
-
220
-
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
221
-
if err != nil {
222
-
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
223
-
return nil, err
224
-
}
225
-
226
-
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
227
-
if err != nil {
228
-
log.Println("failed to reach knotserver", err)
229
-
return nil, err
230
-
}
231
-
232
-
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
233
-
return branch.Name == f.Ref
234
-
}) {
235
-
forkInfo.Status = types.MissingBranch
236
-
return &forkInfo, nil
237
-
}
238
-
239
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
240
-
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
241
-
log.Printf("failed to update tracking branch: %s", err)
242
-
return nil, err
243
-
}
244
-
245
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
246
-
247
-
var status types.AncestorCheckResponse
248
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
249
-
if err != nil {
250
-
log.Printf("failed to check if fork is ahead/behind: %s", err)
251
-
return nil, err
252
-
}
253
-
254
-
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
255
-
log.Printf("failed to decode fork status: %s", err)
256
-
return nil, err
257
-
}
258
-
259
-
forkInfo.Status = status.Status
260
-
return &forkInfo, nil
261
78
}
262
79
263
80
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
···
316
133
log.Println(err)
317
134
}
318
135
136
+
repoInfo := f.RepoInfo(user)
137
+
138
+
var shas []string
139
+
for _, c := range repolog.Commits {
140
+
shas = append(shas, c.Hash.String())
141
+
}
142
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
143
+
if err != nil {
144
+
log.Println(err)
145
+
// non-fatal
146
+
}
147
+
319
148
rp.pages.RepoLog(w, pages.RepoLogParams{
320
149
LoggedInUser: user,
321
150
TagMap: tagMap,
322
-
RepoInfo: f.RepoInfo(user),
151
+
RepoInfo: repoInfo,
323
152
RepoLogResponse: *repolog,
324
153
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
325
154
VerifiedCommits: vc,
155
+
Pipelines: pipelines,
326
156
})
327
157
return
328
158
}
···
367
197
})
368
198
return
369
199
case http.MethodPut:
370
-
user := rp.oauth.GetUser(r)
371
200
newDescription := r.FormValue("description")
372
201
client, err := rp.oauth.AuthorizedClient(r)
373
202
if err != nil {
···
405
234
Owner: user.Did,
406
235
CreatedAt: f.CreatedAt,
407
236
Description: &newDescription,
237
+
Spindle: &f.Spindle,
408
238
},
409
239
},
410
240
})
···
473
303
}
474
304
475
305
user := rp.oauth.GetUser(r)
306
+
repoInfo := f.RepoInfo(user)
307
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
308
+
if err != nil {
309
+
log.Println(err)
310
+
// non-fatal
311
+
}
312
+
var pipeline *db.Pipeline
313
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
314
+
pipeline = &p
315
+
}
316
+
476
317
rp.pages.RepoCommit(w, pages.RepoCommitParams{
477
318
LoggedInUser: user,
478
319
RepoInfo: f.RepoInfo(user),
479
320
RepoCommitResponse: result,
480
321
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
481
322
VerifiedCommit: vc,
323
+
Pipeline: pipeline,
482
324
})
483
325
return
484
326
}
···
748
590
return
749
591
}
750
592
751
-
w.Header().Set("Content-Type", "text/plain")
593
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
752
594
w.Write([]byte(result.Contents))
753
595
return
754
596
}
755
597
598
+
// modify the spindle configured for this repo
599
+
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
600
+
f, err := rp.repoResolver.Resolve(r)
601
+
if err != nil {
602
+
log.Println("failed to get repo and knot", err)
603
+
w.WriteHeader(http.StatusBadRequest)
604
+
return
605
+
}
606
+
607
+
repoAt := f.RepoAt
608
+
rkey := repoAt.RecordKey().String()
609
+
if rkey == "" {
610
+
log.Println("invalid aturi for repo", err)
611
+
w.WriteHeader(http.StatusInternalServerError)
612
+
return
613
+
}
614
+
615
+
user := rp.oauth.GetUser(r)
616
+
617
+
newSpindle := r.FormValue("spindle")
618
+
client, err := rp.oauth.AuthorizedClient(r)
619
+
if err != nil {
620
+
log.Println("failed to get client")
621
+
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
622
+
return
623
+
}
624
+
625
+
// ensure that this is a valid spindle for this user
626
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
627
+
if err != nil {
628
+
log.Println("failed to get valid spindles")
629
+
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
630
+
return
631
+
}
632
+
633
+
if !slices.Contains(validSpindles, newSpindle) {
634
+
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
635
+
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
636
+
return
637
+
}
638
+
639
+
// optimistic update
640
+
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
641
+
if err != nil {
642
+
log.Println("failed to perform update-spindle query", err)
643
+
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
644
+
return
645
+
}
646
+
647
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
648
+
if err != nil {
649
+
// failed to get record
650
+
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
651
+
return
652
+
}
653
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
654
+
Collection: tangled.RepoNSID,
655
+
Repo: user.Did,
656
+
Rkey: rkey,
657
+
SwapRecord: ex.Cid,
658
+
Record: &lexutil.LexiconTypeDecoder{
659
+
Val: &tangled.Repo{
660
+
Knot: f.Knot,
661
+
Name: f.RepoName,
662
+
Owner: user.Did,
663
+
CreatedAt: f.CreatedAt,
664
+
Description: &f.Description,
665
+
Spindle: &newSpindle,
666
+
},
667
+
},
668
+
})
669
+
670
+
if err != nil {
671
+
log.Println("failed to perform update-spindle query", err)
672
+
// failed to get record
673
+
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.")
674
+
return
675
+
}
676
+
677
+
// add this spindle to spindle stream
678
+
rp.spindlestream.AddSource(
679
+
context.Background(),
680
+
eventconsumer.NewSpindleSource(newSpindle),
681
+
)
682
+
683
+
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
684
+
}
685
+
756
686
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
757
687
f, err := rp.repoResolver.Resolve(r)
758
688
if err != nil {
···
794
724
}
795
725
796
726
if ksResp.StatusCode != http.StatusNoContent {
797
-
w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
727
+
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
798
728
return
799
729
}
800
730
801
731
tx, err := rp.db.BeginTx(r.Context(), nil)
802
732
if err != nil {
803
733
log.Println("failed to start tx")
804
-
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
734
+
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
805
735
return
806
736
}
807
737
defer func() {
···
814
744
815
745
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
816
746
if err != nil {
817
-
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
747
+
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
818
748
return
819
749
}
820
750
821
751
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
822
752
if err != nil {
823
-
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
753
+
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
824
754
return
825
755
}
826
756
···
838
768
return
839
769
}
840
770
841
-
w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
771
+
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
842
772
843
773
}
844
774
···
897
827
tx, err := rp.db.BeginTx(r.Context(), nil)
898
828
if err != nil {
899
829
log.Println("failed to start tx")
900
-
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
830
+
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
901
831
return
902
832
}
903
833
defer func() {
···
988
918
return
989
919
}
990
920
991
-
w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
921
+
w.Write(fmt.Append(nil, "default branch set to: ", branch))
992
922
}
993
923
994
924
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
···
1027
957
return
1028
958
}
1029
959
960
+
// all spindles that this user is a member of
961
+
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
962
+
if err != nil {
963
+
log.Println("failed to fetch spindles", err)
964
+
return
965
+
}
966
+
1030
967
rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1031
968
LoggedInUser: user,
1032
969
RepoInfo: f.RepoInfo(user),
1033
970
Collaborators: repoCollaborators,
1034
971
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1035
972
Branches: result.Branches,
973
+
Spindles: spindles,
974
+
CurrentSpindle: f.Spindle,
1036
975
})
1037
976
}
1038
977
}
···
1049
988
case http.MethodPost:
1050
989
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1051
990
if err != nil {
1052
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", f.Knot))
991
+
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1053
992
return
1054
993
}
1055
994
···
1090
1029
switch r.Method {
1091
1030
case http.MethodGet:
1092
1031
user := rp.oauth.GetUser(r)
1093
-
knots, err := rp.enforcer.GetDomainsForUser(user.Did)
1032
+
knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1094
1033
if err != nil {
1095
1034
rp.pages.Notice(w, "repo", "Invalid user account.")
1096
1035
return
···
1135
1074
}
1136
1075
secret, err := db.GetRegistrationKey(rp.db, knot)
1137
1076
if err != nil {
1138
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", knot))
1077
+
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1139
1078
return
1140
1079
}
1141
1080
+35
appview/repo/repo_util.go
+35
appview/repo/repo_util.go
···
6
6
"fmt"
7
7
"math/big"
8
8
9
+
"tangled.sh/tangled.sh/core/appview/db"
10
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
11
+
9
12
"github.com/go-git/go-git/v5/plumbing/object"
10
13
)
11
14
···
98
101
99
102
return string(result)
100
103
}
104
+
105
+
// grab pipelines from DB and munge that into a hashmap with commit sha as key
106
+
//
107
+
// golang is so blessed that it requires 35 lines of imperative code for this
108
+
func getPipelineStatuses(
109
+
d *db.DB,
110
+
repoInfo repoinfo.RepoInfo,
111
+
shas []string,
112
+
) (map[string]db.Pipeline, error) {
113
+
m := make(map[string]db.Pipeline)
114
+
115
+
if len(shas) == 0 {
116
+
return m, nil
117
+
}
118
+
119
+
ps, err := db.GetPipelineStatuses(
120
+
d,
121
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
122
+
db.FilterEq("repo_name", repoInfo.Name),
123
+
db.FilterEq("knot", repoInfo.Knot),
124
+
db.FilterIn("sha", shas),
125
+
)
126
+
if err != nil {
127
+
return nil, err
128
+
}
129
+
130
+
for _, p := range ps {
131
+
m[p.Sha] = p
132
+
}
133
+
134
+
return m, nil
135
+
}
+1
appview/repo/router.go
+1
appview/repo/router.go
···
70
70
})
71
71
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
72
72
r.Get("/", rp.RepoSettings)
73
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
73
74
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
74
75
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
75
76
r.Put("/branches/default", rp.SetDefaultBranch)
+4
appview/reporesolver/resolver.go
+4
appview/reporesolver/resolver.go
···
31
31
RepoName string
32
32
RepoAt syntax.ATURI
33
33
Description string
34
+
Spindle string
34
35
CreatedAt string
35
36
Ref string
36
37
CurrentDir string
···
95
96
// pass through values from the middleware
96
97
description, ok := r.Context().Value("repoDescription").(string)
97
98
addedAt, ok := r.Context().Value("repoAddedAt").(string)
99
+
spindle, ok := r.Context().Value("repoSpindle").(string)
98
100
99
101
return &ResolvedRepo{
100
102
Knot: knot,
···
105
107
CreatedAt: addedAt,
106
108
Ref: ref,
107
109
CurrentDir: currentDir,
110
+
Spindle: spindle,
108
111
109
112
rr: rr,
110
113
}, nil
···
248
251
Ref: f.Ref,
249
252
IsStarred: isStarred,
250
253
Knot: knot,
254
+
Spindle: f.Spindle,
251
255
Roles: f.RolesInRepo(user),
252
256
Stats: db.RepoStats{
253
257
StarCount: starCount,
+711
appview/spindles/spindles.go
+711
appview/spindles/spindles.go
···
1
+
package spindles
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"slices"
9
+
"time"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/appview"
14
+
"tangled.sh/tangled.sh/core/appview/config"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/idresolver"
17
+
"tangled.sh/tangled.sh/core/appview/middleware"
18
+
"tangled.sh/tangled.sh/core/appview/oauth"
19
+
"tangled.sh/tangled.sh/core/appview/pages"
20
+
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
21
+
"tangled.sh/tangled.sh/core/rbac"
22
+
23
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
+
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
)
27
+
28
+
type Spindles struct {
29
+
Db *db.DB
30
+
OAuth *oauth.OAuth
31
+
Pages *pages.Pages
32
+
Config *config.Config
33
+
Enforcer *rbac.Enforcer
34
+
IdResolver *idresolver.Resolver
35
+
Logger *slog.Logger
36
+
}
37
+
38
+
func (s *Spindles) Router() http.Handler {
39
+
r := chi.NewRouter()
40
+
41
+
r.With(middleware.AuthMiddleware(s.OAuth)).Get("/", s.spindles)
42
+
r.With(middleware.AuthMiddleware(s.OAuth)).Post("/register", s.register)
43
+
44
+
r.With(middleware.AuthMiddleware(s.OAuth)).Get("/{instance}", s.dashboard)
45
+
r.With(middleware.AuthMiddleware(s.OAuth)).Delete("/{instance}", s.delete)
46
+
47
+
r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/retry", s.retry)
48
+
r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/add", s.addMember)
49
+
r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/remove", s.removeMember)
50
+
51
+
return r
52
+
}
53
+
54
+
func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) {
55
+
user := s.OAuth.GetUser(r)
56
+
all, err := db.GetSpindles(
57
+
s.Db,
58
+
db.FilterEq("owner", user.Did),
59
+
)
60
+
if err != nil {
61
+
s.Logger.Error("failed to fetch spindles", "err", err)
62
+
w.WriteHeader(http.StatusInternalServerError)
63
+
return
64
+
}
65
+
66
+
s.Pages.Spindles(w, pages.SpindlesParams{
67
+
LoggedInUser: user,
68
+
Spindles: all,
69
+
})
70
+
}
71
+
72
+
func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) {
73
+
l := s.Logger.With("handler", "dashboard")
74
+
75
+
user := s.OAuth.GetUser(r)
76
+
l = l.With("user", user.Did)
77
+
78
+
instance := chi.URLParam(r, "instance")
79
+
if instance == "" {
80
+
return
81
+
}
82
+
l = l.With("instance", instance)
83
+
84
+
spindles, err := db.GetSpindles(
85
+
s.Db,
86
+
db.FilterEq("instance", instance),
87
+
db.FilterEq("owner", user.Did),
88
+
db.FilterIsNot("verified", "null"),
89
+
)
90
+
if err != nil || len(spindles) != 1 {
91
+
l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles))
92
+
http.Error(w, "Not found", http.StatusNotFound)
93
+
return
94
+
}
95
+
96
+
spindle := spindles[0]
97
+
members, err := s.Enforcer.GetSpindleUsersByRole("server:member", spindle.Instance)
98
+
if err != nil {
99
+
l.Error("failed to get spindle members", "err", err)
100
+
http.Error(w, "Not found", http.StatusInternalServerError)
101
+
return
102
+
}
103
+
slices.Sort(members)
104
+
105
+
repos, err := db.GetRepos(
106
+
s.Db,
107
+
db.FilterEq("spindle", instance),
108
+
)
109
+
if err != nil {
110
+
l.Error("failed to get spindle repos", "err", err)
111
+
http.Error(w, "Not found", http.StatusInternalServerError)
112
+
return
113
+
}
114
+
115
+
identsToResolve := make([]string, len(members))
116
+
for i, member := range members {
117
+
identsToResolve[i] = member
118
+
}
119
+
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
120
+
didHandleMap := make(map[string]string)
121
+
for _, identity := range resolvedIds {
122
+
if !identity.Handle.IsInvalidHandle() {
123
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
124
+
} else {
125
+
didHandleMap[identity.DID.String()] = identity.DID.String()
126
+
}
127
+
}
128
+
129
+
// organize repos by did
130
+
repoMap := make(map[string][]db.Repo)
131
+
for _, r := range repos {
132
+
repoMap[r.Did] = append(repoMap[r.Did], r)
133
+
}
134
+
135
+
s.Pages.SpindleDashboard(w, pages.SpindleDashboardParams{
136
+
LoggedInUser: user,
137
+
Spindle: spindle,
138
+
Members: members,
139
+
Repos: repoMap,
140
+
DidHandleMap: didHandleMap,
141
+
})
142
+
}
143
+
144
+
// this endpoint inserts a record on behalf of the user to register that domain
145
+
//
146
+
// when registered, it also makes a request to see if the spindle declares this users as its owner,
147
+
// and if so, marks the spindle as verified.
148
+
//
149
+
// if the spindle is not up yet, the user is free to retry verification at a later point
150
+
func (s *Spindles) register(w http.ResponseWriter, r *http.Request) {
151
+
user := s.OAuth.GetUser(r)
152
+
l := s.Logger.With("handler", "register")
153
+
154
+
noticeId := "register-error"
155
+
defaultErr := "Failed to register spindle. Try again later."
156
+
fail := func() {
157
+
s.Pages.Notice(w, noticeId, defaultErr)
158
+
}
159
+
160
+
instance := r.FormValue("instance")
161
+
if instance == "" {
162
+
s.Pages.Notice(w, noticeId, "Incomplete form.")
163
+
return
164
+
}
165
+
l = l.With("instance", instance)
166
+
l = l.With("user", user.Did)
167
+
168
+
tx, err := s.Db.Begin()
169
+
if err != nil {
170
+
l.Error("failed to start transaction", "err", err)
171
+
fail()
172
+
return
173
+
}
174
+
defer func() {
175
+
tx.Rollback()
176
+
s.Enforcer.E.LoadPolicy()
177
+
}()
178
+
179
+
err = db.AddSpindle(tx, db.Spindle{
180
+
Owner: syntax.DID(user.Did),
181
+
Instance: instance,
182
+
})
183
+
if err != nil {
184
+
l.Error("failed to insert", "err", err)
185
+
fail()
186
+
return
187
+
}
188
+
189
+
err = s.Enforcer.AddSpindle(instance)
190
+
if err != nil {
191
+
l.Error("failed to create spindle", "err", err)
192
+
fail()
193
+
return
194
+
}
195
+
196
+
// create record on pds
197
+
client, err := s.OAuth.AuthorizedClient(r)
198
+
if err != nil {
199
+
l.Error("failed to authorize client", "err", err)
200
+
fail()
201
+
return
202
+
}
203
+
204
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
205
+
var exCid *string
206
+
if ex != nil {
207
+
exCid = ex.Cid
208
+
}
209
+
210
+
// re-announce by registering under same rkey
211
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
212
+
Collection: tangled.SpindleNSID,
213
+
Repo: user.Did,
214
+
Rkey: instance,
215
+
Record: &lexutil.LexiconTypeDecoder{
216
+
Val: &tangled.Spindle{
217
+
CreatedAt: time.Now().Format(time.RFC3339),
218
+
},
219
+
},
220
+
SwapRecord: exCid,
221
+
})
222
+
223
+
if err != nil {
224
+
l.Error("failed to put record", "err", err)
225
+
fail()
226
+
return
227
+
}
228
+
229
+
err = tx.Commit()
230
+
if err != nil {
231
+
l.Error("failed to commit transaction", "err", err)
232
+
fail()
233
+
return
234
+
}
235
+
236
+
err = s.Enforcer.E.SavePolicy()
237
+
if err != nil {
238
+
l.Error("failed to update ACL", "err", err)
239
+
s.Pages.HxRefresh(w)
240
+
return
241
+
}
242
+
243
+
// begin verification
244
+
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
245
+
if err != nil {
246
+
l.Error("verification failed", "err", err)
247
+
s.Pages.HxRefresh(w)
248
+
return
249
+
}
250
+
251
+
_, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
252
+
if err != nil {
253
+
l.Error("failed to mark verified", "err", err)
254
+
s.Pages.HxRefresh(w)
255
+
return
256
+
}
257
+
258
+
// ok
259
+
s.Pages.HxRefresh(w)
260
+
return
261
+
}
262
+
263
+
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
264
+
user := s.OAuth.GetUser(r)
265
+
l := s.Logger.With("handler", "delete")
266
+
267
+
noticeId := "operation-error"
268
+
defaultErr := "Failed to delete spindle. Try again later."
269
+
fail := func() {
270
+
s.Pages.Notice(w, noticeId, defaultErr)
271
+
}
272
+
273
+
instance := chi.URLParam(r, "instance")
274
+
if instance == "" {
275
+
l.Error("empty instance")
276
+
fail()
277
+
return
278
+
}
279
+
280
+
spindles, err := db.GetSpindles(
281
+
s.Db,
282
+
db.FilterEq("owner", user.Did),
283
+
db.FilterEq("instance", instance),
284
+
)
285
+
if err != nil || len(spindles) != 1 {
286
+
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
287
+
fail()
288
+
return
289
+
}
290
+
291
+
if string(spindles[0].Owner) != user.Did {
292
+
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
293
+
s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.")
294
+
return
295
+
}
296
+
297
+
tx, err := s.Db.Begin()
298
+
if err != nil {
299
+
l.Error("failed to start txn", "err", err)
300
+
fail()
301
+
return
302
+
}
303
+
defer func() {
304
+
tx.Rollback()
305
+
s.Enforcer.E.LoadPolicy()
306
+
}()
307
+
308
+
err = db.DeleteSpindle(
309
+
tx,
310
+
db.FilterEq("owner", user.Did),
311
+
db.FilterEq("instance", instance),
312
+
)
313
+
if err != nil {
314
+
l.Error("failed to delete spindle", "err", err)
315
+
fail()
316
+
return
317
+
}
318
+
319
+
err = s.Enforcer.RemoveSpindle(instance)
320
+
if err != nil {
321
+
l.Error("failed to update ACL", "err", err)
322
+
fail()
323
+
return
324
+
}
325
+
326
+
client, err := s.OAuth.AuthorizedClient(r)
327
+
if err != nil {
328
+
l.Error("failed to authorize client", "err", err)
329
+
fail()
330
+
return
331
+
}
332
+
333
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
334
+
Collection: tangled.SpindleNSID,
335
+
Repo: user.Did,
336
+
Rkey: instance,
337
+
})
338
+
if err != nil {
339
+
// non-fatal
340
+
l.Error("failed to delete record", "err", err)
341
+
}
342
+
343
+
err = tx.Commit()
344
+
if err != nil {
345
+
l.Error("failed to delete spindle", "err", err)
346
+
fail()
347
+
return
348
+
}
349
+
350
+
err = s.Enforcer.E.SavePolicy()
351
+
if err != nil {
352
+
l.Error("failed to update ACL", "err", err)
353
+
s.Pages.HxRefresh(w)
354
+
return
355
+
}
356
+
357
+
shouldRedirect := r.Header.Get("shouldRedirect")
358
+
if shouldRedirect == "true" {
359
+
s.Pages.HxRedirect(w, "/spindles")
360
+
return
361
+
}
362
+
363
+
w.Write([]byte{})
364
+
}
365
+
366
+
func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) {
367
+
user := s.OAuth.GetUser(r)
368
+
l := s.Logger.With("handler", "retry")
369
+
370
+
noticeId := "operation-error"
371
+
defaultErr := "Failed to verify spindle. Try again later."
372
+
fail := func() {
373
+
s.Pages.Notice(w, noticeId, defaultErr)
374
+
}
375
+
376
+
instance := chi.URLParam(r, "instance")
377
+
if instance == "" {
378
+
l.Error("empty instance")
379
+
fail()
380
+
return
381
+
}
382
+
l = l.With("instance", instance)
383
+
l = l.With("user", user.Did)
384
+
385
+
spindles, err := db.GetSpindles(
386
+
s.Db,
387
+
db.FilterEq("owner", user.Did),
388
+
db.FilterEq("instance", instance),
389
+
)
390
+
if err != nil || len(spindles) != 1 {
391
+
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
392
+
fail()
393
+
return
394
+
}
395
+
396
+
if string(spindles[0].Owner) != user.Did {
397
+
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
398
+
s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.")
399
+
return
400
+
}
401
+
402
+
// begin verification
403
+
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
404
+
if err != nil {
405
+
l.Error("verification failed", "err", err)
406
+
407
+
if errors.Is(err, verify.FetchError) {
408
+
s.Pages.Notice(w, noticeId, err.Error())
409
+
return
410
+
}
411
+
412
+
if e, ok := err.(*verify.OwnerMismatch); ok {
413
+
s.Pages.Notice(w, noticeId, e.Error())
414
+
return
415
+
}
416
+
417
+
fail()
418
+
return
419
+
}
420
+
421
+
rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
422
+
if err != nil {
423
+
l.Error("failed to mark verified", "err", err)
424
+
s.Pages.Notice(w, noticeId, err.Error())
425
+
return
426
+
}
427
+
428
+
verifiedSpindle, err := db.GetSpindles(
429
+
s.Db,
430
+
db.FilterEq("id", rowId),
431
+
)
432
+
if err != nil || len(verifiedSpindle) != 1 {
433
+
l.Error("failed get new spindle", "err", err)
434
+
s.Pages.HxRefresh(w)
435
+
return
436
+
}
437
+
438
+
shouldRefresh := r.Header.Get("shouldRefresh")
439
+
if shouldRefresh == "true" {
440
+
s.Pages.HxRefresh(w)
441
+
return
442
+
}
443
+
444
+
w.Header().Set("HX-Reswap", "outerHTML")
445
+
s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]})
446
+
}
447
+
448
+
func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
449
+
user := s.OAuth.GetUser(r)
450
+
l := s.Logger.With("handler", "addMember")
451
+
452
+
instance := chi.URLParam(r, "instance")
453
+
if instance == "" {
454
+
l.Error("empty instance")
455
+
http.Error(w, "Not found", http.StatusNotFound)
456
+
return
457
+
}
458
+
l = l.With("instance", instance)
459
+
l = l.With("user", user.Did)
460
+
461
+
spindles, err := db.GetSpindles(
462
+
s.Db,
463
+
db.FilterEq("owner", user.Did),
464
+
db.FilterEq("instance", instance),
465
+
)
466
+
if err != nil || len(spindles) != 1 {
467
+
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
468
+
http.Error(w, "Not found", http.StatusNotFound)
469
+
return
470
+
}
471
+
472
+
noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id)
473
+
defaultErr := "Failed to add member. Try again later."
474
+
fail := func() {
475
+
s.Pages.Notice(w, noticeId, defaultErr)
476
+
}
477
+
478
+
if string(spindles[0].Owner) != user.Did {
479
+
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
480
+
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
481
+
return
482
+
}
483
+
484
+
member := r.FormValue("member")
485
+
if member == "" {
486
+
l.Error("empty member")
487
+
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
488
+
return
489
+
}
490
+
l = l.With("member", member)
491
+
492
+
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
493
+
if err != nil {
494
+
l.Error("failed to resolve member identity to handle", "err", err)
495
+
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
496
+
return
497
+
}
498
+
if memberId.Handle.IsInvalidHandle() {
499
+
l.Error("failed to resolve member identity to handle")
500
+
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
501
+
return
502
+
}
503
+
504
+
// write to pds
505
+
client, err := s.OAuth.AuthorizedClient(r)
506
+
if err != nil {
507
+
l.Error("failed to authorize client", "err", err)
508
+
fail()
509
+
return
510
+
}
511
+
512
+
tx, err := s.Db.Begin()
513
+
if err != nil {
514
+
l.Error("failed to start txn", "err", err)
515
+
fail()
516
+
return
517
+
}
518
+
defer func() {
519
+
tx.Rollback()
520
+
s.Enforcer.E.LoadPolicy()
521
+
}()
522
+
523
+
rkey := appview.TID()
524
+
525
+
// add member to db
526
+
if err = db.AddSpindleMember(tx, db.SpindleMember{
527
+
Did: syntax.DID(user.Did),
528
+
Rkey: rkey,
529
+
Instance: instance,
530
+
Subject: memberId.DID,
531
+
}); err != nil {
532
+
l.Error("failed to add spindle member", "err", err)
533
+
fail()
534
+
return
535
+
}
536
+
537
+
if err = s.Enforcer.AddSpindleMember(instance, memberId.DID.String()); err != nil {
538
+
l.Error("failed to add member to ACLs")
539
+
fail()
540
+
return
541
+
}
542
+
543
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
544
+
Collection: tangled.SpindleMemberNSID,
545
+
Repo: user.Did,
546
+
Rkey: rkey,
547
+
Record: &lexutil.LexiconTypeDecoder{
548
+
Val: &tangled.SpindleMember{
549
+
CreatedAt: time.Now().Format(time.RFC3339),
550
+
Instance: instance,
551
+
Subject: memberId.DID.String(),
552
+
},
553
+
},
554
+
})
555
+
if err != nil {
556
+
l.Error("failed to add record to PDS", "err", err)
557
+
s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
558
+
return
559
+
}
560
+
561
+
if err = tx.Commit(); err != nil {
562
+
l.Error("failed to commit txn", "err", err)
563
+
fail()
564
+
return
565
+
}
566
+
567
+
if err = s.Enforcer.E.SavePolicy(); err != nil {
568
+
l.Error("failed to add member to ACLs", "err", err)
569
+
fail()
570
+
return
571
+
}
572
+
573
+
// success
574
+
s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance))
575
+
}
576
+
577
+
func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
578
+
user := s.OAuth.GetUser(r)
579
+
l := s.Logger.With("handler", "removeMember")
580
+
581
+
noticeId := "operation-error"
582
+
defaultErr := "Failed to add member. Try again later."
583
+
fail := func() {
584
+
s.Pages.Notice(w, noticeId, defaultErr)
585
+
}
586
+
587
+
instance := chi.URLParam(r, "instance")
588
+
if instance == "" {
589
+
l.Error("empty instance")
590
+
fail()
591
+
return
592
+
}
593
+
l = l.With("instance", instance)
594
+
l = l.With("user", user.Did)
595
+
596
+
spindles, err := db.GetSpindles(
597
+
s.Db,
598
+
db.FilterEq("owner", user.Did),
599
+
db.FilterEq("instance", instance),
600
+
)
601
+
if err != nil || len(spindles) != 1 {
602
+
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
603
+
fail()
604
+
return
605
+
}
606
+
607
+
if string(spindles[0].Owner) != user.Did {
608
+
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
609
+
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
610
+
return
611
+
}
612
+
613
+
member := r.FormValue("member")
614
+
if member == "" {
615
+
l.Error("empty member")
616
+
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
617
+
return
618
+
}
619
+
l = l.With("member", member)
620
+
621
+
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
622
+
if err != nil {
623
+
l.Error("failed to resolve member identity to handle", "err", err)
624
+
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
625
+
return
626
+
}
627
+
if memberId.Handle.IsInvalidHandle() {
628
+
l.Error("failed to resolve member identity to handle")
629
+
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
630
+
return
631
+
}
632
+
633
+
tx, err := s.Db.Begin()
634
+
if err != nil {
635
+
l.Error("failed to start txn", "err", err)
636
+
fail()
637
+
return
638
+
}
639
+
defer func() {
640
+
tx.Rollback()
641
+
s.Enforcer.E.LoadPolicy()
642
+
}()
643
+
644
+
// get the record from the DB first:
645
+
members, err := db.GetSpindleMembers(
646
+
s.Db,
647
+
db.FilterEq("did", user.Did),
648
+
db.FilterEq("instance", instance),
649
+
db.FilterEq("subject", memberId.DID),
650
+
)
651
+
if err != nil || len(members) != 1 {
652
+
l.Error("failed to get member", "err", err)
653
+
fail()
654
+
return
655
+
}
656
+
657
+
// remove from db
658
+
if err = db.RemoveSpindleMember(
659
+
tx,
660
+
db.FilterEq("did", user.Did),
661
+
db.FilterEq("instance", instance),
662
+
db.FilterEq("subject", memberId.DID),
663
+
); err != nil {
664
+
l.Error("failed to remove spindle member", "err", err)
665
+
fail()
666
+
return
667
+
}
668
+
669
+
// remove from enforcer
670
+
if err = s.Enforcer.RemoveSpindleMember(instance, memberId.DID.String()); err != nil {
671
+
l.Error("failed to update ACLs", "err", err)
672
+
fail()
673
+
return
674
+
}
675
+
676
+
client, err := s.OAuth.AuthorizedClient(r)
677
+
if err != nil {
678
+
l.Error("failed to authorize client", "err", err)
679
+
fail()
680
+
return
681
+
}
682
+
683
+
// remove from pds
684
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
685
+
Collection: tangled.SpindleMemberNSID,
686
+
Repo: user.Did,
687
+
Rkey: members[0].Rkey,
688
+
})
689
+
if err != nil {
690
+
// non-fatal
691
+
l.Error("failed to delete record", "err", err)
692
+
}
693
+
694
+
// commit everything
695
+
if err = tx.Commit(); err != nil {
696
+
l.Error("failed to commit txn", "err", err)
697
+
fail()
698
+
return
699
+
}
700
+
701
+
// commit everything
702
+
if err = s.Enforcer.E.SavePolicy(); err != nil {
703
+
l.Error("failed to save ACLs", "err", err)
704
+
fail()
705
+
return
706
+
}
707
+
708
+
// ok
709
+
s.Pages.HxRefresh(w)
710
+
return
711
+
}
+118
appview/spindleverify/verify.go
+118
appview/spindleverify/verify.go
···
1
+
package spindleverify
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"strings"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/appview/db"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
)
15
+
16
+
var (
17
+
FetchError = errors.New("failed to fetch owner")
18
+
)
19
+
20
+
// TODO: move this to "spindleclient" or similar
21
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
22
+
scheme := "https"
23
+
if dev {
24
+
scheme = "http"
25
+
}
26
+
27
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
+
req, err := http.NewRequest("GET", url, nil)
29
+
if err != nil {
30
+
return "", err
31
+
}
32
+
33
+
client := &http.Client{
34
+
Timeout: 1 * time.Second,
35
+
}
36
+
37
+
resp, err := client.Do(req.WithContext(ctx))
38
+
if err != nil || resp.StatusCode != 200 {
39
+
return "", fmt.Errorf("failed to fetch /owner")
40
+
}
41
+
42
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
+
if err != nil {
44
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
45
+
}
46
+
47
+
did := strings.TrimSpace(string(body))
48
+
if did == "" {
49
+
return "", fmt.Errorf("empty DID in /owner response")
50
+
}
51
+
52
+
return did, nil
53
+
}
54
+
55
+
type OwnerMismatch struct {
56
+
expected string
57
+
observed string
58
+
}
59
+
60
+
func (e *OwnerMismatch) Error() string {
61
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
62
+
}
63
+
64
+
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
65
+
// begin verification
66
+
observedOwner, err := fetchOwner(ctx, instance, dev)
67
+
if err != nil {
68
+
return fmt.Errorf("%w: %w", FetchError, err)
69
+
}
70
+
71
+
if observedOwner != expectedOwner {
72
+
return &OwnerMismatch{
73
+
expected: expectedOwner,
74
+
observed: observedOwner,
75
+
}
76
+
}
77
+
78
+
return nil
79
+
}
80
+
81
+
// mark this spindle as verified in the DB and add this user as its owner
82
+
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
83
+
tx, err := d.Begin()
84
+
if err != nil {
85
+
return 0, fmt.Errorf("failed to create txn: %w", err)
86
+
}
87
+
defer func() {
88
+
tx.Rollback()
89
+
e.E.LoadPolicy()
90
+
}()
91
+
92
+
// mark this spindle as verified in the db
93
+
rowId, err := db.VerifySpindle(
94
+
tx,
95
+
db.FilterEq("owner", owner),
96
+
db.FilterEq("instance", instance),
97
+
)
98
+
if err != nil {
99
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
100
+
}
101
+
102
+
err = e.AddSpindleOwner(instance, owner)
103
+
if err != nil {
104
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
105
+
}
106
+
107
+
err = tx.Commit()
108
+
if err != nil {
109
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
110
+
}
111
+
112
+
err = e.E.SavePolicy()
113
+
if err != nil {
114
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
115
+
}
116
+
117
+
return rowId, nil
118
+
}
+84
-15
appview/state/knotstream.go
+84
-15
appview/state/knotstream.go
···
1
1
package state
2
2
3
3
import (
4
+
"context"
4
5
"encoding/json"
5
6
"fmt"
6
7
"slices"
···
10
11
"tangled.sh/tangled.sh/core/appview/cache"
11
12
"tangled.sh/tangled.sh/core/appview/config"
12
13
"tangled.sh/tangled.sh/core/appview/db"
13
-
kc "tangled.sh/tangled.sh/core/knotclient"
14
+
ec "tangled.sh/tangled.sh/core/eventconsumer"
15
+
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
14
16
"tangled.sh/tangled.sh/core/log"
15
17
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/workflow"
16
19
20
+
"github.com/bluesky-social/indigo/atproto/syntax"
17
21
"github.com/posthog/posthog-go"
18
22
)
19
23
20
-
func KnotstreamConsumer(c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*kc.EventConsumer, error) {
24
+
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
21
25
knots, err := db.GetCompletedRegistrations(d)
22
26
if err != nil {
23
27
return nil, err
24
28
}
25
29
26
-
srcs := make(map[kc.EventSource]struct{})
30
+
srcs := make(map[ec.Source]struct{})
27
31
for _, k := range knots {
28
-
s := kc.EventSource{k}
32
+
s := ec.NewKnotSource(k)
29
33
srcs[s] = struct{}{}
30
34
}
31
35
32
36
logger := log.New("knotstream")
33
37
cache := cache.New(c.Redis.Addr)
34
-
cursorStore := kc.NewRedisCursorStore(cache)
38
+
cursorStore := cursor.NewRedisCursorStore(cache)
35
39
36
-
cfg := kc.ConsumerConfig{
40
+
cfg := ec.ConsumerConfig{
37
41
Sources: srcs,
38
-
ProcessFunc: knotstreamIngester(d, enforcer, posthog, c.Core.Dev),
42
+
ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev),
39
43
RetryInterval: c.Knotstream.RetryInterval,
40
44
MaxRetryInterval: c.Knotstream.MaxRetryInterval,
41
45
ConnectionTimeout: c.Knotstream.ConnectionTimeout,
···
46
50
CursorStore: &cursorStore,
47
51
}
48
52
49
-
return kc.NewEventConsumer(cfg), nil
53
+
return ec.NewConsumer(cfg), nil
50
54
}
51
55
52
-
func knotstreamIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) kc.ProcessFunc {
53
-
return func(source kc.EventSource, msg kc.Message) error {
56
+
func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc {
57
+
return func(ctx context.Context, source ec.Source, msg ec.Message) error {
54
58
switch msg.Nsid {
55
59
case tangled.GitRefUpdateNSID:
56
60
return ingestRefUpdate(d, enforcer, posthog, dev, source, msg)
57
61
case tangled.PipelineNSID:
58
-
// TODO
62
+
return ingestPipeline(d, source, msg)
59
63
}
60
64
61
65
return nil
62
66
}
63
67
}
64
68
65
-
func ingestRefUpdate(d *db.DB, enforcer *rbac.Enforcer, pc posthog.Client, dev bool, source kc.EventSource, msg kc.Message) error {
69
+
func ingestRefUpdate(d *db.DB, enforcer *rbac.Enforcer, pc posthog.Client, dev bool, source ec.Source, msg ec.Message) error {
66
70
var record tangled.GitRefUpdate
67
71
err := json.Unmarshal(msg.EventJson, &record)
68
72
if err != nil {
69
73
return err
70
74
}
71
75
72
-
knownKnots, err := enforcer.GetDomainsForUser(record.CommitterDid)
76
+
knownKnots, err := enforcer.GetKnotsForUser(record.CommitterDid)
73
77
if err != nil {
74
78
return err
75
79
}
76
-
if !slices.Contains(knownKnots, source.Knot) {
77
-
return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Knot)
80
+
if !slices.Contains(knownKnots, source.Key()) {
81
+
return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key())
78
82
}
79
83
80
84
knownEmails, err := db.GetAllEmails(d, record.CommitterDid)
···
120
124
121
125
return nil
122
126
}
127
+
128
+
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
129
+
var record tangled.Pipeline
130
+
err := json.Unmarshal(msg.EventJson, &record)
131
+
if err != nil {
132
+
return err
133
+
}
134
+
135
+
if record.TriggerMetadata == nil {
136
+
return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
137
+
}
138
+
139
+
if record.TriggerMetadata.Repo == nil {
140
+
return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
141
+
}
142
+
143
+
// trigger info
144
+
var trigger db.Trigger
145
+
var sha string
146
+
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
147
+
switch trigger.Kind {
148
+
case workflow.TriggerKindPush:
149
+
trigger.PushRef = &record.TriggerMetadata.Push.Ref
150
+
trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha
151
+
trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha
152
+
sha = *trigger.PushNewSha
153
+
case workflow.TriggerKindPullRequest:
154
+
trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch
155
+
trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch
156
+
trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha
157
+
trigger.PRAction = &record.TriggerMetadata.PullRequest.Action
158
+
sha = *trigger.PRSourceSha
159
+
}
160
+
161
+
tx, err := d.Begin()
162
+
if err != nil {
163
+
return fmt.Errorf("failed to start txn: %w", err)
164
+
}
165
+
166
+
triggerId, err := db.AddTrigger(tx, trigger)
167
+
if err != nil {
168
+
return fmt.Errorf("failed to add trigger entry: %w", err)
169
+
}
170
+
171
+
pipeline := db.Pipeline{
172
+
Rkey: msg.Rkey,
173
+
Knot: source.Key(),
174
+
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
175
+
RepoName: record.TriggerMetadata.Repo.Repo,
176
+
TriggerId: int(triggerId),
177
+
Sha: sha,
178
+
}
179
+
180
+
err = db.AddPipeline(tx, pipeline)
181
+
if err != nil {
182
+
return fmt.Errorf("failed to add pipeline: %w", err)
183
+
}
184
+
185
+
err = tx.Commit()
186
+
if err != nil {
187
+
return fmt.Errorf("failed to commit txn: %w", err)
188
+
}
189
+
190
+
return nil
191
+
}
+126
appview/state/reaction.go
+126
appview/state/reaction.go
···
1
+
package state
2
+
3
+
import (
4
+
"log"
5
+
"net/http"
6
+
"time"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
11
+
lexutil "github.com/bluesky-social/indigo/lex/util"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/appview"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/pages"
16
+
)
17
+
18
+
func (s *State) React(w http.ResponseWriter, r *http.Request) {
19
+
currentUser := s.oauth.GetUser(r)
20
+
21
+
subject := r.URL.Query().Get("subject")
22
+
if subject == "" {
23
+
log.Println("invalid form")
24
+
return
25
+
}
26
+
27
+
subjectUri, err := syntax.ParseATURI(subject)
28
+
if err != nil {
29
+
log.Println("invalid form")
30
+
return
31
+
}
32
+
33
+
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
34
+
if !ok {
35
+
log.Println("invalid reaction kind")
36
+
return
37
+
}
38
+
39
+
client, err := s.oauth.AuthorizedClient(r)
40
+
if err != nil {
41
+
log.Println("failed to authorize client", err)
42
+
return
43
+
}
44
+
45
+
switch r.Method {
46
+
case http.MethodPost:
47
+
createdAt := time.Now().Format(time.RFC3339)
48
+
rkey := appview.TID()
49
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
+
Collection: tangled.FeedReactionNSID,
51
+
Repo: currentUser.Did,
52
+
Rkey: rkey,
53
+
Record: &lexutil.LexiconTypeDecoder{
54
+
Val: &tangled.FeedReaction{
55
+
Subject: subjectUri.String(),
56
+
Reaction: reactionKind.String(),
57
+
CreatedAt: createdAt,
58
+
},
59
+
},
60
+
})
61
+
if err != nil {
62
+
log.Println("failed to create atproto record", err)
63
+
return
64
+
}
65
+
66
+
err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey)
67
+
if err != nil {
68
+
log.Println("failed to react", err)
69
+
return
70
+
}
71
+
72
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
73
+
if err != nil {
74
+
log.Println("failed to get reaction count for ", subjectUri)
75
+
}
76
+
77
+
log.Println("created atproto record: ", resp.Uri)
78
+
79
+
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
80
+
ThreadAt: subjectUri,
81
+
Kind: reactionKind,
82
+
Count: count,
83
+
IsReacted: true,
84
+
})
85
+
86
+
return
87
+
case http.MethodDelete:
88
+
reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind)
89
+
if err != nil {
90
+
log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri)
91
+
return
92
+
}
93
+
94
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
95
+
Collection: tangled.FeedReactionNSID,
96
+
Repo: currentUser.Did,
97
+
Rkey: reaction.Rkey,
98
+
})
99
+
100
+
if err != nil {
101
+
log.Println("failed to remove reaction")
102
+
return
103
+
}
104
+
105
+
err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey)
106
+
if err != nil {
107
+
log.Println("failed to delete reaction from DB")
108
+
// this is not an issue, the firehose event might have already done this
109
+
}
110
+
111
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
112
+
if err != nil {
113
+
log.Println("failed to get reaction count for ", subjectUri)
114
+
return
115
+
}
116
+
117
+
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
118
+
ThreadAt: subjectUri,
119
+
Kind: reactionKind,
120
+
Count: count,
121
+
IsReacted: false,
122
+
})
123
+
124
+
return
125
+
}
126
+
}
+32
-1
appview/state/router.go
+32
-1
appview/state/router.go
···
9
9
"tangled.sh/tangled.sh/core/appview/issues"
10
10
"tangled.sh/tangled.sh/core/appview/middleware"
11
11
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
12
+
"tangled.sh/tangled.sh/core/appview/pipelines"
12
13
"tangled.sh/tangled.sh/core/appview/pulls"
13
14
"tangled.sh/tangled.sh/core/appview/repo"
14
15
"tangled.sh/tangled.sh/core/appview/settings"
16
+
"tangled.sh/tangled.sh/core/appview/spindles"
15
17
"tangled.sh/tangled.sh/core/appview/state/userutil"
18
+
"tangled.sh/tangled.sh/core/log"
16
19
)
17
20
18
21
func (s *State) Router() http.Handler {
···
74
77
r.Mount("/", s.RepoRouter(mw))
75
78
r.Mount("/issues", s.IssuesRouter(mw))
76
79
r.Mount("/pulls", s.PullsRouter(mw))
80
+
r.Mount("/pipelines", s.PipelinesRouter(mw))
77
81
78
82
// These routes get proxied to the knot
79
83
r.Get("/info/refs", s.InfoRefs)
···
131
135
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
132
136
r.Post("/", s.Star)
133
137
r.Delete("/", s.Star)
138
+
})
139
+
140
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) {
141
+
r.Post("/", s.React)
142
+
r.Delete("/", s.React)
134
143
})
135
144
136
145
r.Route("/profile", func(r chi.Router) {
···
142
151
})
143
152
144
153
r.Mount("/settings", s.SettingsRouter())
154
+
r.Mount("/spindles", s.SpindlesRouter())
145
155
r.Mount("/", s.OAuthRouter())
146
156
147
157
r.Get("/keys/{user}", s.Keys)
···
169
179
return settings.Router()
170
180
}
171
181
182
+
func (s *State) SpindlesRouter() http.Handler {
183
+
logger := log.New("spindles")
184
+
185
+
spindles := &spindles.Spindles{
186
+
Db: s.db,
187
+
OAuth: s.oauth,
188
+
Pages: s.pages,
189
+
Config: s.config,
190
+
Enforcer: s.enforcer,
191
+
IdResolver: s.idResolver,
192
+
Logger: logger,
193
+
}
194
+
195
+
return spindles.Router()
196
+
}
197
+
172
198
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
173
199
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
174
200
return issues.Router(mw)
···
181
207
}
182
208
183
209
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
184
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
210
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
185
211
return repo.Router(mw)
186
212
}
213
+
214
+
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
215
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
216
+
return pipes.Router(mw)
217
+
}
+110
appview/state/spindlestream.go
+110
appview/state/spindlestream.go
···
1
+
package state
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"log/slog"
8
+
"strings"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/appview/cache"
14
+
"tangled.sh/tangled.sh/core/appview/config"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
ec "tangled.sh/tangled.sh/core/eventconsumer"
17
+
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
18
+
"tangled.sh/tangled.sh/core/log"
19
+
"tangled.sh/tangled.sh/core/rbac"
20
+
spindle "tangled.sh/tangled.sh/core/spindle/models"
21
+
)
22
+
23
+
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
24
+
spindles, err := db.GetSpindles(
25
+
d,
26
+
db.FilterIsNot("verified", "null"),
27
+
)
28
+
if err != nil {
29
+
return nil, err
30
+
}
31
+
32
+
srcs := make(map[ec.Source]struct{})
33
+
for _, s := range spindles {
34
+
src := ec.NewSpindleSource(s.Instance)
35
+
srcs[src] = struct{}{}
36
+
}
37
+
38
+
logger := log.New("spindlestream")
39
+
cache := cache.New(c.Redis.Addr)
40
+
cursorStore := cursor.NewRedisCursorStore(cache)
41
+
42
+
cfg := ec.ConsumerConfig{
43
+
Sources: srcs,
44
+
ProcessFunc: spindleIngester(ctx, logger, d),
45
+
RetryInterval: c.Spindlestream.RetryInterval,
46
+
MaxRetryInterval: c.Spindlestream.MaxRetryInterval,
47
+
ConnectionTimeout: c.Spindlestream.ConnectionTimeout,
48
+
WorkerCount: c.Spindlestream.WorkerCount,
49
+
QueueSize: c.Spindlestream.QueueSize,
50
+
Logger: logger,
51
+
Dev: c.Core.Dev,
52
+
CursorStore: &cursorStore,
53
+
}
54
+
55
+
return ec.NewConsumer(cfg), nil
56
+
}
57
+
58
+
func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc {
59
+
return func(ctx context.Context, source ec.Source, msg ec.Message) error {
60
+
switch msg.Nsid {
61
+
case tangled.PipelineStatusNSID:
62
+
return ingestPipelineStatus(ctx, logger, d, source, msg)
63
+
}
64
+
65
+
return nil
66
+
}
67
+
}
68
+
69
+
func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
70
+
var record tangled.PipelineStatus
71
+
err := json.Unmarshal(msg.EventJson, &record)
72
+
if err != nil {
73
+
return err
74
+
}
75
+
76
+
pipelineUri, err := syntax.ParseATURI(record.Pipeline)
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
exitCode := 0
82
+
if record.ExitCode != nil {
83
+
exitCode = int(*record.ExitCode)
84
+
}
85
+
86
+
// pick the record creation time if possible, or use time.Now
87
+
created := time.Now()
88
+
if t, err := time.Parse(time.RFC3339, record.CreatedAt); err == nil && created.After(t) {
89
+
created = t
90
+
}
91
+
92
+
status := db.PipelineStatus{
93
+
Spindle: source.Key(),
94
+
Rkey: msg.Rkey,
95
+
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
96
+
PipelineRkey: pipelineUri.RecordKey().String(),
97
+
Created: created,
98
+
Workflow: record.Workflow,
99
+
Status: spindle.StatusKind(record.Status),
100
+
Error: record.Error,
101
+
ExitCode: exitCode,
102
+
}
103
+
104
+
err = db.AddPipelineStatus(d, status)
105
+
if err != nil {
106
+
return fmt.Errorf("failed to add pipeline status: %w", err)
107
+
}
108
+
109
+
return nil
110
+
}
+47
-24
appview/state/state.go
+47
-24
appview/state/state.go
···
28
28
"tangled.sh/tangled.sh/core/appview/oauth"
29
29
"tangled.sh/tangled.sh/core/appview/pages"
30
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
"tangled.sh/tangled.sh/core/eventconsumer"
31
32
"tangled.sh/tangled.sh/core/jetstream"
32
33
"tangled.sh/tangled.sh/core/knotclient"
34
+
tlog "tangled.sh/tangled.sh/core/log"
33
35
"tangled.sh/tangled.sh/core/rbac"
34
36
)
35
37
36
38
type State struct {
37
-
db *db.DB
38
-
oauth *oauth.OAuth
39
-
enforcer *rbac.Enforcer
40
-
tidClock syntax.TIDClock
41
-
pages *pages.Pages
42
-
sess *session.SessionStore
43
-
idResolver *idresolver.Resolver
44
-
posthog posthog.Client
45
-
jc *jetstream.JetstreamClient
46
-
config *config.Config
47
-
repoResolver *reporesolver.RepoResolver
48
-
knotstream *knotclient.EventConsumer
39
+
db *db.DB
40
+
oauth *oauth.OAuth
41
+
enforcer *rbac.Enforcer
42
+
tidClock syntax.TIDClock
43
+
pages *pages.Pages
44
+
sess *session.SessionStore
45
+
idResolver *idresolver.Resolver
46
+
posthog posthog.Client
47
+
jc *jetstream.JetstreamClient
48
+
config *config.Config
49
+
repoResolver *reporesolver.RepoResolver
50
+
knotstream *eventconsumer.Consumer
51
+
spindlestream *eventconsumer.Consumer
49
52
}
50
53
51
-
func Make(config *config.Config) (*State, error) {
54
+
func Make(ctx context.Context, config *config.Config) (*State, error) {
52
55
d, err := db.Make(config.Core.DbPath)
53
56
if err != nil {
54
-
return nil, err
57
+
return nil, fmt.Errorf("failed to create db: %w", err)
55
58
}
56
59
57
60
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
58
61
if err != nil {
59
-
return nil, err
62
+
return nil, fmt.Errorf("failed to create enforcer: %w", err)
60
63
}
61
64
62
65
clock := syntax.NewTIDClock(0)
···
91
94
tangled.PublicKeyNSID,
92
95
tangled.RepoArtifactNSID,
93
96
tangled.ActorProfileNSID,
97
+
tangled.SpindleMemberNSID,
98
+
tangled.SpindleNSID,
94
99
},
95
100
nil,
96
101
slog.Default(),
···
104
109
if err != nil {
105
110
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
106
111
}
107
-
err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer))
112
+
113
+
ingester := appview.Ingester{
114
+
Db: wrapper,
115
+
Enforcer: enforcer,
116
+
IdResolver: res,
117
+
Config: config,
118
+
Logger: tlog.New("ingester"),
119
+
}
120
+
err = jc.StartJetstream(ctx, ingester.Ingest())
108
121
if err != nil {
109
122
return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
110
123
}
111
124
112
-
knotstream, err := KnotstreamConsumer(config, d, enforcer, posthog)
125
+
knotstream, err := Knotstream(ctx, config, d, enforcer, posthog)
113
126
if err != nil {
114
127
return nil, fmt.Errorf("failed to start knotstream consumer: %w", err)
115
128
}
116
-
knotstream.Start(context.Background())
129
+
knotstream.Start(ctx)
130
+
131
+
spindlestream, err := Spindlestream(ctx, config, d, enforcer)
132
+
if err != nil {
133
+
return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err)
134
+
}
135
+
spindlestream.Start(ctx)
117
136
118
137
state := &State{
119
138
d,
···
128
147
config,
129
148
repoResolver,
130
149
knotstream,
150
+
spindlestream,
131
151
}
132
152
133
153
return state, nil
···
336
356
}
337
357
338
358
// add basic acls for this domain
339
-
err = s.enforcer.AddDomain(domain)
359
+
err = s.enforcer.AddKnot(domain)
340
360
if err != nil {
341
361
log.Println("failed to setup owner of domain", err)
342
362
http.Error(w, err.Error(), http.StatusInternalServerError)
···
344
364
}
345
365
346
366
// add this did as owner of this domain
347
-
err = s.enforcer.AddOwner(domain, reg.ByDid)
367
+
err = s.enforcer.AddKnotOwner(domain, reg.ByDid)
348
368
if err != nil {
349
369
log.Println("failed to setup owner of domain", err)
350
370
http.Error(w, err.Error(), http.StatusInternalServerError)
···
366
386
}
367
387
368
388
// add this knot to knotstream
369
-
go s.knotstream.AddSource(context.Background(), knotclient.EventSource{domain})
389
+
go s.knotstream.AddSource(
390
+
context.Background(),
391
+
eventconsumer.NewKnotSource(domain),
392
+
)
370
393
371
394
w.Write([]byte("check success"))
372
395
}
···
409
432
}
410
433
}
411
434
412
-
ok, err := s.enforcer.IsServerOwner(user.Did, domain)
435
+
ok, err := s.enforcer.IsKnotOwner(user.Did, domain)
413
436
isOwner := err == nil && ok
414
437
415
438
p := pages.KnotParams{
···
528
551
return
529
552
}
530
553
531
-
err = s.enforcer.AddMember(domain, subjectIdentity.DID.String())
554
+
err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
532
555
if err != nil {
533
556
w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
534
557
return
···
576
599
switch r.Method {
577
600
case http.MethodGet:
578
601
user := s.oauth.GetUser(r)
579
-
knots, err := s.enforcer.GetDomainsForUser(user.Did)
602
+
knots, err := s.enforcer.GetKnotsForUser(user.Did)
580
603
if err != nil {
581
604
s.pages.Notice(w, "repo", "Invalid user account.")
582
605
return
+90
-69
avatar/src/index.js
+90
-69
avatar/src/index.js
···
1
1
export default {
2
-
async fetch(request, env) {
3
-
const url = new URL(request.url);
4
-
const { pathname } = url;
2
+
async fetch(request, env) {
3
+
const url = new URL(request.url);
4
+
const { pathname, searchParams } = url;
5
5
6
-
if (!pathname || pathname === '/') {
7
-
return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare.
8
-
You can't use this directly unforunately since all requests are signed and may only originate from the appview.`);
9
-
}
6
+
if (!pathname || pathname === "/") {
7
+
return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare.
8
+
You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`);
9
+
}
10
10
11
-
const cache = caches.default;
11
+
const size = searchParams.get("size");
12
+
const resizeToTiny = size === "tiny";
12
13
13
-
let cacheKey = request.url;
14
-
let response = await cache.match(cacheKey);
15
-
if (response) {
16
-
return response;
17
-
}
14
+
const cache = caches.default;
15
+
let cacheKey = request.url;
16
+
let response = await cache.match(cacheKey);
17
+
if (response) return response;
18
18
19
-
const pathParts = pathname.slice(1).split('/');
20
-
if (pathParts.length < 2) {
21
-
return new Response('Bad URL', { status: 400 });
22
-
}
19
+
const pathParts = pathname.slice(1).split("/");
20
+
if (pathParts.length < 2) {
21
+
return new Response("Bad URL", { status: 400 });
22
+
}
23
23
24
-
const [signatureHex, actor] = pathParts;
24
+
const [signatureHex, actor] = pathParts;
25
+
const actorBytes = new TextEncoder().encode(actor);
25
26
26
-
const actorBytes = new TextEncoder().encode(actor);
27
+
const key = await crypto.subtle.importKey(
28
+
"raw",
29
+
new TextEncoder().encode(env.AVATAR_SHARED_SECRET),
30
+
{ name: "HMAC", hash: "SHA-256" },
31
+
false,
32
+
["sign", "verify"],
33
+
);
27
34
28
-
const key = await crypto.subtle.importKey(
29
-
'raw',
30
-
new TextEncoder().encode(env.AVATAR_SHARED_SECRET),
31
-
{ name: 'HMAC', hash: 'SHA-256' },
32
-
false,
33
-
['sign', 'verify'],
34
-
);
35
+
const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes);
36
+
const computedSig = Array.from(new Uint8Array(computedSigBuffer))
37
+
.map((b) => b.toString(16).padStart(2, "0"))
38
+
.join("");
35
39
36
-
const computedSigBuffer = await crypto.subtle.sign('HMAC', key, actorBytes);
37
-
const computedSig = Array.from(new Uint8Array(computedSigBuffer))
38
-
.map((b) => b.toString(16).padStart(2, '0'))
39
-
.join('');
40
+
console.log({
41
+
level: "debug",
42
+
message: "avatar request for: " + actor,
43
+
computedSignature: computedSig,
44
+
providedSignature: signatureHex,
45
+
});
40
46
41
-
console.log({
42
-
level: 'debug',
43
-
message: 'avatar request for: ' + actor,
44
-
computedSignature: computedSig,
45
-
providedSignature: signatureHex,
46
-
});
47
+
const sigBytes = Uint8Array.from(
48
+
signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
49
+
);
50
+
const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes);
47
51
48
-
const sigBytes = Uint8Array.from(signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)));
49
-
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, actorBytes);
52
+
if (!valid) {
53
+
return new Response("Invalid signature", { status: 403 });
54
+
}
50
55
51
-
if (!valid) {
52
-
return new Response('Invalid signature', { status: 403 });
53
-
}
56
+
try {
57
+
const profileResponse = await fetch(
58
+
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`,
59
+
);
60
+
const profile = await profileResponse.json();
61
+
const avatar = profile.avatar;
54
62
55
-
try {
56
-
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, { method: 'GET' });
57
-
const profile = await profileResponse.json();
58
-
const avatar = profile.avatar;
63
+
if (!avatar) {
64
+
return new Response(`avatar not found for ${actor}.`, { status: 404 });
65
+
}
59
66
60
-
if (!avatar) {
61
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
62
-
}
67
+
// Resize if requested
68
+
let avatarResponse;
69
+
if (resizeToTiny) {
70
+
avatarResponse = await fetch(avatar, {
71
+
cf: {
72
+
image: {
73
+
width: 32,
74
+
height: 32,
75
+
fit: "cover",
76
+
format: "webp",
77
+
},
78
+
},
79
+
});
80
+
} else {
81
+
avatarResponse = await fetch(avatar);
82
+
}
63
83
64
-
// fetch the actual avatar image
65
-
const avatarResponse = await fetch(avatar);
66
-
if (!avatarResponse.ok) {
67
-
return new Response(`failed to fetch avatar for ${actor}.`, { status: avatarResponse.status });
68
-
}
84
+
if (!avatarResponse.ok) {
85
+
return new Response(`failed to fetch avatar for ${actor}.`, {
86
+
status: avatarResponse.status,
87
+
});
88
+
}
69
89
70
-
const avatarData = await avatarResponse.arrayBuffer();
71
-
const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg';
90
+
const avatarData = await avatarResponse.arrayBuffer();
91
+
const contentType =
92
+
avatarResponse.headers.get("content-type") || "image/jpeg";
72
93
73
-
response = new Response(avatarData, {
74
-
headers: {
75
-
'Content-Type': contentType,
76
-
'Cache-Control': 'public, max-age=43200', // 12 h
77
-
},
78
-
});
94
+
response = new Response(avatarData, {
95
+
headers: {
96
+
"Content-Type": contentType,
97
+
"Cache-Control": "public, max-age=43200",
98
+
},
99
+
});
79
100
80
-
// cache it in cf using request.url as the key
81
-
await cache.put(cacheKey, response.clone());
82
-
83
-
return response;
84
-
} catch (error) {
85
-
return new Response(`error fetching avatar: ${error.message}`, { status: 500 });
86
-
}
87
-
},
101
+
await cache.put(cacheKey, response.clone());
102
+
return response;
103
+
} catch (error) {
104
+
return new Response(`error fetching avatar: ${error.message}`, {
105
+
status: 500,
106
+
});
107
+
}
108
+
},
88
109
};
+4
-2
cmd/appview/main.go
+4
-2
cmd/appview/main.go
···
14
14
func main() {
15
15
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
16
16
17
-
c, err := config.LoadConfig(context.Background())
17
+
ctx := context.Background()
18
+
19
+
c, err := config.LoadConfig(ctx)
18
20
if err != nil {
19
21
log.Println("failed to load config", "error", err)
20
22
return
21
23
}
22
24
23
-
state, err := state.Make(c)
25
+
state, err := state.Make(ctx, c)
24
26
25
27
if err != nil {
26
28
log.Fatal(err)
-51
cmd/eventconsumer/main.go
-51
cmd/eventconsumer/main.go
···
1
-
package main
2
-
3
-
import (
4
-
"context"
5
-
"flag"
6
-
"fmt"
7
-
"strings"
8
-
"time"
9
-
10
-
"tangled.sh/tangled.sh/core/knotclient"
11
-
)
12
-
13
-
func main() {
14
-
knots := flag.String("knots", "", "list of knots to connect to")
15
-
retryFlag := flag.Duration("retry", 1*time.Minute, "retry interval")
16
-
maxRetryFlag := flag.Duration("max-retry", 30*time.Minute, "max retry interval")
17
-
workerCount := flag.Int("workers", 10, "goroutine pool size")
18
-
19
-
flag.Parse()
20
-
21
-
if *knots == "" {
22
-
fmt.Println("error: -knots is required")
23
-
flag.Usage()
24
-
return
25
-
}
26
-
27
-
var srcs []knotclient.EventSource
28
-
for k := range strings.SplitSeq(*knots, ",") {
29
-
srcs = append(srcs, knotclient.EventSource{k})
30
-
}
31
-
32
-
consumer := knotclient.NewEventConsumer(knotclient.ConsumerConfig{
33
-
Sources: srcs,
34
-
ProcessFunc: processEvent,
35
-
RetryInterval: *retryFlag,
36
-
MaxRetryInterval: *maxRetryFlag,
37
-
WorkerCount: *workerCount,
38
-
Dev: true,
39
-
})
40
-
41
-
ctx, cancel := context.WithCancel(context.Background())
42
-
consumer.Start(ctx)
43
-
time.Sleep(1 * time.Hour)
44
-
cancel()
45
-
consumer.Stop()
46
-
}
47
-
48
-
func processEvent(source knotclient.EventSource, msg knotclient.Message) error {
49
-
fmt.Printf("From %s (%s, %s): %s\n", source.Knot, msg.Rkey, msg.Nsid, string(msg.EventJson))
50
-
return nil
51
-
}
+6
-3
cmd/gen.go
+6
-3
cmd/gen.go
···
15
15
"api/tangled/cbor_gen.go",
16
16
"tangled",
17
17
tangled.ActorProfile{},
18
+
tangled.FeedReaction{},
18
19
tangled.FeedStar{},
19
20
tangled.GitRefUpdate{},
20
21
tangled.GitRefUpdate_Meta{},
···
24
25
tangled.KnotMember{},
25
26
tangled.Pipeline{},
26
27
tangled.Pipeline_CloneOpts{},
27
-
tangled.Pipeline_Dependencies_Elem{},
28
+
tangled.Pipeline_Dependency{},
28
29
tangled.Pipeline_ManualTriggerData{},
29
-
tangled.Pipeline_ManualTriggerData_Inputs_Elem{},
30
+
tangled.Pipeline_Pair{},
30
31
tangled.Pipeline_PullRequestTriggerData{},
31
32
tangled.Pipeline_PushTriggerData{},
33
+
tangled.PipelineStatus{},
32
34
tangled.Pipeline_Step{},
33
35
tangled.Pipeline_TriggerMetadata{},
34
36
tangled.Pipeline_TriggerRepo{},
35
37
tangled.Pipeline_Workflow{},
36
-
tangled.Pipeline_Workflow_Environment_Elem{},
37
38
tangled.PublicKey{},
38
39
tangled.Repo{},
39
40
tangled.RepoArtifact{},
···
44
45
tangled.RepoPullComment{},
45
46
tangled.RepoPull_Source{},
46
47
tangled.RepoPullStatus{},
48
+
tangled.Spindle{},
49
+
tangled.SpindleMember{},
47
50
); err != nil {
48
51
panic(err)
49
52
}
+1
-1
cmd/punchcardPopulate/main.go
+1
-1
cmd/punchcardPopulate/main.go
+19
cmd/spindle/main.go
+19
cmd/spindle/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"context"
5
+
"os"
6
+
7
+
"tangled.sh/tangled.sh/core/log"
8
+
"tangled.sh/tangled.sh/core/spindle"
9
+
_ "tangled.sh/tangled.sh/core/tid"
10
+
)
11
+
12
+
func main() {
13
+
ctx := log.NewContext(context.Background(), "spindle")
14
+
err := spindle.Run(ctx)
15
+
if err != nil {
16
+
log.FromContext(ctx).Error("error running spindle", "error", err)
17
+
os.Exit(-1)
18
+
}
19
+
}
+5
-4
docs/hacking.md
+5
-4
docs/hacking.md
···
47
47
`nixos-shell` like so:
48
48
49
49
```bash
50
-
QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM
50
+
nix run .#vm
51
+
# or nixos-shell --flake .#vm
51
52
52
53
# hit Ctrl-a + c + q to exit the VM
53
54
```
54
55
55
-
This starts a knot on port 6000 with `ssh` exposed on port
56
-
2222. You can push repositories to this VM with this ssh
57
-
config block on your main machine:
56
+
This starts a knot on port 6000, a spindle on port 6555
57
+
with `ssh` exposed on port 2222. You can push repositories
58
+
to this VM with this ssh config block on your main machine:
58
59
59
60
```bash
60
61
Host nixos-shell
+33
docs/knot-hosting.md
+33
docs/knot-hosting.md
···
89
89
systemctl start knotserver
90
90
```
91
91
92
+
The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
93
+
knot. Here's an example configuration for Nginx:
94
+
95
+
```
96
+
server {
97
+
listen 80;
98
+
listen [::]:80;
99
+
server_name knot.example.com;
100
+
101
+
location / {
102
+
proxy_pass http://localhost:5555;
103
+
proxy_set_header Host $host;
104
+
proxy_set_header X-Real-IP $remote_addr;
105
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
106
+
proxy_set_header X-Forwarded-Proto $scheme;
107
+
}
108
+
109
+
# wss endpoint for git events
110
+
location /events {
111
+
proxy_set_header X-Forwarded-For $remote_addr;
112
+
proxy_set_header Host $http_host;
113
+
proxy_set_header Upgrade websocket;
114
+
proxy_set_header Connection Upgrade;
115
+
proxy_pass http://localhost:5555;
116
+
}
117
+
# additional config for SSL/TLS go here.
118
+
}
119
+
120
+
```
121
+
122
+
Remember to use Let's Encrypt or similar to procure a certificate for your
123
+
knot domain.
124
+
92
125
You should now have a running knot server! You can finalize your registration by hitting the
93
126
`initialize` button on the [/knots](/knots) page.
94
127
+24
docs/spindle/architecture.md
+24
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. Spindle executes each step in
17
+
the pipeline in a fresh container, with state persisted across steps within the
18
+
`/tangled/workspace` directory.
19
+
20
+
The base image for the container is constructed on the fly using
21
+
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
22
+
used packages.
23
+
24
+
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.
+59
docs/spindle/pipeline.md
+59
docs/spindle/pipeline.md
···
1
+
# spindle pipeline manifest
2
+
3
+
Spindle pipelines are defined under the `.tangled/workflows` directory in a
4
+
repo. Generally:
5
+
6
+
* Pipelines are defined in YAML.
7
+
* Dependencies can be specified from
8
+
[Nixpkgs](https://search.nixos.org) or custom registries.
9
+
* Environment variables can be set globally or per-step.
10
+
11
+
Here's an example that uses all fields:
12
+
13
+
```yaml
14
+
# build_and_test.yaml
15
+
when:
16
+
- event: ["push", "pull_request"]
17
+
branch: ["main", "develop"]
18
+
- event: ["manual"]
19
+
20
+
dependencies:
21
+
## from nixpkgs
22
+
nixpkgs:
23
+
- nodejs
24
+
## custom registry
25
+
git+https://tangled.sh/@oppi.li/statix:
26
+
- statix
27
+
28
+
steps:
29
+
- name: "Install dependencies"
30
+
command: "npm install"
31
+
environment:
32
+
NODE_ENV: "development"
33
+
CI: "true"
34
+
35
+
- name: "Run linter"
36
+
command: "npm run lint"
37
+
38
+
- name: "Run tests"
39
+
command: "npm test"
40
+
environment:
41
+
NODE_ENV: "test"
42
+
JEST_WORKERS: "2"
43
+
44
+
- name: "Build application"
45
+
command: "npm run build"
46
+
environment:
47
+
NODE_ENV: "production"
48
+
49
+
environment:
50
+
BUILD_NUMBER: "123"
51
+
GIT_BRANCH: "main"
52
+
53
+
## current repository is cloned and checked out at the target ref
54
+
## by default.
55
+
clone:
56
+
skip: false
57
+
depth: 50
58
+
submodules: true
59
+
```
+263
eventconsumer/consumer.go
+263
eventconsumer/consumer.go
···
1
+
package eventconsumer
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"log/slog"
7
+
"math/rand"
8
+
"net/url"
9
+
"sync"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
13
+
"tangled.sh/tangled.sh/core/log"
14
+
15
+
"github.com/avast/retry-go/v4"
16
+
"github.com/gorilla/websocket"
17
+
)
18
+
19
+
type ProcessFunc func(ctx context.Context, source Source, message Message) error
20
+
21
+
type Message struct {
22
+
Rkey string
23
+
Nsid string
24
+
// do not full deserialize this portion of the message, processFunc can do that
25
+
EventJson json.RawMessage `json:"event"`
26
+
}
27
+
28
+
type ConsumerConfig struct {
29
+
Sources map[Source]struct{}
30
+
ProcessFunc ProcessFunc
31
+
RetryInterval time.Duration
32
+
MaxRetryInterval time.Duration
33
+
ConnectionTimeout time.Duration
34
+
WorkerCount int
35
+
QueueSize int
36
+
Logger *slog.Logger
37
+
Dev bool
38
+
CursorStore cursor.Store
39
+
}
40
+
41
+
func NewConsumerConfig() *ConsumerConfig {
42
+
return &ConsumerConfig{
43
+
Sources: make(map[Source]struct{}),
44
+
}
45
+
}
46
+
47
+
type Source interface {
48
+
// url to start streaming events from
49
+
Url(cursor int64, dev bool) (*url.URL, error)
50
+
// cache key for cursor storage
51
+
Key() string
52
+
}
53
+
54
+
type Consumer struct {
55
+
wg sync.WaitGroup
56
+
dialer *websocket.Dialer
57
+
connMap sync.Map
58
+
jobQueue chan job
59
+
logger *slog.Logger
60
+
randSource *rand.Rand
61
+
62
+
// rw lock over edits to ConsumerConfig
63
+
cfgMu sync.RWMutex
64
+
cfg ConsumerConfig
65
+
}
66
+
67
+
type job struct {
68
+
source Source
69
+
message []byte
70
+
}
71
+
72
+
func NewConsumer(cfg ConsumerConfig) *Consumer {
73
+
if cfg.RetryInterval == 0 {
74
+
cfg.RetryInterval = 15 * time.Minute
75
+
}
76
+
if cfg.ConnectionTimeout == 0 {
77
+
cfg.ConnectionTimeout = 10 * time.Second
78
+
}
79
+
if cfg.WorkerCount <= 0 {
80
+
cfg.WorkerCount = 5
81
+
}
82
+
if cfg.MaxRetryInterval == 0 {
83
+
cfg.MaxRetryInterval = 1 * time.Hour
84
+
}
85
+
if cfg.Logger == nil {
86
+
cfg.Logger = log.New("consumer")
87
+
}
88
+
if cfg.QueueSize == 0 {
89
+
cfg.QueueSize = 100
90
+
}
91
+
if cfg.CursorStore == nil {
92
+
cfg.CursorStore = &cursor.MemoryStore{}
93
+
}
94
+
return &Consumer{
95
+
cfg: cfg,
96
+
dialer: websocket.DefaultDialer,
97
+
jobQueue: make(chan job, cfg.QueueSize), // buffered job queue
98
+
logger: cfg.Logger,
99
+
randSource: rand.New(rand.NewSource(time.Now().UnixNano())),
100
+
}
101
+
}
102
+
103
+
func (c *Consumer) Start(ctx context.Context) {
104
+
c.cfg.Logger.Info("starting consumer", "config", c.cfg)
105
+
106
+
// start workers
107
+
for range c.cfg.WorkerCount {
108
+
c.wg.Add(1)
109
+
go c.worker(ctx)
110
+
}
111
+
112
+
// start streaming
113
+
for source := range c.cfg.Sources {
114
+
c.wg.Add(1)
115
+
go c.startConnectionLoop(ctx, source)
116
+
}
117
+
}
118
+
119
+
func (c *Consumer) Stop() {
120
+
c.connMap.Range(func(_, val any) bool {
121
+
if conn, ok := val.(*websocket.Conn); ok {
122
+
conn.Close()
123
+
}
124
+
return true
125
+
})
126
+
c.wg.Wait()
127
+
close(c.jobQueue)
128
+
}
129
+
130
+
func (c *Consumer) AddSource(ctx context.Context, s Source) {
131
+
// we are already listening to this source
132
+
if _, ok := c.cfg.Sources[s]; ok {
133
+
c.logger.Info("source already present", "source", s)
134
+
return
135
+
}
136
+
137
+
c.cfgMu.Lock()
138
+
c.cfg.Sources[s] = struct{}{}
139
+
c.wg.Add(1)
140
+
go c.startConnectionLoop(ctx, s)
141
+
c.cfgMu.Unlock()
142
+
}
143
+
144
+
func (c *Consumer) worker(ctx context.Context) {
145
+
defer c.wg.Done()
146
+
for {
147
+
select {
148
+
case <-ctx.Done():
149
+
return
150
+
case j, ok := <-c.jobQueue:
151
+
if !ok {
152
+
return
153
+
}
154
+
155
+
var msg Message
156
+
err := json.Unmarshal(j.message, &msg)
157
+
if err != nil {
158
+
c.logger.Error("error deserializing message", "source", j.source.Key(), "err", err)
159
+
return
160
+
}
161
+
162
+
// update cursor
163
+
c.cfg.CursorStore.Set(j.source.Key(), time.Now().UnixNano())
164
+
165
+
if err := c.cfg.ProcessFunc(ctx, j.source, msg); err != nil {
166
+
c.logger.Error("error processing message", "source", j.source, "err", err)
167
+
}
168
+
}
169
+
}
170
+
}
171
+
172
+
func (c *Consumer) startConnectionLoop(ctx context.Context, source Source) {
173
+
defer c.wg.Done()
174
+
175
+
// attempt connection initially
176
+
err := c.runConnection(ctx, source)
177
+
if err != nil {
178
+
c.logger.Error("failed to run connection", "err", err)
179
+
}
180
+
181
+
timer := time.NewTimer(1 * time.Minute)
182
+
defer timer.Stop()
183
+
184
+
// every subsequent attempt is delayed by 1 minute
185
+
for {
186
+
select {
187
+
case <-ctx.Done():
188
+
return
189
+
case <-timer.C:
190
+
err := c.runConnection(ctx, source)
191
+
if err != nil {
192
+
c.logger.Error("failed to run connection", "err", err)
193
+
}
194
+
timer.Reset(1 * time.Minute)
195
+
}
196
+
}
197
+
}
198
+
199
+
func (c *Consumer) runConnection(ctx context.Context, source Source) error {
200
+
cursor := c.cfg.CursorStore.Get(source.Key())
201
+
202
+
u, err := source.Url(cursor, c.cfg.Dev)
203
+
if err != nil {
204
+
return err
205
+
}
206
+
207
+
c.logger.Info("connecting", "url", u.String())
208
+
209
+
retryOpts := []retry.Option{
210
+
retry.Attempts(0), // infinite attempts
211
+
retry.DelayType(retry.BackOffDelay),
212
+
retry.Delay(c.cfg.RetryInterval),
213
+
retry.MaxDelay(c.cfg.MaxRetryInterval),
214
+
retry.MaxJitter(c.cfg.RetryInterval / 5),
215
+
retry.OnRetry(func(n uint, err error) {
216
+
c.logger.Info("retrying connection",
217
+
"source", source,
218
+
"url", u.String(),
219
+
"attempt", n+1,
220
+
"err", err,
221
+
)
222
+
}),
223
+
retry.Context(ctx),
224
+
}
225
+
226
+
var conn *websocket.Conn
227
+
228
+
err = retry.Do(func() error {
229
+
connCtx, cancel := context.WithTimeout(ctx, c.cfg.ConnectionTimeout)
230
+
defer cancel()
231
+
conn, _, err = c.dialer.DialContext(connCtx, u.String(), nil)
232
+
return err
233
+
}, retryOpts...)
234
+
if err != nil {
235
+
return err
236
+
}
237
+
238
+
c.connMap.Store(source, conn)
239
+
defer conn.Close()
240
+
defer c.connMap.Delete(source)
241
+
242
+
c.logger.Info("connected", "source", source)
243
+
244
+
for {
245
+
select {
246
+
case <-ctx.Done():
247
+
return nil
248
+
default:
249
+
msgType, msg, err := conn.ReadMessage()
250
+
if err != nil {
251
+
return err
252
+
}
253
+
if msgType != websocket.TextMessage {
254
+
continue
255
+
}
256
+
select {
257
+
case c.jobQueue <- job{source: source, message: msg}:
258
+
case <-ctx.Done():
259
+
return nil
260
+
}
261
+
}
262
+
}
263
+
}
+23
eventconsumer/cursor/memory.go
+23
eventconsumer/cursor/memory.go
···
1
+
package cursor
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type MemoryStore struct {
8
+
store sync.Map
9
+
}
10
+
11
+
func (m *MemoryStore) Set(knot string, cursor int64) {
12
+
m.store.Store(knot, cursor)
13
+
}
14
+
15
+
func (m *MemoryStore) Get(knot string) (cursor int64) {
16
+
if result, ok := m.store.Load(knot); ok {
17
+
if val, ok := result.(int64); ok {
18
+
return val
19
+
}
20
+
}
21
+
22
+
return 0
23
+
}
+43
eventconsumer/cursor/redis.go
+43
eventconsumer/cursor/redis.go
···
1
+
package cursor
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"strconv"
7
+
8
+
"tangled.sh/tangled.sh/core/appview/cache"
9
+
)
10
+
11
+
const (
12
+
cursorKey = "cursor:%s"
13
+
)
14
+
15
+
type RedisStore struct {
16
+
rdb *cache.Cache
17
+
}
18
+
19
+
func NewRedisCursorStore(cache *cache.Cache) RedisStore {
20
+
return RedisStore{
21
+
rdb: cache,
22
+
}
23
+
}
24
+
25
+
func (r *RedisStore) Set(knot string, cursor int64) {
26
+
key := fmt.Sprintf(cursorKey, knot)
27
+
r.rdb.Set(context.Background(), key, cursor, 0)
28
+
}
29
+
30
+
func (r *RedisStore) Get(knot string) (cursor int64) {
31
+
key := fmt.Sprintf(cursorKey, knot)
32
+
val, err := r.rdb.Get(context.Background(), key).Result()
33
+
if err != nil {
34
+
return 0
35
+
}
36
+
cursor, err = strconv.ParseInt(val, 10, 64)
37
+
if err != nil {
38
+
// TODO: log here
39
+
return 0
40
+
}
41
+
42
+
return cursor
43
+
}
+83
eventconsumer/cursor/sqlite.go
+83
eventconsumer/cursor/sqlite.go
···
1
+
package cursor
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
7
+
_ "github.com/mattn/go-sqlite3"
8
+
)
9
+
10
+
type SqliteStore struct {
11
+
db *sql.DB
12
+
tableName string
13
+
}
14
+
15
+
type SqliteStoreOpt func(*SqliteStore)
16
+
17
+
func WithTableName(name string) SqliteStoreOpt {
18
+
return func(s *SqliteStore) {
19
+
s.tableName = name
20
+
}
21
+
}
22
+
23
+
func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) {
24
+
db, err := sql.Open("sqlite3", dbPath)
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
27
+
}
28
+
29
+
store := &SqliteStore{
30
+
db: db,
31
+
tableName: "cursors",
32
+
}
33
+
34
+
for _, o := range opts {
35
+
o(store)
36
+
}
37
+
38
+
if err := store.init(); err != nil {
39
+
return nil, err
40
+
}
41
+
42
+
return store, nil
43
+
}
44
+
45
+
func (s *SqliteStore) init() error {
46
+
createTable := fmt.Sprintf(`
47
+
create table if not exists %s (
48
+
knot text primary key,
49
+
cursor text
50
+
);`, s.tableName)
51
+
_, err := s.db.Exec(createTable)
52
+
return err
53
+
}
54
+
55
+
func (s *SqliteStore) Set(knot string, cursor int64) {
56
+
query := fmt.Sprintf(`
57
+
insert into %s (knot, cursor)
58
+
values (?, ?)
59
+
on conflict(knot) do update set cursor=excluded.cursor;
60
+
`, s.tableName)
61
+
62
+
_, err := s.db.Exec(query, knot, cursor)
63
+
64
+
if err != nil {
65
+
// TODO: log here
66
+
}
67
+
}
68
+
69
+
func (s *SqliteStore) Get(knot string) (cursor int64) {
70
+
query := fmt.Sprintf(`
71
+
select cursor from %s where knot = ?;
72
+
`, s.tableName)
73
+
err := s.db.QueryRow(query, knot).Scan(&cursor)
74
+
75
+
if err != nil {
76
+
if err != sql.ErrNoRows {
77
+
// TODO: log here
78
+
}
79
+
return 0
80
+
}
81
+
82
+
return cursor
83
+
}
+6
eventconsumer/cursor/store.go
+6
eventconsumer/cursor/store.go
+39
eventconsumer/knot.go
+39
eventconsumer/knot.go
···
1
+
package eventconsumer
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
type KnotSource struct {
9
+
Knot string
10
+
}
11
+
12
+
func (k KnotSource) Key() string {
13
+
return k.Knot
14
+
}
15
+
16
+
func (k KnotSource) Url(cursor int64, dev bool) (*url.URL, error) {
17
+
scheme := "wss"
18
+
if dev {
19
+
scheme = "ws"
20
+
}
21
+
22
+
u, err := url.Parse(scheme + "://" + k.Knot + "/events")
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
if cursor != 0 {
28
+
query := url.Values{}
29
+
query.Add("cursor", fmt.Sprintf("%d", cursor))
30
+
u.RawQuery = query.Encode()
31
+
}
32
+
return u, nil
33
+
}
34
+
35
+
func NewKnotSource(knot string) KnotSource {
36
+
return KnotSource{
37
+
Knot: knot,
38
+
}
39
+
}
+39
eventconsumer/spindle.go
+39
eventconsumer/spindle.go
···
1
+
package eventconsumer
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
type SpindleSource struct {
9
+
Spindle string
10
+
}
11
+
12
+
func (s SpindleSource) Key() string {
13
+
return s.Spindle
14
+
}
15
+
16
+
func (s SpindleSource) Url(cursor int64, dev bool) (*url.URL, error) {
17
+
scheme := "wss"
18
+
if dev {
19
+
scheme = "ws"
20
+
}
21
+
22
+
u, err := url.Parse(scheme + "://" + s.Spindle + "/events")
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
if cursor != 0 {
28
+
query := url.Values{}
29
+
query.Add("cursor", fmt.Sprintf("%d", cursor))
30
+
u.RawQuery = query.Encode()
31
+
}
32
+
return u, nil
33
+
}
34
+
35
+
func NewSpindleSource(spindle string) SpindleSource {
36
+
return SpindleSource{
37
+
Spindle: spindle,
38
+
}
39
+
}
+13
flake.lock
+13
flake.lock
···
32
32
"url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"
33
33
}
34
34
},
35
+
"htmx-ws-src": {
36
+
"flake": false,
37
+
"locked": {
38
+
"narHash": "sha256-2fg6KyEJoO24q0fQqbz9RMaYNPQrMwpZh29tkSqdqGY=",
39
+
"type": "file",
40
+
"url": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2"
41
+
},
42
+
"original": {
43
+
"type": "file",
44
+
"url": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2"
45
+
}
46
+
},
35
47
"ibm-plex-mono-src": {
36
48
"flake": false,
37
49
"locked": {
···
107
119
"inputs": {
108
120
"gitignore": "gitignore",
109
121
"htmx-src": "htmx-src",
122
+
"htmx-ws-src": "htmx-ws-src",
110
123
"ibm-plex-mono-src": "ibm-plex-mono-src",
111
124
"indigo": "indigo",
112
125
"inter-fonts-src": "inter-fonts-src",
+59
-50
flake.nix
+59
-50
flake.nix
···
11
11
url = "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js";
12
12
flake = false;
13
13
};
14
+
htmx-ws-src = {
15
+
# strange errors in consle that i can't really make out
16
+
# url = "https://unpkg.com/htmx.org@2.0.4/dist/ext/ws.js";
17
+
url = "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2";
18
+
flake = false;
19
+
};
14
20
lucide-src = {
15
21
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
16
22
flake = false;
···
38
44
nixpkgs,
39
45
indigo,
40
46
htmx-src,
47
+
htmx-ws-src,
41
48
lucide-src,
42
49
gitignore,
43
50
inter-fonts-src,
···
46
53
}: let
47
54
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
48
55
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
49
-
nixpkgsFor = forAllSystems (system:
50
-
import nixpkgs {
51
-
inherit system;
52
-
overlays = [self.overlays.default];
53
-
});
56
+
nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system});
54
57
inherit (gitignore.lib) gitignoreSource;
55
-
in {
56
-
overlays.default = final: prev: let
57
-
goModHash = "sha256-+OQfLBXd5OQuITHRPaxXQs49vPGfQfsNJzpcjJjeHKs=";
58
-
appviewDeps = {
59
-
inherit htmx-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource;
58
+
mkPackageSet = pkgs: let
59
+
goModHash = "sha256-SLi+nALwCd/Lzn3aljwPqCo2UaM9hl/4OAjcHQLt2Bk=";
60
+
sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix {
61
+
inherit (pkgs) gcc;
62
+
inherit sqlite-lib-src;
60
63
};
61
-
knotDeps = {
62
-
inherit goModHash gitignoreSource;
64
+
genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;};
65
+
lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
66
+
appview = pkgs.callPackage ./nix/pkgs/appview.nix {
67
+
inherit sqlite-lib htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource;
63
68
};
64
-
mkPackageSet = pkgs: {
65
-
lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
66
-
appview = pkgs.callPackage ./nix/pkgs/appview.nix appviewDeps;
67
-
# appview-static = final.pkgsStatic.callPackage ./nix/pkgs/appview.nix appviewDeps;
68
-
# appview-cross = final.pkgsCross.gnu64.pkgsStatic.callPackage ./nix/pkgs/appview.nix appviewDeps;
69
-
knot = pkgs.callPackage ./nix/pkgs/knot.nix {};
70
-
# knot-static = final.pkgsStatic.callPackage ./nix/pkgs/knot.nix {};
71
-
knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix knotDeps;
72
-
# knot-unwrapped-static = final.pkgsStatic.callPackage ./nix/pkgs/knot-unwrapped.nix knotDeps;
73
-
# knot-cross = final.pkgsCross.gnu64.pkgsStatic.callPackage ./nix/pkgs/knot.nix knotDeps;
74
-
sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix {
75
-
inherit (pkgs) gcc;
76
-
inherit sqlite-lib-src;
77
-
};
78
-
genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;};
79
-
};
80
-
in mkPackageSet final;
69
+
spindle = pkgs.callPackage ./nix/pkgs/spindle.nix {inherit sqlite-lib goModHash gitignoreSource;};
70
+
knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix {inherit sqlite-lib goModHash gitignoreSource;};
71
+
knot = pkgs.callPackage ./nix/pkgs/knot.nix {inherit knot-unwrapped;};
72
+
in {
73
+
inherit lexgen appview spindle knot-unwrapped knot sqlite-lib genjwks;
74
+
};
75
+
in {
76
+
overlays.default = final: prev: mkPackageSet final;
81
77
82
78
packages = forAllSystems (system: let
83
-
pkgs = nixpkgsFor.${system};
84
-
staticPkgs = pkgs.pkgsStatic;
85
-
crossPkgs = pkgs.pkgsCross.gnu64.pkgsStatic;
79
+
pkgs = nixpkgsFor.${system};
80
+
packages = mkPackageSet pkgs;
81
+
staticPackages = mkPackageSet pkgs.pkgsStatic;
82
+
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
86
83
in {
87
-
appview = pkgs.appview;
88
-
lexgen = pkgs.lexgen;
89
-
knot = pkgs.knot;
90
-
knot-unwrapped = pkgs.knot-unwrapped;
91
-
genjwks = pkgs.genjwks;
92
-
sqlite-lib = pkgs.sqlite-lib;
84
+
appview = packages.appview;
85
+
lexgen = packages.lexgen;
86
+
knot = packages.knot;
87
+
knot-unwrapped = packages.knot-unwrapped;
88
+
spindle = packages.spindle;
89
+
genjwks = packages.genjwks;
90
+
sqlite-lib = packages.sqlite-lib;
93
91
94
-
pkgsStatic-appview = staticPkgs.appview;
95
-
pkgsStatic-knot = staticPkgs.knot;
96
-
pkgsStatic-knot-unwrapped = staticPkgs.knot-unwrapped;
97
-
pkgsStatic-sqlite-lib = staticPkgs.sqlite-lib;
92
+
pkgsStatic-appview = staticPackages.appview;
93
+
pkgsStatic-knot = staticPackages.knot;
94
+
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
95
+
pkgsStatic-spindle = staticPackages.spindle;
96
+
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
98
97
99
-
pkgsCross-gnu64-pkgsStatic-appview = crossPkgs.appview;
100
-
pkgsCross-gnu64-pkgsStatic-knot = crossPkgs.knot;
101
-
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPkgs.knot-unwrapped;
98
+
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
99
+
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
100
+
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
101
+
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
102
102
});
103
-
defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview);
104
-
formatter = forAllSystems (system: nixpkgsFor."${system}".alejandra);
103
+
defaultPackage = forAllSystems (system: self.packages.${system}.appview);
104
+
formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra);
105
105
devShells = forAllSystems (system: let
106
106
pkgs = nixpkgsFor.${system};
107
+
packages' = self.packages.${system};
107
108
staticShell = pkgs.mkShell.override {
108
109
stdenv = pkgs.pkgsStatic.stdenv;
109
110
};
···
114
115
pkgs.air
115
116
pkgs.gopls
116
117
pkgs.httpie
117
-
pkgs.lexgen
118
118
pkgs.litecli
119
119
pkgs.websocat
120
120
pkgs.tailwindcss
121
121
pkgs.nixos-shell
122
122
pkgs.redis
123
+
packages'.lexgen
123
124
];
124
125
shellHook = ''
125
126
mkdir -p appview/pages/static/{fonts,icons}
126
127
cp -f ${htmx-src} appview/pages/static/htmx.min.js
128
+
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
127
129
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
128
130
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
129
131
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
130
132
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
131
-
export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)"
133
+
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
132
134
'';
133
135
env.CGO_ENABLED = 1;
134
136
};
···
162
164
type = "app";
163
165
program = ''${tailwind-watcher}/bin/run'';
164
166
};
167
+
vm = {
168
+
type = "app";
169
+
program = toString (pkgs.writeShellScript "vm" ''
170
+
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
171
+
'');
172
+
};
165
173
});
166
174
167
175
nixosModules.appview = import ./nix/modules/appview.nix {inherit self;};
168
176
nixosModules.knot = import ./nix/modules/knot.nix {inherit self;};
169
-
nixosConfigurations.knotVM = import ./nix/vm.nix {inherit self nixpkgs;};
177
+
nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;};
178
+
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
170
179
};
171
180
}
+23
-1
go.mod
+23
-1
go.mod
···
14
14
github.com/casbin/casbin/v2 v2.103.0
15
15
github.com/cyphar/filepath-securejoin v0.4.1
16
16
github.com/dgraph-io/ristretto v0.2.0
17
+
github.com/docker/docker v28.2.2+incompatible
17
18
github.com/dustin/go-humanize v1.0.1
18
19
github.com/gliderlabs/ssh v0.3.8
19
20
github.com/go-chi/chi/v5 v5.2.0
···
23
24
github.com/gorilla/sessions v1.4.0
24
25
github.com/gorilla/websocket v1.5.3
25
26
github.com/hiddeco/sshsig v0.2.0
27
+
github.com/hpcloud/tail v1.0.0
26
28
github.com/ipfs/go-cid v0.5.0
27
29
github.com/lestrrat-go/jwx/v2 v2.1.6
28
30
github.com/mattn/go-sqlite3 v1.14.24
···
47
49
github.com/Microsoft/go-winio v0.6.2 // indirect
48
50
github.com/ProtonMail/go-crypto v1.2.0 // indirect
49
51
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
+
github.com/avast/retry-go/v4 v4.6.1 // indirect
50
53
github.com/aymerick/douceur v0.2.0 // indirect
51
54
github.com/beorn7/perks v1.0.1 // indirect
52
55
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
53
56
github.com/casbin/govaluate v1.3.0 // indirect
54
57
github.com/cespare/xxhash/v2 v2.3.0 // indirect
55
58
github.com/cloudflare/circl v1.6.0 // indirect
59
+
github.com/containerd/errdefs v1.0.0 // indirect
60
+
github.com/containerd/errdefs/pkg v0.3.0 // indirect
61
+
github.com/containerd/log v0.1.0 // indirect
56
62
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
57
63
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
58
64
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
65
+
github.com/distribution/reference v0.6.0 // indirect
59
66
github.com/dlclark/regexp2 v1.11.5 // indirect
67
+
github.com/docker/go-connections v0.5.0 // indirect
68
+
github.com/docker/go-units v0.5.0 // indirect
60
69
github.com/emirpasic/gods v1.18.1 // indirect
61
70
github.com/felixge/httpsnoop v1.0.4 // indirect
62
71
github.com/go-enry/go-oniguruma v1.2.1 // indirect
···
96
105
github.com/lestrrat-go/option v1.0.1 // indirect
97
106
github.com/mattn/go-isatty v0.0.20 // indirect
98
107
github.com/minio/sha256-simd v1.0.1 // indirect
108
+
github.com/moby/docker-image-spec v1.3.1 // indirect
109
+
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
+
github.com/moby/term v0.5.2 // indirect
111
+
github.com/morikuni/aec v1.0.0 // indirect
99
112
github.com/mr-tron/base58 v1.2.0 // indirect
100
113
github.com/multiformats/go-base32 v0.1.0 // indirect
101
114
github.com/multiformats/go-base36 v0.2.0 // indirect
···
103
116
github.com/multiformats/go-multihash v0.2.3 // indirect
104
117
github.com/multiformats/go-varint v0.0.7 // indirect
105
118
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
119
+
github.com/opencontainers/go-digest v1.0.0 // indirect
120
+
github.com/opencontainers/image-spec v1.1.1 // indirect
106
121
github.com/opentracing/opentracing-go v1.2.0 // indirect
107
122
github.com/pjbgf/sha1cd v0.3.2 // indirect
108
123
github.com/pkg/errors v0.9.1 // indirect
···
125
140
go.opentelemetry.io/otel v1.36.0 // indirect
126
141
go.opentelemetry.io/otel/metric v1.36.0 // indirect
127
142
go.opentelemetry.io/otel/trace v1.36.0 // indirect
143
+
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
128
144
go.uber.org/atomic v1.11.0 // indirect
129
145
go.uber.org/multierr v1.11.0 // indirect
130
146
go.uber.org/zap v1.27.0 // indirect
131
147
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
132
-
golang.org/x/sync v0.13.0 // indirect
148
+
golang.org/x/sync v0.14.0 // indirect
133
149
golang.org/x/sys v0.33.0 // indirect
134
150
golang.org/x/time v0.8.0 // indirect
151
+
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
152
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
153
+
google.golang.org/grpc v1.72.1 // indirect
135
154
google.golang.org/protobuf v1.36.6 // indirect
155
+
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
136
157
gopkg.in/warnings.v0 v0.1.2 // indirect
158
+
gotest.tools/v3 v3.5.2 // indirect
137
159
lukechampine.com/blake3 v1.4.1 // indirect
138
160
)
139
161
+56
-2
go.sum
+56
-2
go.sum
···
1
1
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
2
2
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
3
+
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
4
+
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
3
5
github.com/Blank-Xu/sql-adapter v1.1.1 h1:+g7QXU9sl/qT6Po97teMpf3GjAO0X9aFaqgSePXvYko=
4
6
github.com/Blank-Xu/sql-adapter v1.1.1/go.mod h1:o2g8EZhZ3TudnYEGDkoU+3jCTCgDgx1o/Ig5ajKkaLY=
5
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
···
15
17
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
16
18
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
17
19
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
20
+
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
21
+
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
18
22
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
19
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
20
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
···
38
42
github.com/casbin/govaluate v1.2.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
39
43
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
40
44
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
45
+
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
46
+
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
41
47
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
42
48
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
43
49
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
···
47
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
48
54
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
49
55
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
+
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
+
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
+
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
59
+
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
60
+
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
61
+
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
50
62
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
51
63
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
52
64
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
···
63
75
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
64
76
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
65
77
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
78
+
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
79
+
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
66
80
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
67
81
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
82
+
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
83
+
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
84
+
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
85
+
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
86
+
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
87
+
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
68
88
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
69
89
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
70
90
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
···
148
168
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
149
169
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
150
170
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
171
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
151
173
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
152
174
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
153
175
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
···
162
184
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
163
185
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
164
186
github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE=
187
+
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
165
188
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
166
189
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
167
190
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
···
242
265
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
243
266
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
244
267
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
268
+
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
+
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
+
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
271
+
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
272
+
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
273
+
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
274
+
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
275
+
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
276
+
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
277
+
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
245
278
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
246
279
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
247
280
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
···
287
320
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
288
321
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
289
322
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
323
+
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
+
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
+
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
+
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
290
327
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
291
328
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
292
329
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
···
330
367
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
331
368
github.com/sethvargo/go-envconfig v1.1.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
332
369
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
370
+
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
371
+
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
333
372
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
334
373
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
335
374
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
···
378
417
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
379
418
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
380
419
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
420
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
421
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
422
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
381
424
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
382
425
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
383
426
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
···
386
429
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
387
430
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
388
431
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
432
+
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
+
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
389
434
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
390
435
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
391
436
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
···
444
489
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
445
490
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
446
491
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
447
-
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
448
-
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
492
+
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
493
+
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
449
494
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
450
495
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
451
496
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
512
557
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
513
558
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
514
559
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
560
+
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
561
+
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
562
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
563
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
564
+
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
565
+
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
515
566
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
516
567
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
517
568
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
529
580
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
530
581
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
531
582
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
583
+
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
532
584
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
533
585
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
534
586
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
···
542
594
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
543
595
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
544
596
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
597
+
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
598
+
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
545
599
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
546
600
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
547
601
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
+2
-2
guard/guard.go
+2
-2
guard/guard.go
···
86
86
"client", clientIP)
87
87
88
88
if sshCommand == "" {
89
-
l.Error("access denied: no interactive shells", "user", incomingUser)
90
-
fmt.Fprintln(os.Stderr, "access denied: we don't serve interactive shells :)")
89
+
l.Info("access denied: no interactive shells", "user", incomingUser)
90
+
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
91
91
os.Exit(-1)
92
92
}
93
93
+1
-1
hook/hook.go
+1
-1
hook/hook.go
···
63
63
return fmt.Errorf("failed to create request: %w", err)
64
64
}
65
65
66
-
req.Header.Set("Content-Type", "text/plain")
66
+
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
67
67
req.Header.Set("X-Git-Dir", gitDir)
68
68
req.Header.Set("X-Git-User-Did", userDid)
69
69
req.Header.Set("X-Git-User-Handle", userHandle)
+29
-19
input.css
+29
-19
input.css
···
74
74
75
75
@layer components {
76
76
.btn {
77
-
@apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center
78
-
justify-center bg-transparent px-2 pb-[0.2rem] text-base
79
-
text-gray-900 before:absolute before:inset-0 before:-z-10
80
-
before:block before:rounded before:border before:border-gray-200
81
-
before:bg-white before:drop-shadow-sm
82
-
before:content-[''] hover:before:border-gray-300
83
-
hover:before:bg-gray-50
84
-
hover:before:shadow-[0_2px_2px_0_rgba(20,20,96,0.1),inset_0_-2px_0_0_#f5f5f5]
85
-
focus:outline-none focus-visible:before:outline
86
-
focus-visible:before:outline-4 focus-visible:before:outline-gray-500
87
-
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)]
88
-
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200
89
-
disabled:hover:before:bg-white disabled:hover:before:shadow-none
90
-
dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700
91
-
dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700
92
-
dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748]
93
-
dark:focus-visible:before:outline-gray-400
94
-
dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)]
95
-
dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700;
77
+
@apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center
78
+
bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900
79
+
before:absolute before:inset-0 before:-z-10 before:block before:rounded
80
+
before:border before:border-gray-200 before:bg-white
81
+
before:shadow-[inset_0_-2px_0_0_rgba(0,0,0,0.1),0_1px_0_0_rgba(0,0,0,0.04)]
82
+
before:content-[''] before:transition-all before:duration-150 before:ease-in-out
83
+
hover:before:shadow-[inset_0_-2px_0_0_rgba(0,0,0,0.15),0_2px_1px_0_rgba(0,0,0,0.06)]
84
+
hover:before:bg-gray-50
85
+
dark:hover:before:bg-gray-700
86
+
active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.1)]
87
+
focus:outline-none focus-visible:before:outline focus-visible:before:outline-2 focus-visible:before:outline-gray-400
88
+
disabled:cursor-not-allowed disabled:opacity-50
89
+
dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700;
90
+
}
91
+
92
+
.btn-create {
93
+
@apply btn text-white
94
+
before:bg-green-600 hover:before:bg-green-700
95
+
dark:before:bg-green-700 dark:hover:before:bg-green-800
96
+
before:border before:border-green-700 hover:before:border-green-800
97
+
focus-visible:before:outline-green-500
98
+
disabled:before:bg-green-400 dark:disabled:before:bg-green-600;
99
+
}
100
+
101
+
.prose img {
102
+
display: inline;
103
+
margin-left: 0;
104
+
margin-right: 0;
105
+
vertical-align: middle;
96
106
}
97
107
}
98
108
@layer utilities {
-314
knotclient/events.go
-314
knotclient/events.go
···
1
-
package knotclient
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"log/slog"
8
-
"math/rand"
9
-
"net/url"
10
-
"strconv"
11
-
"sync"
12
-
"time"
13
-
14
-
"tangled.sh/tangled.sh/core/appview/cache"
15
-
"tangled.sh/tangled.sh/core/log"
16
-
17
-
"github.com/gorilla/websocket"
18
-
)
19
-
20
-
type ProcessFunc func(source EventSource, message Message) error
21
-
22
-
type Message struct {
23
-
Rkey string
24
-
Nsid string
25
-
// do not full deserialize this portion of the message, processFunc can do that
26
-
EventJson json.RawMessage `json:"event"`
27
-
}
28
-
29
-
type ConsumerConfig struct {
30
-
Sources map[EventSource]struct{}
31
-
ProcessFunc ProcessFunc
32
-
RetryInterval time.Duration
33
-
MaxRetryInterval time.Duration
34
-
ConnectionTimeout time.Duration
35
-
WorkerCount int
36
-
QueueSize int
37
-
Logger *slog.Logger
38
-
Dev bool
39
-
CursorStore CursorStore
40
-
}
41
-
42
-
type EventSource struct {
43
-
Knot string
44
-
}
45
-
46
-
func NewEventSource(knot string) EventSource {
47
-
return EventSource{
48
-
Knot: knot,
49
-
}
50
-
}
51
-
52
-
type EventConsumer struct {
53
-
cfg ConsumerConfig
54
-
wg sync.WaitGroup
55
-
dialer *websocket.Dialer
56
-
connMap sync.Map
57
-
jobQueue chan job
58
-
logger *slog.Logger
59
-
randSource *rand.Rand
60
-
61
-
// rw lock over edits to consumer config
62
-
mu sync.RWMutex
63
-
}
64
-
65
-
type CursorStore interface {
66
-
Set(knot string, cursor int64)
67
-
Get(knot string) (cursor int64)
68
-
}
69
-
70
-
type RedisCursorStore struct {
71
-
rdb *cache.Cache
72
-
}
73
-
74
-
func NewRedisCursorStore(cache *cache.Cache) RedisCursorStore {
75
-
return RedisCursorStore{
76
-
rdb: cache,
77
-
}
78
-
}
79
-
80
-
const (
81
-
cursorKey = "cursor:%s"
82
-
)
83
-
84
-
func (r *RedisCursorStore) Set(knot string, cursor int64) {
85
-
key := fmt.Sprintf(cursorKey, knot)
86
-
r.rdb.Set(context.Background(), key, cursor, 0)
87
-
}
88
-
89
-
func (r *RedisCursorStore) Get(knot string) (cursor int64) {
90
-
key := fmt.Sprintf(cursorKey, knot)
91
-
val, err := r.rdb.Get(context.Background(), key).Result()
92
-
if err != nil {
93
-
return 0
94
-
}
95
-
96
-
cursor, err = strconv.ParseInt(val, 10, 64)
97
-
if err != nil {
98
-
return 0 // optionally log parsing error
99
-
}
100
-
101
-
return cursor
102
-
}
103
-
104
-
type MemoryCursorStore struct {
105
-
store sync.Map
106
-
}
107
-
108
-
func (m *MemoryCursorStore) Set(knot string, cursor int64) {
109
-
m.store.Store(knot, cursor)
110
-
}
111
-
112
-
func (m *MemoryCursorStore) Get(knot string) (cursor int64) {
113
-
if result, ok := m.store.Load(knot); ok {
114
-
if val, ok := result.(int64); ok {
115
-
return val
116
-
}
117
-
}
118
-
119
-
return 0
120
-
}
121
-
122
-
func (e *EventConsumer) buildUrl(s EventSource, cursor int64) (*url.URL, error) {
123
-
scheme := "wss"
124
-
if e.cfg.Dev {
125
-
scheme = "ws"
126
-
}
127
-
128
-
u, err := url.Parse(scheme + "://" + s.Knot + "/events")
129
-
if err != nil {
130
-
return nil, err
131
-
}
132
-
133
-
if cursor != 0 {
134
-
query := url.Values{}
135
-
query.Add("cursor", fmt.Sprintf("%d", cursor))
136
-
u.RawQuery = query.Encode()
137
-
}
138
-
return u, nil
139
-
}
140
-
141
-
type job struct {
142
-
source EventSource
143
-
message []byte
144
-
}
145
-
146
-
func NewEventConsumer(cfg ConsumerConfig) *EventConsumer {
147
-
if cfg.RetryInterval == 0 {
148
-
cfg.RetryInterval = 15 * time.Minute
149
-
}
150
-
if cfg.ConnectionTimeout == 0 {
151
-
cfg.ConnectionTimeout = 10 * time.Second
152
-
}
153
-
if cfg.WorkerCount <= 0 {
154
-
cfg.WorkerCount = 5
155
-
}
156
-
if cfg.MaxRetryInterval == 0 {
157
-
cfg.MaxRetryInterval = 1 * time.Hour
158
-
}
159
-
if cfg.Logger == nil {
160
-
cfg.Logger = log.New("eventconsumer")
161
-
}
162
-
if cfg.QueueSize == 0 {
163
-
cfg.QueueSize = 100
164
-
}
165
-
if cfg.CursorStore == nil {
166
-
cfg.CursorStore = &MemoryCursorStore{}
167
-
}
168
-
return &EventConsumer{
169
-
cfg: cfg,
170
-
dialer: websocket.DefaultDialer,
171
-
jobQueue: make(chan job, cfg.QueueSize), // buffered job queue
172
-
logger: cfg.Logger,
173
-
randSource: rand.New(rand.NewSource(time.Now().UnixNano())),
174
-
}
175
-
}
176
-
177
-
func (c *EventConsumer) Start(ctx context.Context) {
178
-
c.cfg.Logger.Info("starting consumer", "config", c.cfg)
179
-
180
-
// start workers
181
-
for range c.cfg.WorkerCount {
182
-
c.wg.Add(1)
183
-
go c.worker(ctx)
184
-
}
185
-
186
-
// start streaming
187
-
for source := range c.cfg.Sources {
188
-
c.wg.Add(1)
189
-
go c.startConnectionLoop(ctx, source)
190
-
}
191
-
}
192
-
193
-
func (c *EventConsumer) Stop() {
194
-
c.connMap.Range(func(_, val any) bool {
195
-
if conn, ok := val.(*websocket.Conn); ok {
196
-
conn.Close()
197
-
}
198
-
return true
199
-
})
200
-
c.wg.Wait()
201
-
close(c.jobQueue)
202
-
}
203
-
204
-
func (c *EventConsumer) AddSource(ctx context.Context, s EventSource) {
205
-
c.mu.Lock()
206
-
c.cfg.Sources[s] = struct{}{}
207
-
c.wg.Add(1)
208
-
go c.startConnectionLoop(ctx, s)
209
-
c.mu.Unlock()
210
-
}
211
-
212
-
func (c *EventConsumer) worker(ctx context.Context) {
213
-
defer c.wg.Done()
214
-
for {
215
-
select {
216
-
case <-ctx.Done():
217
-
return
218
-
case j, ok := <-c.jobQueue:
219
-
if !ok {
220
-
return
221
-
}
222
-
223
-
var msg Message
224
-
err := json.Unmarshal(j.message, &msg)
225
-
if err != nil {
226
-
c.logger.Error("error deserializing message", "source", j.source.Knot, "err", err)
227
-
return
228
-
}
229
-
230
-
// update cursor
231
-
c.cfg.CursorStore.Set(j.source.Knot, time.Now().Unix())
232
-
233
-
if err := c.cfg.ProcessFunc(j.source, msg); err != nil {
234
-
c.logger.Error("error processing message", "source", j.source, "err", err)
235
-
}
236
-
}
237
-
}
238
-
}
239
-
240
-
func (c *EventConsumer) startConnectionLoop(ctx context.Context, source EventSource) {
241
-
defer c.wg.Done()
242
-
retryInterval := c.cfg.RetryInterval
243
-
for {
244
-
select {
245
-
case <-ctx.Done():
246
-
return
247
-
default:
248
-
err := c.runConnection(ctx, source)
249
-
if err != nil {
250
-
c.logger.Error("connection failed", "source", source, "err", err)
251
-
}
252
-
253
-
// apply jitter
254
-
jitter := time.Duration(c.randSource.Int63n(int64(retryInterval) / 5))
255
-
delay := retryInterval + jitter
256
-
257
-
if retryInterval < c.cfg.MaxRetryInterval {
258
-
retryInterval *= 2
259
-
if retryInterval > c.cfg.MaxRetryInterval {
260
-
retryInterval = c.cfg.MaxRetryInterval
261
-
}
262
-
}
263
-
c.logger.Info("retrying connection", "source", source, "delay", delay)
264
-
select {
265
-
case <-time.After(delay):
266
-
case <-ctx.Done():
267
-
return
268
-
}
269
-
}
270
-
}
271
-
}
272
-
273
-
func (c *EventConsumer) runConnection(ctx context.Context, source EventSource) error {
274
-
connCtx, cancel := context.WithTimeout(ctx, c.cfg.ConnectionTimeout)
275
-
defer cancel()
276
-
277
-
cursor := c.cfg.CursorStore.Get(source.Knot)
278
-
279
-
u, err := c.buildUrl(source, cursor)
280
-
if err != nil {
281
-
return err
282
-
}
283
-
284
-
c.logger.Info("connecting", "url", u.String())
285
-
conn, _, err := c.dialer.DialContext(connCtx, u.String(), nil)
286
-
if err != nil {
287
-
return err
288
-
}
289
-
defer conn.Close()
290
-
c.connMap.Store(source, conn)
291
-
defer c.connMap.Delete(source)
292
-
293
-
c.logger.Info("connected", "source", source)
294
-
295
-
for {
296
-
select {
297
-
case <-ctx.Done():
298
-
return nil
299
-
default:
300
-
msgType, msg, err := conn.ReadMessage()
301
-
if err != nil {
302
-
return err
303
-
}
304
-
if msgType != websocket.TextMessage {
305
-
continue
306
-
}
307
-
select {
308
-
case c.jobQueue <- job{source: source, message: msg}:
309
-
case <-ctx.Done():
310
-
return nil
311
-
}
312
-
}
313
-
}
314
-
}
+4
-2
knotserver/db/events.go
+4
-2
knotserver/db/events.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"time"
5
6
6
-
"tangled.sh/tangled.sh/core/knotserver/notifier"
7
+
"tangled.sh/tangled.sh/core/notifier"
7
8
)
8
9
9
10
type Event struct {
···
16
17
func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error {
17
18
18
19
_, err := d.db.Exec(
19
-
`insert into events (rkey, nsid, event) values (?, ?, ?)`,
20
+
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
20
21
event.Rkey,
21
22
event.Nsid,
22
23
event.EventJson,
24
+
time.Now().UnixNano(),
23
25
)
24
26
25
27
notifier.NotifyAll()
+5
-1
knotserver/events.go
+5
-1
knotserver/events.go
···
43
43
}
44
44
}()
45
45
46
+
defaultCursor := time.Now().UnixNano()
46
47
cursorStr := r.URL.Query().Get("cursor")
47
48
cursor, err := strconv.ParseInt(cursorStr, 10, 64)
48
49
if err != nil {
49
-
l.Error("empty or invalid cursor, defaulting to zero", "invalidCursor", cursorStr)
50
+
l.Error("empty or invalid cursor", "invalidCursor", cursorStr, "default", defaultCursor)
51
+
}
52
+
if cursor == 0 {
53
+
cursor = defaultCursor
50
54
}
51
55
52
56
// complete backfill first before going to live data
+15
-16
knotserver/git/git.go
+15
-16
knotserver/git/git.go
···
2
2
3
3
import (
4
4
"archive/tar"
5
+
"bytes"
5
6
"fmt"
6
7
"io"
7
8
"io/fs"
···
158
159
fmt.Sprintf("--count"),
159
160
)
160
161
if err != nil {
161
-
return 0, fmt.Errorf("failed to run rev-list", err)
162
+
return 0, fmt.Errorf("failed to run rev-list: %w", err)
162
163
}
163
164
164
165
count, err := strconv.Atoi(strings.TrimSpace(string(output)))
···
201
202
}
202
203
203
204
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
204
-
buf := []byte{}
205
-
206
205
c, err := g.r.CommitObject(g.h)
207
206
if err != nil {
208
207
return nil, fmt.Errorf("commit object: %w", err)
···
219
218
}
220
219
221
220
isbin, _ := file.IsBinary()
222
-
223
-
if !isbin {
224
-
reader, err := file.Reader()
225
-
if err != nil {
226
-
return nil, err
227
-
}
228
-
bufReader := io.LimitReader(reader, cap)
229
-
_, err = bufReader.Read(buf)
230
-
if err != nil {
231
-
return nil, err
232
-
}
233
-
return buf, nil
234
-
} else {
221
+
if isbin {
235
222
return nil, ErrBinaryFile
236
223
}
224
+
225
+
reader, err := file.Reader()
226
+
if err != nil {
227
+
return nil, err
228
+
}
229
+
230
+
buf := new(bytes.Buffer)
231
+
if _, err = buf.ReadFrom(io.LimitReader(reader, cap)); err != nil {
232
+
return nil, err
233
+
}
234
+
235
+
return buf.Bytes(), nil
237
236
}
238
237
239
238
func (g *GitRepo) FileContent(path string) (string, error) {
+79
knotserver/git/tree.go
+79
knotserver/git/tree.go
···
2
2
3
3
import (
4
4
"context"
5
+
"errors"
5
6
"fmt"
6
7
"path"
7
8
"time"
···
78
79
79
80
return nts
80
81
}
82
+
83
+
var (
84
+
TerminateWalk error = errors.New("terminate walk")
85
+
)
86
+
87
+
type callback = func(node object.TreeEntry, parent *object.Tree, fullPath string) error
88
+
89
+
func (g *GitRepo) Walk(
90
+
ctx context.Context,
91
+
root string,
92
+
cb callback,
93
+
) error {
94
+
c, err := g.r.CommitObject(g.h)
95
+
if err != nil {
96
+
return fmt.Errorf("commit object: %w", err)
97
+
}
98
+
99
+
tree, err := c.Tree()
100
+
if err != nil {
101
+
return fmt.Errorf("file tree: %w", err)
102
+
}
103
+
104
+
subtree := tree
105
+
if root != "" {
106
+
subtree, err = tree.Tree(root)
107
+
if err != nil {
108
+
return fmt.Errorf("sub tree: %w", err)
109
+
}
110
+
}
111
+
112
+
return g.walkHelper(ctx, root, subtree, cb)
113
+
}
114
+
115
+
func (g *GitRepo) walkHelper(
116
+
ctx context.Context,
117
+
root string,
118
+
currentTree *object.Tree,
119
+
cb callback,
120
+
) error {
121
+
for _, e := range currentTree.Entries {
122
+
// check if context hits deadline before processing
123
+
select {
124
+
case <-ctx.Done():
125
+
return ctx.Err()
126
+
default:
127
+
}
128
+
129
+
mode, err := e.Mode.ToOSFileMode()
130
+
if err != nil {
131
+
// TODO: log this
132
+
continue
133
+
}
134
+
135
+
if e.Mode.IsFile() {
136
+
err = cb(e, currentTree, root)
137
+
if errors.Is(err, TerminateWalk) {
138
+
return err
139
+
}
140
+
}
141
+
142
+
// e is a directory
143
+
if mode.IsDir() {
144
+
subtree, err := currentTree.Tree(e.Name)
145
+
if err != nil {
146
+
return fmt.Errorf("sub tree %s: %w", e.Name, err)
147
+
}
148
+
149
+
fullPath := path.Join(root, e.Name)
150
+
151
+
err = g.walkHelper(ctx, fullPath, subtree, cb)
152
+
if err != nil {
153
+
return err
154
+
}
155
+
}
156
+
}
157
+
158
+
return nil
159
+
}
+3
-3
knotserver/handler.go
+3
-3
knotserver/handler.go
···
11
11
"tangled.sh/tangled.sh/core/jetstream"
12
12
"tangled.sh/tangled.sh/core/knotserver/config"
13
13
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/knotserver/notifier"
14
+
"tangled.sh/tangled.sh/core/notifier"
15
15
"tangled.sh/tangled.sh/core/rbac"
16
16
)
17
17
···
46
46
init: make(chan struct{}),
47
47
}
48
48
49
-
err := e.AddDomain(ThisServer)
49
+
err := e.AddKnot(ThisServer)
50
50
if err != nil {
51
51
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
52
52
}
···
187
187
}
188
188
}
189
189
190
-
w.Header().Set("Content-Type", "text/plain")
190
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
191
191
fmt.Fprintf(w, "knotserver/%s", version)
192
192
}
+305
knotserver/ingester.go
+305
knotserver/ingester.go
···
1
+
package knotserver
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"net/url"
10
+
"path/filepath"
11
+
"slices"
12
+
"strings"
13
+
14
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
16
+
"github.com/bluesky-social/indigo/xrpc"
17
+
"github.com/bluesky-social/jetstream/pkg/models"
18
+
securejoin "github.com/cyphar/filepath-securejoin"
19
+
"tangled.sh/tangled.sh/core/api/tangled"
20
+
"tangled.sh/tangled.sh/core/appview/idresolver"
21
+
"tangled.sh/tangled.sh/core/knotserver/db"
22
+
"tangled.sh/tangled.sh/core/knotserver/git"
23
+
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/workflow"
25
+
)
26
+
27
+
func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error {
28
+
l := log.FromContext(ctx)
29
+
pk := db.PublicKey{
30
+
Did: did,
31
+
PublicKey: record,
32
+
}
33
+
if err := h.db.AddPublicKey(pk); err != nil {
34
+
l.Error("failed to add public key", "error", err)
35
+
return fmt.Errorf("failed to add public key: %w", err)
36
+
}
37
+
l.Info("added public key from firehose", "did", did)
38
+
return nil
39
+
}
40
+
41
+
func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error {
42
+
l := log.FromContext(ctx)
43
+
44
+
if record.Domain != h.c.Server.Hostname {
45
+
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
46
+
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
+
}
48
+
49
+
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
50
+
if err != nil || !ok {
51
+
l.Error("failed to add member", "did", did)
52
+
return fmt.Errorf("failed to enforce permissions: %w", err)
53
+
}
54
+
55
+
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
56
+
l.Error("failed to add member", "error", err)
57
+
return fmt.Errorf("failed to add member: %w", err)
58
+
}
59
+
l.Info("added member from firehose", "member", record.Subject)
60
+
61
+
if err := h.db.AddDid(did); err != nil {
62
+
l.Error("failed to add did", "error", err)
63
+
return fmt.Errorf("failed to add did: %w", err)
64
+
}
65
+
h.jc.AddDid(did)
66
+
67
+
if err := h.fetchAndAddKeys(ctx, did); err != nil {
68
+
return fmt.Errorf("failed to fetch and add keys: %w", err)
69
+
}
70
+
71
+
return nil
72
+
}
73
+
74
+
func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error {
75
+
l := log.FromContext(ctx)
76
+
l = l.With("handler", "processPull")
77
+
l = l.With("did", did)
78
+
l = l.With("target_repo", record.TargetRepo)
79
+
l = l.With("target_branch", record.TargetBranch)
80
+
81
+
if record.Source == nil {
82
+
reason := "not a branch-based pull request"
83
+
l.Info("ignoring pull record", "reason", reason)
84
+
return fmt.Errorf("ignoring pull record: %s", reason)
85
+
}
86
+
87
+
if record.Source.Repo != nil {
88
+
reason := "fork based pull"
89
+
l.Info("ignoring pull record", "reason", reason)
90
+
return fmt.Errorf("ignoring pull record: %s", reason)
91
+
}
92
+
93
+
allDids, err := h.db.GetAllDids()
94
+
if err != nil {
95
+
return err
96
+
}
97
+
98
+
// presently: we only process PRs from collaborators for pipelines
99
+
if !slices.Contains(allDids, did) {
100
+
reason := "not a known did"
101
+
l.Info("rejecting pull record", "reason", reason)
102
+
return fmt.Errorf("rejected pull record: %s, %s", reason, did)
103
+
}
104
+
105
+
repoAt, err := syntax.ParseATURI(record.TargetRepo)
106
+
if err != nil {
107
+
return err
108
+
}
109
+
110
+
// resolve this aturi to extract the repo record
111
+
resolver := idresolver.DefaultResolver()
112
+
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
113
+
if err != nil || ident.Handle.IsInvalidHandle() {
114
+
return fmt.Errorf("failed to resolve handle: %w", err)
115
+
}
116
+
117
+
xrpcc := xrpc.Client{
118
+
Host: ident.PDSEndpoint(),
119
+
}
120
+
121
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
122
+
if err != nil {
123
+
return err
124
+
}
125
+
126
+
repo := resp.Value.Val.(*tangled.Repo)
127
+
128
+
if repo.Knot != h.c.Server.Hostname {
129
+
reason := "not this knot"
130
+
l.Info("rejecting pull record", "reason", reason)
131
+
return fmt.Errorf("rejected pull record: %s", reason)
132
+
}
133
+
134
+
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
135
+
if err != nil {
136
+
return err
137
+
}
138
+
139
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
140
+
if err != nil {
141
+
return err
142
+
}
143
+
144
+
gr, err := git.Open(repoPath, record.Source.Branch)
145
+
if err != nil {
146
+
return err
147
+
}
148
+
149
+
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
150
+
if err != nil {
151
+
return err
152
+
}
153
+
154
+
var pipeline workflow.Pipeline
155
+
for _, e := range workflowDir {
156
+
if !e.IsFile {
157
+
continue
158
+
}
159
+
160
+
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
161
+
contents, err := gr.RawContent(fpath)
162
+
if err != nil {
163
+
continue
164
+
}
165
+
166
+
wf, err := workflow.FromFile(e.Name, contents)
167
+
if err != nil {
168
+
// TODO: log here, respond to client that is pushing
169
+
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
170
+
continue
171
+
}
172
+
173
+
pipeline = append(pipeline, wf)
174
+
}
175
+
176
+
trigger := tangled.Pipeline_PullRequestTriggerData{
177
+
Action: "create",
178
+
SourceBranch: record.Source.Branch,
179
+
SourceSha: record.Source.Sha,
180
+
TargetBranch: record.TargetBranch,
181
+
}
182
+
183
+
compiler := workflow.Compiler{
184
+
Trigger: tangled.Pipeline_TriggerMetadata{
185
+
Kind: string(workflow.TriggerKindPullRequest),
186
+
PullRequest: &trigger,
187
+
Repo: &tangled.Pipeline_TriggerRepo{
188
+
Did: repo.Owner,
189
+
Knot: repo.Knot,
190
+
Repo: repo.Name,
191
+
},
192
+
},
193
+
}
194
+
195
+
cp := compiler.Compile(pipeline)
196
+
eventJson, err := json.Marshal(cp)
197
+
if err != nil {
198
+
return err
199
+
}
200
+
201
+
// do not run empty pipelines
202
+
if cp.Workflows == nil {
203
+
return nil
204
+
}
205
+
206
+
event := db.Event{
207
+
Rkey: TID(),
208
+
Nsid: tangled.PipelineNSID,
209
+
EventJson: string(eventJson),
210
+
}
211
+
212
+
return h.db.InsertEvent(event, h.n)
213
+
}
214
+
215
+
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
216
+
l := log.FromContext(ctx)
217
+
218
+
keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did)
219
+
if err != nil {
220
+
l.Error("error building endpoint url", "did", did, "error", err.Error())
221
+
return fmt.Errorf("error building endpoint url: %w", err)
222
+
}
223
+
224
+
resp, err := http.Get(keysEndpoint)
225
+
if err != nil {
226
+
l.Error("error getting keys", "did", did, "error", err)
227
+
return fmt.Errorf("error getting keys: %w", err)
228
+
}
229
+
defer resp.Body.Close()
230
+
231
+
if resp.StatusCode == http.StatusNotFound {
232
+
l.Info("no keys found for did", "did", did)
233
+
return nil
234
+
}
235
+
236
+
plaintext, err := io.ReadAll(resp.Body)
237
+
if err != nil {
238
+
l.Error("error reading response body", "error", err)
239
+
return fmt.Errorf("error reading response body: %w", err)
240
+
}
241
+
242
+
for _, key := range strings.Split(string(plaintext), "\n") {
243
+
if key == "" {
244
+
continue
245
+
}
246
+
pk := db.PublicKey{
247
+
Did: did,
248
+
}
249
+
pk.Key = key
250
+
if err := h.db.AddPublicKey(pk); err != nil {
251
+
l.Error("failed to add public key", "error", err)
252
+
return fmt.Errorf("failed to add public key: %w", err)
253
+
}
254
+
}
255
+
return nil
256
+
}
257
+
258
+
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
259
+
did := event.Did
260
+
if event.Kind != models.EventKindCommit {
261
+
return nil
262
+
}
263
+
264
+
var err error
265
+
defer func() {
266
+
eventTime := event.TimeUS
267
+
lastTimeUs := eventTime + 1
268
+
fmt.Println("lastTimeUs", lastTimeUs)
269
+
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
+
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
+
}
272
+
}()
273
+
274
+
raw := json.RawMessage(event.Commit.Record)
275
+
276
+
switch event.Commit.Collection {
277
+
case tangled.PublicKeyNSID:
278
+
var record tangled.PublicKey
279
+
if err := json.Unmarshal(raw, &record); err != nil {
280
+
return fmt.Errorf("failed to unmarshal record: %w", err)
281
+
}
282
+
if err := h.processPublicKey(ctx, did, record); err != nil {
283
+
return fmt.Errorf("failed to process public key: %w", err)
284
+
}
285
+
286
+
case tangled.KnotMemberNSID:
287
+
var record tangled.KnotMember
288
+
if err := json.Unmarshal(raw, &record); err != nil {
289
+
return fmt.Errorf("failed to unmarshal record: %w", err)
290
+
}
291
+
if err := h.processKnotMember(ctx, did, record); err != nil {
292
+
return fmt.Errorf("failed to process knot member: %w", err)
293
+
}
294
+
case tangled.RepoPullNSID:
295
+
var record tangled.RepoPull
296
+
if err := json.Unmarshal(raw, &record); err != nil {
297
+
return fmt.Errorf("failed to unmarshal record: %w", err)
298
+
}
299
+
if err := h.processPull(ctx, did, record); err != nil {
300
+
return fmt.Errorf("failed to process knot member: %w", err)
301
+
}
302
+
}
303
+
304
+
return err
305
+
}
+9
-7
knotserver/internal.go
+9
-7
knotserver/internal.go
···
15
15
"tangled.sh/tangled.sh/core/knotserver/config"
16
16
"tangled.sh/tangled.sh/core/knotserver/db"
17
17
"tangled.sh/tangled.sh/core/knotserver/git"
18
-
"tangled.sh/tangled.sh/core/knotserver/notifier"
18
+
"tangled.sh/tangled.sh/core/notifier"
19
19
"tangled.sh/tangled.sh/core/rbac"
20
20
"tangled.sh/tangled.sh/core/workflow"
21
21
)
···
147
147
}
148
148
149
149
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
150
-
const (
151
-
WorkflowDir = ".tangled/workflows"
152
-
)
153
-
154
150
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
155
151
if err != nil {
156
152
return err
···
166
162
return err
167
163
}
168
164
169
-
workflowDir, err := gr.FileTree(context.Background(), WorkflowDir)
165
+
workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
170
166
if err != nil {
171
167
return err
172
168
}
···
177
173
continue
178
174
}
179
175
180
-
fpath := filepath.Join(WorkflowDir, e.Name)
176
+
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
181
177
contents, err := gr.RawContent(fpath)
182
178
if err != nil {
183
179
continue
···
186
182
wf, err := workflow.FromFile(e.Name, contents)
187
183
if err != nil {
188
184
// TODO: log here, respond to client that is pushing
185
+
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
189
186
continue
190
187
}
191
188
···
215
212
eventJson, err := json.Marshal(cp)
216
213
if err != nil {
217
214
return err
215
+
}
216
+
217
+
// do not run empty pipelines
218
+
if cp.Workflows == nil {
219
+
return nil
218
220
}
219
221
220
222
event := db.Event{
-147
knotserver/jetstream.go
-147
knotserver/jetstream.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"io"
8
-
"net/http"
9
-
"net/url"
10
-
"strings"
11
-
12
-
"github.com/bluesky-social/jetstream/pkg/models"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/log"
16
-
)
17
-
18
-
func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error {
19
-
l := log.FromContext(ctx)
20
-
pk := db.PublicKey{
21
-
Did: did,
22
-
PublicKey: record,
23
-
}
24
-
if err := h.db.AddPublicKey(pk); err != nil {
25
-
l.Error("failed to add public key", "error", err)
26
-
return fmt.Errorf("failed to add public key: %w", err)
27
-
}
28
-
l.Info("added public key from firehose", "did", did)
29
-
return nil
30
-
}
31
-
32
-
func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error {
33
-
l := log.FromContext(ctx)
34
-
35
-
if record.Domain != h.c.Server.Hostname {
36
-
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
37
-
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
38
-
}
39
-
40
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
41
-
if err != nil || !ok {
42
-
l.Error("failed to add member", "did", did)
43
-
return fmt.Errorf("failed to enforce permissions: %w", err)
44
-
}
45
-
46
-
if err := h.e.AddMember(ThisServer, record.Subject); err != nil {
47
-
l.Error("failed to add member", "error", err)
48
-
return fmt.Errorf("failed to add member: %w", err)
49
-
}
50
-
l.Info("added member from firehose", "member", record.Subject)
51
-
52
-
if err := h.db.AddDid(did); err != nil {
53
-
l.Error("failed to add did", "error", err)
54
-
return fmt.Errorf("failed to add did: %w", err)
55
-
}
56
-
h.jc.AddDid(did)
57
-
58
-
if err := h.fetchAndAddKeys(ctx, did); err != nil {
59
-
return fmt.Errorf("failed to fetch and add keys: %w", err)
60
-
}
61
-
62
-
return nil
63
-
}
64
-
65
-
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
66
-
l := log.FromContext(ctx)
67
-
68
-
keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did)
69
-
if err != nil {
70
-
l.Error("error building endpoint url", "did", did, "error", err.Error())
71
-
return fmt.Errorf("error building endpoint url: %w", err)
72
-
}
73
-
74
-
resp, err := http.Get(keysEndpoint)
75
-
if err != nil {
76
-
l.Error("error getting keys", "did", did, "error", err)
77
-
return fmt.Errorf("error getting keys: %w", err)
78
-
}
79
-
defer resp.Body.Close()
80
-
81
-
if resp.StatusCode == http.StatusNotFound {
82
-
l.Info("no keys found for did", "did", did)
83
-
return nil
84
-
}
85
-
86
-
plaintext, err := io.ReadAll(resp.Body)
87
-
if err != nil {
88
-
l.Error("error reading response body", "error", err)
89
-
return fmt.Errorf("error reading response body: %w", err)
90
-
}
91
-
92
-
for _, key := range strings.Split(string(plaintext), "\n") {
93
-
if key == "" {
94
-
continue
95
-
}
96
-
pk := db.PublicKey{
97
-
Did: did,
98
-
}
99
-
pk.Key = key
100
-
if err := h.db.AddPublicKey(pk); err != nil {
101
-
l.Error("failed to add public key", "error", err)
102
-
return fmt.Errorf("failed to add public key: %w", err)
103
-
}
104
-
}
105
-
return nil
106
-
}
107
-
108
-
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
109
-
did := event.Did
110
-
if event.Kind != models.EventKindCommit {
111
-
return nil
112
-
}
113
-
114
-
var err error
115
-
defer func() {
116
-
eventTime := event.TimeUS
117
-
lastTimeUs := eventTime + 1
118
-
fmt.Println("lastTimeUs", lastTimeUs)
119
-
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
120
-
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
121
-
}
122
-
}()
123
-
124
-
raw := json.RawMessage(event.Commit.Record)
125
-
126
-
switch event.Commit.Collection {
127
-
case tangled.PublicKeyNSID:
128
-
var record tangled.PublicKey
129
-
if err := json.Unmarshal(raw, &record); err != nil {
130
-
return fmt.Errorf("failed to unmarshal record: %w", err)
131
-
}
132
-
if err := h.processPublicKey(ctx, did, record); err != nil {
133
-
return fmt.Errorf("failed to process public key: %w", err)
134
-
}
135
-
136
-
case tangled.KnotMemberNSID:
137
-
var record tangled.KnotMember
138
-
if err := json.Unmarshal(raw, &record); err != nil {
139
-
return fmt.Errorf("failed to unmarshal record: %w", err)
140
-
}
141
-
if err := h.processKnotMember(ctx, did, record); err != nil {
142
-
return fmt.Errorf("failed to process knot member: %w", err)
143
-
}
144
-
}
145
-
146
-
return err
147
-
}
-43
knotserver/notifier/notifier.go
-43
knotserver/notifier/notifier.go
···
1
-
package notifier
2
-
3
-
import (
4
-
"sync"
5
-
)
6
-
7
-
type Notifier struct {
8
-
subscribers map[chan struct{}]struct{}
9
-
mu sync.Mutex
10
-
}
11
-
12
-
func New() Notifier {
13
-
return Notifier{
14
-
subscribers: make(map[chan struct{}]struct{}),
15
-
}
16
-
}
17
-
18
-
func (n *Notifier) Subscribe() chan struct{} {
19
-
ch := make(chan struct{}, 1)
20
-
n.mu.Lock()
21
-
n.subscribers[ch] = struct{}{}
22
-
n.mu.Unlock()
23
-
return ch
24
-
}
25
-
26
-
func (n *Notifier) Unsubscribe(ch chan struct{}) {
27
-
n.mu.Lock()
28
-
delete(n.subscribers, ch)
29
-
close(ch)
30
-
n.mu.Unlock()
31
-
}
32
-
33
-
func (n *Notifier) NotifyAll() {
34
-
n.mu.Lock()
35
-
for ch := range n.subscribers {
36
-
select {
37
-
case ch <- struct{}{}:
38
-
default:
39
-
// avoid blocking if channel is full
40
-
}
41
-
}
42
-
n.mu.Unlock()
43
-
}
+46
-36
knotserver/routes.go
+46
-36
knotserver/routes.go
···
18
18
"strconv"
19
19
"strings"
20
20
"sync"
21
+
"time"
21
22
22
23
securejoin "github.com/cyphar/filepath-securejoin"
23
24
"github.com/gliderlabs/ssh"
···
763
764
}
764
765
765
766
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
766
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
767
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
767
768
ref := chi.URLParam(r, "ref")
768
769
ref, _ = url.PathUnescape(ref)
769
770
770
771
l := h.l.With("handler", "RepoLanguages")
771
772
772
-
gr, err := git.Open(path, ref)
773
+
gr, err := git.Open(repoPath, ref)
773
774
if err != nil {
774
775
l.Error("opening repo", "error", err.Error())
775
776
notFound(w)
776
777
return
777
778
}
778
779
779
-
languageFileCount := make(map[string]int)
780
+
sizes := make(map[string]int64)
781
+
782
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
783
+
defer cancel()
784
+
785
+
err = gr.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error {
786
+
filepath := path.Join(root, node.Name)
787
+
788
+
content, err := gr.FileContentN(filepath, 16*1024) // 16KB
789
+
if err != nil {
790
+
return nil
791
+
}
792
+
793
+
if enry.IsGenerated(filepath, content) {
794
+
return nil
795
+
}
780
796
781
-
err = recurseEntireTree(r.Context(), gr, func(absPath string) {
782
-
lang, safe := enry.GetLanguageByExtension(absPath)
783
-
if len(lang) == 0 || !safe {
784
-
content, _ := gr.FileContentN(absPath, 1024)
785
-
if !safe {
786
-
lang = enry.GetLanguage(absPath, content)
787
-
} else {
788
-
lang, _ = enry.GetLanguageByContent(absPath, content)
789
-
if len(lang) == 0 {
790
-
return
791
-
}
792
-
}
797
+
language := analyzeLanguage(node, content)
798
+
if group := enry.GetLanguageGroup(language); group != "" {
799
+
language = group
793
800
}
794
801
795
-
v, ok := languageFileCount[lang]
796
-
if ok {
797
-
languageFileCount[lang] = v + 1
798
-
} else {
799
-
languageFileCount[lang] = 1
802
+
langType := enry.GetLanguageType(language)
803
+
if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown {
804
+
return nil
800
805
}
801
-
}, "")
806
+
807
+
sz, _ := parent.Size(node.Name)
808
+
sizes[language] += sz
809
+
810
+
return nil
811
+
})
802
812
if err != nil {
803
813
l.Error("failed to recurse file tree", "error", err.Error())
804
814
writeError(w, err.Error(), http.StatusNoContent)
805
815
return
806
816
}
807
817
808
-
resp := types.RepoLanguageResponse{Languages: languageFileCount}
818
+
resp := types.RepoLanguageResponse{Languages: sizes}
809
819
810
820
writeJSON(w, resp)
811
821
return
812
822
}
813
823
814
-
func recurseEntireTree(ctx context.Context, git *git.GitRepo, callback func(absPath string), filePath string) error {
815
-
files, err := git.FileTree(ctx, filePath)
816
-
if err != nil {
817
-
log.Println(err)
818
-
return err
824
+
func analyzeLanguage(node object.TreeEntry, content []byte) string {
825
+
language, ok := enry.GetLanguageByExtension(node.Name)
826
+
if ok {
827
+
return language
819
828
}
820
829
821
-
for _, file := range files {
822
-
absPath := path.Join(filePath, file.Name)
823
-
if !file.IsFile {
824
-
return recurseEntireTree(ctx, git, callback, absPath)
825
-
}
826
-
callback(absPath)
830
+
language, ok = enry.GetLanguageByFilename(node.Name)
831
+
if ok {
832
+
return language
833
+
}
834
+
835
+
if len(content) == 0 {
836
+
return enry.OtherLanguage
827
837
}
828
838
829
-
return nil
839
+
return enry.GetLanguage(node.Name, content)
830
840
}
831
841
832
842
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
···
1177
1187
}
1178
1188
h.jc.AddDid(did)
1179
1189
1180
-
if err := h.e.AddMember(ThisServer, did); err != nil {
1190
+
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1181
1191
l.Error("adding member", "error", err.Error())
1182
1192
writeError(w, err.Error(), http.StatusInternalServerError)
1183
1193
return
···
1312
1322
}
1313
1323
h.jc.AddDid(data.Did)
1314
1324
1315
-
if err := h.e.AddOwner(ThisServer, data.Did); err != nil {
1325
+
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1316
1326
l.Error("adding owner", "error", err.Error())
1317
1327
writeError(w, err.Error(), http.StatusInternalServerError)
1318
1328
return
+2
-1
knotserver/server.go
+2
-1
knotserver/server.go
···
11
11
"tangled.sh/tangled.sh/core/jetstream"
12
12
"tangled.sh/tangled.sh/core/knotserver/config"
13
13
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/knotserver/notifier"
15
14
"tangled.sh/tangled.sh/core/log"
15
+
"tangled.sh/tangled.sh/core/notifier"
16
16
"tangled.sh/tangled.sh/core/rbac"
17
17
)
18
18
···
75
75
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
76
76
tangled.PublicKeyNSID,
77
77
tangled.KnotMemberNSID,
78
+
tangled.RepoPullNSID,
78
79
}, nil, logger, db, true, c.Server.LogDids)
79
80
if err != nil {
80
81
logger.Error("failed to setup jetstream", "error", err)
+34
lexicons/feed/reaction.json
+34
lexicons/feed/reaction.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.feed.reaction",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"reaction",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"reaction": {
23
+
"type": "string",
24
+
"enum": [ "๐", "๐", "๐", "๐", "๐ซค", "โค๏ธ", "๐", "๐" ]
25
+
},
26
+
"createdAt": {
27
+
"type": "string",
28
+
"format": "datetime"
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
+53
lexicons/pipeline/status.json
+53
lexicons/pipeline/status.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.pipeline.status",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": ["pipeline", "workflow", "status", "createdAt"],
13
+
"properties": {
14
+
"pipeline": {
15
+
"type": "string",
16
+
"format": "at-uri",
17
+
"description": "ATURI of the pipeline"
18
+
},
19
+
"workflow": {
20
+
"type": "string",
21
+
"format": "at-uri",
22
+
"description": "name of the workflow within this pipeline"
23
+
},
24
+
"status": {
25
+
"type": "string",
26
+
"description": "status of the workflow",
27
+
"enum": [
28
+
"pending",
29
+
"running",
30
+
"failed",
31
+
"timeout",
32
+
"cancelled",
33
+
"success"
34
+
]
35
+
},
36
+
"createdAt": {
37
+
"type": "string",
38
+
"format": "datetime",
39
+
"description": "time of creation of this status update"
40
+
},
41
+
"error": {
42
+
"type": "string",
43
+
"description": "error message if failed"
44
+
},
45
+
"exitCode": {
46
+
"type": "integer",
47
+
"description": "exit code if failed"
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
+44
-44
lexicons/pipeline.json
+44
-44
lexicons/pipeline.json
···
139
139
"inputs": {
140
140
"type": "array",
141
141
"items": {
142
-
"type": "object",
143
-
"required": [
144
-
"key",
145
-
"value"
146
-
],
147
-
"properties": {
148
-
"key": {
149
-
"type": "string"
150
-
},
151
-
"value": {
152
-
"type": "string"
153
-
}
154
-
}
142
+
"type": "ref",
143
+
"ref": "#pair"
155
144
}
156
145
}
157
146
}
···
170
159
"type": "string"
171
160
},
172
161
"dependencies": {
173
-
"type": "ref",
174
-
"ref": "#dependencies"
162
+
"type": "array",
163
+
"items": {
164
+
"type": "ref",
165
+
"ref": "#dependency"
166
+
}
175
167
},
176
168
"steps": {
177
169
"type": "array",
···
183
175
"environment": {
184
176
"type": "array",
185
177
"items": {
186
-
"type": "object",
187
-
"required": [
188
-
"key",
189
-
"value"
190
-
],
191
-
"properties": {
192
-
"key": {
193
-
"type": "string"
194
-
},
195
-
"value": {
196
-
"type": "string"
197
-
}
198
-
}
178
+
"type": "ref",
179
+
"ref": "#pair"
199
180
}
200
181
},
201
182
"clone": {
···
204
185
}
205
186
}
206
187
},
207
-
"dependencies": {
208
-
"type": "array",
209
-
"items": {
210
-
"type": "object",
211
-
"required": [
212
-
"registry",
213
-
"packages"
214
-
],
215
-
"properties": {
216
-
"registry": {
188
+
"dependency": {
189
+
"type": "object",
190
+
"required": [
191
+
"registry",
192
+
"packages"
193
+
],
194
+
"properties": {
195
+
"registry": {
196
+
"type": "string"
197
+
},
198
+
"packages": {
199
+
"type": "array",
200
+
"items": {
217
201
"type": "string"
218
-
},
219
-
"packages": {
220
-
"type": "array",
221
-
"items": {
222
-
"type": "string"
223
-
}
224
202
}
225
203
}
226
204
}
···
255
233
"type": "string"
256
234
},
257
235
"command": {
236
+
"type": "string"
237
+
},
238
+
"environment": {
239
+
"type": "array",
240
+
"items": {
241
+
"type": "ref",
242
+
"ref": "#pair"
243
+
}
244
+
}
245
+
}
246
+
},
247
+
"pair": {
248
+
"type": "object",
249
+
"required": [
250
+
"key",
251
+
"value"
252
+
],
253
+
"properties": {
254
+
"key": {
255
+
"type": "string"
256
+
},
257
+
"value": {
258
258
"type": "string"
259
259
}
260
260
}
+7
-1
lexicons/pulls/pull.json
+7
-1
lexicons/pulls/pull.json
···
51
51
"source": {
52
52
"type": "object",
53
53
"required": [
54
-
"branch"
54
+
"branch",
55
+
"sha"
55
56
],
56
57
"properties": {
57
58
"branch": {
58
59
"type": "string"
60
+
},
61
+
"sha": {
62
+
"type": "string",
63
+
"minLength": 40,
64
+
"maxLength": 40
59
65
},
60
66
"repo": {
61
67
"type": "string",
+4
lexicons/repo.json
+4
lexicons/repo.json
+34
lexicons/spindle/member.json
+34
lexicons/spindle/member.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle.member",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"instance",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"instance": {
23
+
"type": "string",
24
+
"description": "spindle instance that the subject is now a member of"
25
+
},
26
+
"createdAt": {
27
+
"type": "string",
28
+
"format": "datetime"
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
+25
lexicons/spindle.json
+25
lexicons/spindle.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
25
+
+2
-4
nix/modules/knot.nix
+2
-4
nix/modules/knot.nix
···
101
101
102
102
system.activationScripts.gitConfig = ''
103
103
mkdir -p "${cfg.repo.scanPath}"
104
-
chown -R ${cfg.gitUser}:${cfg.gitUser} \
105
-
"${cfg.repo.scanPath}"
104
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
106
105
107
106
mkdir -p "${cfg.stateDir}/.config/git"
108
107
cat > "${cfg.stateDir}/.config/git/config" << EOF
···
110
109
name = Git User
111
110
email = git@example.com
112
111
EOF
113
-
chown -R ${cfg.gitUser}:${cfg.gitUser} \
114
-
"${cfg.stateDir}"
112
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
115
113
'';
116
114
117
115
users.users.${cfg.gitUser} = {
+97
nix/modules/spindle.nix
+97
nix/modules/spindle.nix
···
1
+
{self}: {
2
+
config,
3
+
pkgs,
4
+
lib,
5
+
...
6
+
}: let
7
+
cfg = config.services.tangled-spindle;
8
+
in
9
+
with lib; {
10
+
options = {
11
+
services.tangled-spindle = {
12
+
enable = mkOption {
13
+
type = types.bool;
14
+
default = false;
15
+
description = "Enable a tangled spindle";
16
+
};
17
+
18
+
server = {
19
+
listenAddr = mkOption {
20
+
type = types.str;
21
+
default = "0.0.0.0:6555";
22
+
description = "Address to listen on";
23
+
};
24
+
25
+
dbPath = mkOption {
26
+
type = types.path;
27
+
default = "/var/lib/spindle/spindle.db";
28
+
description = "Path to the database file";
29
+
};
30
+
31
+
hostname = mkOption {
32
+
type = types.str;
33
+
example = "spindle.tangled.sh";
34
+
description = "Hostname for the server (required)";
35
+
};
36
+
37
+
jetstreamEndpoint = mkOption {
38
+
type = types.str;
39
+
default = "wss://jetstream1.us-west.bsky.network/subscribe";
40
+
description = "Jetstream endpoint to subscribe to";
41
+
};
42
+
43
+
dev = mkOption {
44
+
type = types.bool;
45
+
default = false;
46
+
description = "Enable development mode (disables signature verification)";
47
+
};
48
+
49
+
owner = mkOption {
50
+
type = types.str;
51
+
example = "did:plc:qfpnj4og54vl56wngdriaxug";
52
+
description = "DID of owner (required)";
53
+
};
54
+
};
55
+
56
+
pipelines = {
57
+
nixery = mkOption {
58
+
type = types.str;
59
+
default = "nixery.tangled.sh";
60
+
description = "Nixery instance to use";
61
+
};
62
+
63
+
stepTimeout = mkOption {
64
+
type = types.str;
65
+
default = "5m";
66
+
description = "Timeout for each step of a pipeline";
67
+
};
68
+
};
69
+
};
70
+
};
71
+
72
+
config = mkIf cfg.enable {
73
+
virtualisation.docker.enable = true;
74
+
75
+
systemd.services.spindle = {
76
+
description = "spindle service";
77
+
after = ["network.target" "docker.service"];
78
+
wantedBy = ["multi-user.target"];
79
+
serviceConfig = {
80
+
LogsDirectory = "spindle";
81
+
StateDirectory = "spindle";
82
+
Environment = [
83
+
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
84
+
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
85
+
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
86
+
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
87
+
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
88
+
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
89
+
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
+
"SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}"
91
+
];
92
+
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
93
+
Restart = "always";
94
+
};
95
+
};
96
+
};
97
+
}
+2
nix/pkgs/appview.nix
+2
nix/pkgs/appview.nix
···
2
2
buildGoModule,
3
3
stdenv,
4
4
htmx-src,
5
+
htmx-ws-src,
5
6
lucide-src,
6
7
inter-fonts-src,
7
8
ibm-plex-mono-src,
···
21
22
pushd source
22
23
mkdir -p appview/pages/static/{fonts,icons}
23
24
cp -f ${htmx-src} appview/pages/static/htmx.min.js
25
+
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
24
26
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
25
27
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
26
28
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
+22
nix/pkgs/spindle.nix
+22
nix/pkgs/spindle.nix
···
1
+
{
2
+
buildGoModule,
3
+
stdenv,
4
+
sqlite-lib,
5
+
goModHash,
6
+
gitignoreSource,
7
+
}:
8
+
buildGoModule {
9
+
pname = "spindle";
10
+
version = "0.1.0";
11
+
src = gitignoreSource ../..;
12
+
13
+
doCheck = false;
14
+
15
+
subPackages = ["cmd/spindle"];
16
+
vendorHash = goModHash;
17
+
tags = "libsqlite3";
18
+
19
+
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
+
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
+
env.CGO_ENABLED = 1;
22
+
}
+36
-4
nix/vm.nix
+36
-4
nix/vm.nix
···
6
6
system = "x86_64-linux";
7
7
modules = [
8
8
self.nixosModules.knot
9
+
self.nixosModules.spindle
9
10
({
10
11
config,
11
12
pkgs,
12
13
...
13
14
}: {
14
-
virtualisation.memorySize = 2048;
15
-
virtualisation.diskSize = 10 * 1024;
16
-
virtualisation.cores = 2;
15
+
virtualisation = {
16
+
memorySize = 2048;
17
+
diskSize = 10 * 1024;
18
+
cores = 2;
19
+
forwardPorts = [
20
+
# ssh
21
+
{
22
+
from = "host";
23
+
host.port = 2222;
24
+
guest.port = 22;
25
+
}
26
+
# knot
27
+
{
28
+
from = "host";
29
+
host.port = 6000;
30
+
guest.port = 6000;
31
+
}
32
+
# spindle
33
+
{
34
+
from = "host";
35
+
host.port = 6555;
36
+
guest.port = 6555;
37
+
}
38
+
];
39
+
};
17
40
services.getty.autologinUser = "root";
18
41
environment.systemPackages = with pkgs; [curl vim git];
19
42
systemd.tmpfiles.rules = let
···
21
44
g = config.services.tangled-knot.gitUser;
22
45
in [
23
46
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
24
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=16154910ef55fe48121082c0b51fc0e360a8b15eb7bda7991d88dc9f7684427a"
47
+
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
25
48
];
26
49
services.tangled-knot = {
27
50
enable = true;
···
29
52
secretFile = "/var/lib/knot/secret";
30
53
hostname = "localhost:6000";
31
54
listenAddr = "0.0.0.0:6000";
55
+
};
56
+
};
57
+
services.tangled-spindle = {
58
+
enable = true;
59
+
server = {
60
+
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
61
+
hostname = "localhost:6555";
62
+
listenAddr = "0.0.0.0:6555";
63
+
dev = true;
32
64
};
33
65
};
34
66
})
+43
notifier/notifier.go
+43
notifier/notifier.go
···
1
+
package notifier
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type Notifier struct {
8
+
subscribers map[chan struct{}]struct{}
9
+
mu sync.Mutex
10
+
}
11
+
12
+
func New() Notifier {
13
+
return Notifier{
14
+
subscribers: make(map[chan struct{}]struct{}),
15
+
}
16
+
}
17
+
18
+
func (n *Notifier) Subscribe() chan struct{} {
19
+
ch := make(chan struct{}, 1)
20
+
n.mu.Lock()
21
+
n.subscribers[ch] = struct{}{}
22
+
n.mu.Unlock()
23
+
return ch
24
+
}
25
+
26
+
func (n *Notifier) Unsubscribe(ch chan struct{}) {
27
+
n.mu.Lock()
28
+
delete(n.subscribers, ch)
29
+
close(ch)
30
+
n.mu.Unlock()
31
+
}
32
+
33
+
func (n *Notifier) NotifyAll() {
34
+
n.mu.Lock()
35
+
for ch := range n.subscribers {
36
+
select {
37
+
case ch <- struct{}{}:
38
+
default:
39
+
// avoid blocking if channel is full
40
+
}
41
+
}
42
+
n.mu.Unlock()
43
+
}
+108
-26
rbac/rbac.go
+108
-26
rbac/rbac.go
···
2
2
3
3
import (
4
4
"database/sql"
5
-
"fmt"
5
+
"slices"
6
6
"strings"
7
7
8
8
adapter "github.com/Blank-Xu/sql-adapter"
···
59
59
return &Enforcer{e}, nil
60
60
}
61
61
62
-
func (e *Enforcer) AddDomain(domain string) error {
62
+
func (e *Enforcer) AddKnot(knot string) error {
63
63
// Add policies with patterns
64
64
_, err := e.E.AddPolicies([][]string{
65
-
{"server:owner", domain, domain, "server:invite"},
66
-
{"server:member", domain, domain, "repo:create"},
65
+
{"server:owner", knot, knot, "server:invite"},
66
+
{"server:member", knot, knot, "repo:create"},
67
67
})
68
68
if err != nil {
69
69
return err
70
70
}
71
71
72
72
// all owners are also members
73
-
_, err = e.E.AddGroupingPolicy("server:owner", "server:member", domain)
73
+
_, err = e.E.AddGroupingPolicy("server:owner", "server:member", knot)
74
74
return err
75
75
}
76
76
77
-
func (e *Enforcer) GetDomainsForUser(did string) ([]string, error) {
78
-
return e.E.GetDomainsForUser(did)
77
+
func (e *Enforcer) AddSpindle(spindle string) error {
78
+
// the internal repr for spindles is spindle:foo.com
79
+
spindle = intoSpindle(spindle)
80
+
81
+
_, err := e.E.AddPolicies([][]string{
82
+
{"server:owner", spindle, spindle, "server:invite"},
83
+
})
84
+
if err != nil {
85
+
return err
86
+
}
87
+
88
+
// all owners are also members
89
+
_, err = e.E.AddGroupingPolicy("server:owner", "server:member", spindle)
90
+
return err
79
91
}
80
92
81
-
func (e *Enforcer) AddOwner(domain, owner string) error {
82
-
_, err := e.E.AddGroupingPolicy(owner, "server:owner", domain)
93
+
func (e *Enforcer) RemoveSpindle(spindle string) error {
94
+
spindle = intoSpindle(spindle)
95
+
_, err := e.E.DeleteDomains(spindle)
83
96
return err
84
97
}
85
98
86
-
func (e *Enforcer) AddMember(domain, member string) error {
87
-
_, err := e.E.AddGroupingPolicy(member, "server:member", domain)
88
-
return err
99
+
func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) {
100
+
keepFunc := isNotSpindle
101
+
stripFunc := unSpindle
102
+
return e.getDomainsForUser(did, keepFunc, stripFunc)
103
+
}
104
+
105
+
func (e *Enforcer) GetSpindlesForUser(did string) ([]string, error) {
106
+
keepFunc := isSpindle
107
+
stripFunc := unSpindle
108
+
return e.getDomainsForUser(did, keepFunc, stripFunc)
109
+
}
110
+
111
+
func (e *Enforcer) AddKnotOwner(domain, owner string) error {
112
+
return e.addOwner(domain, owner)
113
+
}
114
+
115
+
func (e *Enforcer) RemoveKnotOwner(domain, owner string) error {
116
+
return e.removeOwner(domain, owner)
117
+
}
118
+
119
+
func (e *Enforcer) AddKnotMember(domain, member string) error {
120
+
return e.addMember(domain, member)
121
+
}
122
+
123
+
func (e *Enforcer) RemoveKnotMember(domain, member string) error {
124
+
return e.removeMember(domain, member)
125
+
}
126
+
127
+
func (e *Enforcer) AddSpindleOwner(domain, owner string) error {
128
+
return e.addOwner(intoSpindle(domain), owner)
129
+
}
130
+
131
+
func (e *Enforcer) RemoveSpindleOwner(domain, owner string) error {
132
+
return e.removeOwner(intoSpindle(domain), owner)
133
+
}
134
+
135
+
func (e *Enforcer) AddSpindleMember(domain, member string) error {
136
+
return e.addMember(intoSpindle(domain), member)
137
+
}
138
+
139
+
func (e *Enforcer) RemoveSpindleMember(domain, member string) error {
140
+
return e.removeMember(intoSpindle(domain), member)
89
141
}
90
142
91
143
func repoPolicies(member, domain, repo string) [][]string {
···
162
214
return nil, err
163
215
}
164
216
165
-
return membersWithoutRoles, nil
217
+
slices.Sort(membersWithoutRoles)
218
+
return slices.Compact(membersWithoutRoles), nil
219
+
}
220
+
221
+
func (e *Enforcer) GetKnotUsersByRole(role, domain string) ([]string, error) {
222
+
return e.GetUserByRole(role, domain)
223
+
}
224
+
225
+
func (e *Enforcer) GetSpindleUsersByRole(role, domain string) ([]string, error) {
226
+
return e.GetUserByRole(role, intoSpindle(domain))
166
227
}
167
228
168
-
func (e *Enforcer) isRole(user, role, domain string) (bool, error) {
169
-
return e.E.HasGroupingPolicy(user, role, domain)
229
+
func (e *Enforcer) GetUserByRoleInRepo(role, domain, repo string) ([]string, error) {
230
+
var users []string
231
+
232
+
policies, err := e.E.GetImplicitUsersForResourceByDomain(repo, domain)
233
+
for _, p := range policies {
234
+
user := p[0]
235
+
if strings.HasPrefix(user, "did:") {
236
+
users = append(users, user)
237
+
}
238
+
}
239
+
if err != nil {
240
+
return nil, err
241
+
}
242
+
243
+
slices.Sort(users)
244
+
return slices.Compact(users), nil
170
245
}
171
246
172
-
func (e *Enforcer) IsServerOwner(user, domain string) (bool, error) {
247
+
func (e *Enforcer) IsKnotOwner(user, domain string) (bool, error) {
173
248
return e.isRole(user, "server:owner", domain)
174
249
}
175
250
176
-
func (e *Enforcer) IsServerMember(user, domain string) (bool, error) {
251
+
func (e *Enforcer) IsKnotMember(user, domain string) (bool, error) {
177
252
return e.isRole(user, "server:member", domain)
253
+
}
254
+
255
+
func (e *Enforcer) IsSpindleOwner(user, domain string) (bool, error) {
256
+
return e.isRole(user, "server:owner", intoSpindle(domain))
257
+
}
258
+
259
+
func (e *Enforcer) IsSpindleMember(user, domain string) (bool, error) {
260
+
return e.isRole(user, "server:member", intoSpindle(domain))
261
+
}
262
+
263
+
func (e *Enforcer) IsKnotInviteAllowed(user, domain string) (bool, error) {
264
+
return e.isInviteAllowed(user, domain)
265
+
}
266
+
267
+
func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) {
268
+
return e.isInviteAllowed(user, intoSpindle(domain))
178
269
}
179
270
180
271
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
···
202
293
203
294
return permissions
204
295
}
205
-
206
-
func checkRepoFormat(repo string) error {
207
-
// sanity check, repo must be of the form ownerDid/repo
208
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
209
-
return fmt.Errorf("invalid repo: %s", repo)
210
-
}
211
-
212
-
return nil
213
-
}
+449
rbac/rbac_test.go
+449
rbac/rbac_test.go
···
1
+
package rbac_test
2
+
3
+
import (
4
+
"database/sql"
5
+
"testing"
6
+
7
+
"tangled.sh/tangled.sh/core/rbac"
8
+
9
+
adapter "github.com/Blank-Xu/sql-adapter"
10
+
"github.com/casbin/casbin/v2"
11
+
"github.com/casbin/casbin/v2/model"
12
+
_ "github.com/mattn/go-sqlite3"
13
+
"github.com/stretchr/testify/assert"
14
+
)
15
+
16
+
func setup(t *testing.T) *rbac.Enforcer {
17
+
db, err := sql.Open("sqlite3", ":memory:")
18
+
assert.NoError(t, err)
19
+
20
+
a, err := adapter.NewAdapter(db, "sqlite3", "acl")
21
+
assert.NoError(t, err)
22
+
23
+
m, err := model.NewModelFromString(rbac.Model)
24
+
assert.NoError(t, err)
25
+
26
+
e, err := casbin.NewEnforcer(m, a)
27
+
assert.NoError(t, err)
28
+
29
+
e.EnableAutoSave(false)
30
+
31
+
return &rbac.Enforcer{E: e}
32
+
}
33
+
34
+
func TestAddKnotAndRoles(t *testing.T) {
35
+
e := setup(t)
36
+
37
+
err := e.AddKnot("example.com")
38
+
assert.NoError(t, err)
39
+
40
+
err = e.AddKnotOwner("example.com", "did:plc:foo")
41
+
assert.NoError(t, err)
42
+
43
+
isOwner, err := e.IsKnotOwner("did:plc:foo", "example.com")
44
+
assert.NoError(t, err)
45
+
assert.True(t, isOwner)
46
+
47
+
isMember, err := e.IsKnotMember("did:plc:foo", "example.com")
48
+
assert.NoError(t, err)
49
+
assert.True(t, isMember)
50
+
}
51
+
52
+
func TestAddMember(t *testing.T) {
53
+
e := setup(t)
54
+
55
+
err := e.AddKnot("example.com")
56
+
assert.NoError(t, err)
57
+
58
+
err = e.AddKnotOwner("example.com", "did:plc:foo")
59
+
assert.NoError(t, err)
60
+
61
+
err = e.AddKnotMember("example.com", "did:plc:bar")
62
+
assert.NoError(t, err)
63
+
64
+
isMember, err := e.IsKnotMember("did:plc:foo", "example.com")
65
+
assert.NoError(t, err)
66
+
assert.True(t, isMember)
67
+
68
+
isMember, err = e.IsKnotMember("did:plc:bar", "example.com")
69
+
assert.NoError(t, err)
70
+
assert.True(t, isMember)
71
+
72
+
isOwner, err := e.IsKnotOwner("did:plc:foo", "example.com")
73
+
assert.NoError(t, err)
74
+
assert.True(t, isOwner)
75
+
76
+
// negated check here
77
+
isOwner, err = e.IsKnotOwner("did:plc:bar", "example.com")
78
+
assert.NoError(t, err)
79
+
assert.False(t, isOwner)
80
+
}
81
+
82
+
func TestAddRepoPermissions(t *testing.T) {
83
+
e := setup(t)
84
+
85
+
knot := "example.com"
86
+
87
+
fooUser := "did:plc:foo"
88
+
fooRepo := "did:plc:foo/my-repo"
89
+
90
+
barUser := "did:plc:bar"
91
+
barRepo := "did:plc:bar/my-repo"
92
+
93
+
_ = e.AddKnot(knot)
94
+
_ = e.AddKnotMember(knot, fooUser)
95
+
_ = e.AddKnotMember(knot, barUser)
96
+
97
+
err := e.AddRepo(fooUser, knot, fooRepo)
98
+
assert.NoError(t, err)
99
+
100
+
err = e.AddRepo(barUser, knot, barRepo)
101
+
assert.NoError(t, err)
102
+
103
+
canPush, err := e.IsPushAllowed(fooUser, knot, fooRepo)
104
+
assert.NoError(t, err)
105
+
assert.True(t, canPush)
106
+
107
+
canPush, err = e.IsPushAllowed(barUser, knot, barRepo)
108
+
assert.NoError(t, err)
109
+
assert.True(t, canPush)
110
+
111
+
// negated
112
+
canPush, err = e.IsPushAllowed(barUser, knot, fooRepo)
113
+
assert.NoError(t, err)
114
+
assert.False(t, canPush)
115
+
116
+
canDelete, err := e.E.Enforce(fooUser, knot, fooRepo, "repo:delete")
117
+
assert.NoError(t, err)
118
+
assert.True(t, canDelete)
119
+
120
+
// negated
121
+
canDelete, err = e.E.Enforce(barUser, knot, fooRepo, "repo:delete")
122
+
assert.NoError(t, err)
123
+
assert.False(t, canDelete)
124
+
}
125
+
126
+
func TestCollaboratorPermissions(t *testing.T) {
127
+
e := setup(t)
128
+
129
+
knot := "example.com"
130
+
repo := "did:plc:foo/my-repo"
131
+
owner := "did:plc:foo"
132
+
collaborator := "did:plc:bar"
133
+
134
+
_ = e.AddKnot(knot)
135
+
_ = e.AddRepo(owner, knot, repo)
136
+
137
+
err := e.AddCollaborator(collaborator, knot, repo)
138
+
assert.NoError(t, err)
139
+
140
+
// all collaborator permissions granted
141
+
perms := e.GetPermissionsInRepo(collaborator, knot, repo)
142
+
assert.ElementsMatch(t, []string{
143
+
"repo:settings", "repo:push", "repo:collaborator",
144
+
}, perms)
145
+
146
+
err = e.RemoveCollaborator(collaborator, knot, repo)
147
+
assert.NoError(t, err)
148
+
149
+
// all permissions removed
150
+
perms = e.GetPermissionsInRepo(collaborator, knot, repo)
151
+
assert.ElementsMatch(t, []string{}, perms)
152
+
}
153
+
154
+
func TestGetByRole(t *testing.T) {
155
+
e := setup(t)
156
+
157
+
knot := "example.com"
158
+
repo := "did:plc:foo/my-repo"
159
+
owner := "did:plc:foo"
160
+
collaborator1 := "did:plc:bar"
161
+
collaborator2 := "did:plc:baz"
162
+
163
+
_ = e.AddKnot(knot)
164
+
_ = e.AddRepo(owner, knot, repo)
165
+
166
+
err := e.AddCollaborator(collaborator1, knot, repo)
167
+
assert.NoError(t, err)
168
+
169
+
err = e.AddCollaborator(collaborator2, knot, repo)
170
+
assert.NoError(t, err)
171
+
172
+
collaborators, err := e.GetUserByRoleInRepo("repo:collaborator", knot, repo)
173
+
assert.NoError(t, err)
174
+
assert.ElementsMatch(t, []string{
175
+
"did:plc:foo", // owner
176
+
"did:plc:bar", // collaborator1
177
+
"did:plc:baz", // collaborator2
178
+
}, collaborators)
179
+
}
180
+
181
+
func TestGetPermissionsInRepo(t *testing.T) {
182
+
e := setup(t)
183
+
184
+
user := "did:plc:foo"
185
+
knot := "example.com"
186
+
repo := "did:plc:foo/my-repo"
187
+
188
+
_ = e.AddKnot(knot)
189
+
_ = e.AddRepo(user, knot, repo)
190
+
191
+
perms := e.GetPermissionsInRepo(user, knot, repo)
192
+
assert.ElementsMatch(t, []string{
193
+
"repo:settings", "repo:push", "repo:owner", "repo:invite", "repo:delete",
194
+
}, perms)
195
+
}
196
+
197
+
func TestInvalidRepoFormat(t *testing.T) {
198
+
e := setup(t)
199
+
200
+
err := e.AddRepo("did:plc:foo", "example.com", "not-valid-format")
201
+
assert.Error(t, err)
202
+
}
203
+
204
+
func TestGetKnotssForUser(t *testing.T) {
205
+
e := setup(t)
206
+
_ = e.AddKnot("example.com")
207
+
_ = e.AddKnotOwner("example.com", "did:plc:foo")
208
+
_ = e.AddKnotMember("example.com", "did:plc:bar")
209
+
210
+
knots1, _ := e.GetKnotsForUser("did:plc:foo")
211
+
assert.Contains(t, knots1, "example.com")
212
+
213
+
knots2, _ := e.GetKnotsForUser("did:plc:bar")
214
+
assert.Contains(t, knots2, "example.com")
215
+
}
216
+
217
+
func TestGetKnotUsersByRole(t *testing.T) {
218
+
e := setup(t)
219
+
_ = e.AddKnot("example.com")
220
+
_ = e.AddKnotMember("example.com", "did:plc:foo")
221
+
_ = e.AddKnotOwner("example.com", "did:plc:bar")
222
+
223
+
members, _ := e.GetKnotUsersByRole("server:member", "example.com")
224
+
assert.Contains(t, members, "did:plc:foo")
225
+
assert.Contains(t, members, "did:plc:bar") // due to inheritance
226
+
}
227
+
228
+
func TestGetSpindleUsersByRole(t *testing.T) {
229
+
e := setup(t)
230
+
_ = e.AddSpindle("example.com")
231
+
_ = e.AddSpindleMember("example.com", "did:plc:foo")
232
+
_ = e.AddSpindleOwner("example.com", "did:plc:bar")
233
+
234
+
members, _ := e.GetSpindleUsersByRole("server:member", "example.com")
235
+
assert.Contains(t, members, "did:plc:foo")
236
+
assert.Contains(t, members, "did:plc:bar") // due to inheritance
237
+
}
238
+
239
+
func TestEmptyUserPermissions(t *testing.T) {
240
+
e := setup(t)
241
+
allowed, _ := e.IsPushAllowed("did:plc:nobody", "unknown.com", "did:plc:nobody/repo")
242
+
assert.False(t, allowed)
243
+
}
244
+
245
+
func TestDuplicatePolicyAddition(t *testing.T) {
246
+
e := setup(t)
247
+
_ = e.AddKnot("example.com")
248
+
_ = e.AddRepo("did:plc:foo", "example.com", "did:plc:foo/repo")
249
+
250
+
// add again
251
+
err := e.AddRepo("did:plc:foo", "example.com", "did:plc:foo/repo")
252
+
assert.NoError(t, err) // should not fail, but won't duplicate
253
+
}
254
+
255
+
func TestRemoveRepo(t *testing.T) {
256
+
e := setup(t)
257
+
repo := "did:plc:foo/repo"
258
+
_ = e.AddKnot("example.com")
259
+
_ = e.AddRepo("did:plc:foo", "example.com", repo)
260
+
261
+
allowed, _ := e.IsSettingsAllowed("did:plc:foo", "example.com", repo)
262
+
assert.True(t, allowed)
263
+
264
+
_ = e.RemoveRepo("did:plc:foo", "example.com", repo)
265
+
266
+
allowed, _ = e.IsSettingsAllowed("did:plc:foo", "example.com", repo)
267
+
assert.False(t, allowed)
268
+
}
269
+
270
+
func TestAddKnotAndSpindle(t *testing.T) {
271
+
e := setup(t)
272
+
273
+
err := e.AddKnot("k.com")
274
+
assert.NoError(t, err)
275
+
276
+
err = e.AddSpindle("s.com")
277
+
assert.NoError(t, err)
278
+
279
+
err = e.AddKnotOwner("k.com", "did:plc:foo")
280
+
assert.NoError(t, err)
281
+
282
+
err = e.AddSpindleOwner("s.com", "did:plc:foo")
283
+
assert.NoError(t, err)
284
+
285
+
knots, err := e.GetKnotsForUser("did:plc:foo")
286
+
assert.NoError(t, err)
287
+
assert.ElementsMatch(t, []string{
288
+
"k.com",
289
+
}, knots)
290
+
291
+
spindles, err := e.GetSpindlesForUser("did:plc:foo")
292
+
assert.NoError(t, err)
293
+
assert.ElementsMatch(t, []string{
294
+
"s.com",
295
+
}, spindles)
296
+
}
297
+
298
+
func TestAddSpindleAndRoles(t *testing.T) {
299
+
e := setup(t)
300
+
301
+
err := e.AddSpindle("s.com")
302
+
assert.NoError(t, err)
303
+
304
+
err = e.AddSpindleOwner("s.com", "did:plc:foo")
305
+
assert.NoError(t, err)
306
+
307
+
ok, err := e.IsSpindleOwner("did:plc:foo", "s.com")
308
+
assert.NoError(t, err)
309
+
assert.True(t, ok)
310
+
311
+
ok, err = e.IsSpindleMember("did:plc:foo", "s.com")
312
+
assert.NoError(t, err)
313
+
assert.True(t, ok)
314
+
}
315
+
316
+
func TestRemoveKnotOwner(t *testing.T) {
317
+
e := setup(t)
318
+
319
+
err := e.AddKnot("k.com")
320
+
assert.NoError(t, err)
321
+
322
+
err = e.AddKnotOwner("k.com", "did:plc:foo")
323
+
assert.NoError(t, err)
324
+
325
+
knots, err := e.GetKnotsForUser("did:plc:foo")
326
+
assert.NoError(t, err)
327
+
assert.ElementsMatch(t, []string{
328
+
"k.com",
329
+
}, knots)
330
+
331
+
err = e.RemoveKnotOwner("k.com", "did:plc:foo")
332
+
assert.NoError(t, err)
333
+
334
+
knots, err = e.GetKnotsForUser("did:plc:foo")
335
+
assert.NoError(t, err)
336
+
assert.Empty(t, knots)
337
+
}
338
+
339
+
func TestRemoveKnotMember(t *testing.T) {
340
+
e := setup(t)
341
+
342
+
err := e.AddKnot("k.com")
343
+
assert.NoError(t, err)
344
+
345
+
err = e.AddKnotOwner("k.com", "did:plc:foo")
346
+
assert.NoError(t, err)
347
+
348
+
err = e.AddKnotMember("k.com", "did:plc:bar")
349
+
assert.NoError(t, err)
350
+
351
+
knots, err := e.GetKnotsForUser("did:plc:bar")
352
+
assert.NoError(t, err)
353
+
assert.ElementsMatch(t, []string{
354
+
"k.com",
355
+
}, knots)
356
+
357
+
err = e.RemoveKnotMember("k.com", "did:plc:bar")
358
+
assert.NoError(t, err)
359
+
360
+
knots, err = e.GetKnotsForUser("did:plc:bar")
361
+
assert.NoError(t, err)
362
+
assert.Empty(t, knots)
363
+
}
364
+
365
+
func TestRemoveSpindleOwner(t *testing.T) {
366
+
e := setup(t)
367
+
368
+
err := e.AddSpindle("s.com")
369
+
assert.NoError(t, err)
370
+
371
+
err = e.AddSpindleOwner("s.com", "did:plc:foo")
372
+
assert.NoError(t, err)
373
+
374
+
spindles, err := e.GetSpindlesForUser("did:plc:foo")
375
+
assert.NoError(t, err)
376
+
assert.ElementsMatch(t, []string{
377
+
"s.com",
378
+
}, spindles)
379
+
380
+
err = e.RemoveSpindleOwner("s.com", "did:plc:foo")
381
+
assert.NoError(t, err)
382
+
383
+
spindles, err = e.GetSpindlesForUser("did:plc:foo")
384
+
assert.NoError(t, err)
385
+
assert.Empty(t, spindles)
386
+
}
387
+
388
+
func TestRemoveSpindleMember(t *testing.T) {
389
+
e := setup(t)
390
+
391
+
err := e.AddSpindle("s.com")
392
+
assert.NoError(t, err)
393
+
394
+
err = e.AddSpindleOwner("s.com", "did:plc:foo")
395
+
assert.NoError(t, err)
396
+
397
+
err = e.AddSpindleMember("s.com", "did:plc:bar")
398
+
assert.NoError(t, err)
399
+
400
+
spindles, err := e.GetSpindlesForUser("did:plc:foo")
401
+
assert.NoError(t, err)
402
+
assert.ElementsMatch(t, []string{
403
+
"s.com",
404
+
}, spindles)
405
+
406
+
spindles, err = e.GetSpindlesForUser("did:plc:bar")
407
+
assert.NoError(t, err)
408
+
assert.ElementsMatch(t, []string{
409
+
"s.com",
410
+
}, spindles)
411
+
412
+
err = e.RemoveSpindleMember("s.com", "did:plc:bar")
413
+
assert.NoError(t, err)
414
+
415
+
spindles, err = e.GetSpindlesForUser("did:plc:bar")
416
+
assert.NoError(t, err)
417
+
assert.Empty(t, spindles)
418
+
}
419
+
420
+
func TestRemoveSpindle(t *testing.T) {
421
+
e := setup(t)
422
+
423
+
err := e.AddSpindle("s.com")
424
+
assert.NoError(t, err)
425
+
426
+
err = e.AddSpindleOwner("s.com", "did:plc:foo")
427
+
assert.NoError(t, err)
428
+
429
+
err = e.AddSpindleMember("s.com", "did:plc:bar")
430
+
assert.NoError(t, err)
431
+
432
+
users, err := e.GetSpindleUsersByRole("server:member", "s.com")
433
+
assert.NoError(t, err)
434
+
assert.ElementsMatch(t, []string{
435
+
"did:plc:foo",
436
+
"did:plc:bar",
437
+
}, users)
438
+
439
+
err = e.RemoveSpindle("s.com")
440
+
assert.NoError(t, err)
441
+
442
+
// TODO: see this issue https://github.com/casbin/casbin/issues/1492
443
+
// s, err := e.E.GetAllDomains()
444
+
// assert.Empty(t, s)
445
+
446
+
spindles, err := e.GetSpindleUsersByRole("server:member", "s.com")
447
+
assert.NoError(t, err)
448
+
assert.Empty(t, spindles)
449
+
}
+93
rbac/util.go
+93
rbac/util.go
···
1
+
package rbac
2
+
3
+
import (
4
+
"fmt"
5
+
"slices"
6
+
"strings"
7
+
)
8
+
9
+
func (e *Enforcer) getDomainsForUser(did string, keepFunc func(string) bool, stripFunc func(string) string) ([]string, error) {
10
+
domains, err := e.E.GetDomainsForUser(did)
11
+
if err != nil {
12
+
return nil, err
13
+
}
14
+
15
+
n := 0
16
+
for _, x := range domains {
17
+
if keepFunc(x) {
18
+
domains[n] = stripFunc(x)
19
+
n++
20
+
}
21
+
}
22
+
domains = domains[:n]
23
+
24
+
return domains, nil
25
+
}
26
+
27
+
func (e *Enforcer) addOwner(domain, owner string) error {
28
+
_, err := e.E.AddGroupingPolicy(owner, "server:owner", domain)
29
+
return err
30
+
}
31
+
32
+
func (e *Enforcer) removeOwner(domain, owner string) error {
33
+
_, err := e.E.RemoveGroupingPolicy(owner, "server:owner", domain)
34
+
return err
35
+
}
36
+
37
+
func (e *Enforcer) addMember(domain, member string) error {
38
+
_, err := e.E.AddGroupingPolicy(member, "server:member", domain)
39
+
return err
40
+
}
41
+
42
+
func (e *Enforcer) removeMember(domain, member string) error {
43
+
_, err := e.E.RemoveGroupingPolicy(member, "server:member", domain)
44
+
return err
45
+
}
46
+
47
+
func (e *Enforcer) isRole(user, role, domain string) (bool, error) {
48
+
roles, err := e.E.GetImplicitRolesForUser(user, domain)
49
+
if err != nil {
50
+
return false, err
51
+
}
52
+
if slices.Contains(roles, role) {
53
+
return true, nil
54
+
}
55
+
return false, nil
56
+
}
57
+
58
+
func (e *Enforcer) isInviteAllowed(user, domain string) (bool, error) {
59
+
return e.E.Enforce(user, domain, domain, "server:invite")
60
+
}
61
+
62
+
func checkRepoFormat(repo string) error {
63
+
// sanity check, repo must be of the form ownerDid/repo
64
+
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
65
+
return fmt.Errorf("invalid repo: %s", repo)
66
+
}
67
+
68
+
return nil
69
+
}
70
+
71
+
const spindlePrefix = "spindle:"
72
+
73
+
func intoSpindle(domain string) string {
74
+
if !isSpindle(domain) {
75
+
return spindlePrefix + domain
76
+
}
77
+
return domain
78
+
}
79
+
80
+
func unSpindle(domain string) string {
81
+
if !isSpindle(domain) {
82
+
return domain
83
+
}
84
+
return strings.TrimPrefix(domain, spindlePrefix)
85
+
}
86
+
87
+
func isSpindle(domain string) bool {
88
+
return strings.HasPrefix(domain, spindlePrefix)
89
+
}
90
+
91
+
func isNotSpindle(domain string) bool {
92
+
return !isSpindle(domain)
93
+
}
+37
spindle/config/config.go
+37
spindle/config/config.go
···
1
+
package config
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/sethvargo/go-envconfig"
7
+
)
8
+
9
+
type Server struct {
10
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
11
+
DBPath string `env:"DB_PATH, default=spindle.db"`
12
+
Hostname string `env:"HOSTNAME, required"`
13
+
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
14
+
Dev bool `env:"DEV, default=false"`
15
+
Owner string `env:"OWNER, required"`
16
+
}
17
+
18
+
type Pipelines struct {
19
+
Nixery string `env:"NIXERY, default=nixery.tangled.sh"`
20
+
WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"`
21
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
22
+
}
23
+
24
+
type Config struct {
25
+
Server Server `env:",prefix=SPINDLE_SERVER_"`
26
+
Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"`
27
+
}
28
+
29
+
func Load(ctx context.Context) (*Config, error) {
30
+
var cfg Config
31
+
err := envconfig.Process(ctx, &cfg)
32
+
if err != nil {
33
+
return nil, err
34
+
}
35
+
36
+
return &cfg, nil
37
+
}
+77
spindle/db/db.go
+77
spindle/db/db.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
6
+
_ "github.com/mattn/go-sqlite3"
7
+
)
8
+
9
+
type DB struct {
10
+
*sql.DB
11
+
}
12
+
13
+
func Make(dbPath string) (*DB, error) {
14
+
db, err := sql.Open("sqlite3", dbPath)
15
+
if err != nil {
16
+
return nil, err
17
+
}
18
+
19
+
_, err = db.Exec(`
20
+
pragma journal_mode = WAL;
21
+
pragma synchronous = normal;
22
+
pragma foreign_keys = on;
23
+
pragma temp_store = memory;
24
+
pragma mmap_size = 30000000000;
25
+
pragma page_size = 32768;
26
+
pragma auto_vacuum = incremental;
27
+
pragma busy_timeout = 5000;
28
+
29
+
create table if not exists _jetstream (
30
+
id integer primary key autoincrement,
31
+
last_time_us integer not null
32
+
);
33
+
34
+
create table if not exists known_dids (
35
+
did text primary key
36
+
);
37
+
38
+
create table if not exists repos (
39
+
id integer primary key autoincrement,
40
+
knot text not null,
41
+
owner text not null,
42
+
name text not null,
43
+
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
44
+
45
+
unique(owner, name)
46
+
);
47
+
48
+
-- status event for a single workflow
49
+
create table if not exists events (
50
+
rkey text not null,
51
+
nsid text not null,
52
+
event text not null, -- json
53
+
created integer not null -- unix nanos
54
+
);
55
+
`)
56
+
if err != nil {
57
+
return nil, err
58
+
}
59
+
60
+
return &DB{db}, nil
61
+
}
62
+
63
+
func (d *DB) SaveLastTimeUs(lastTimeUs int64) error {
64
+
_, err := d.Exec(`
65
+
insert into _jetstream (id, last_time_us)
66
+
values (1, ?)
67
+
on conflict(id) do update set last_time_us = excluded.last_time_us
68
+
`, lastTimeUs)
69
+
return err
70
+
}
71
+
72
+
func (d *DB) GetLastTimeUs() (int64, error) {
73
+
var lastTimeUs int64
74
+
row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`)
75
+
err := row.Scan(&lastTimeUs)
76
+
return lastTimeUs, err
77
+
}
+175
spindle/db/events.go
+175
spindle/db/events.go
···
1
+
package db
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"time"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/notifier"
10
+
"tangled.sh/tangled.sh/core/spindle/models"
11
+
"tangled.sh/tangled.sh/core/tid"
12
+
)
13
+
14
+
type Event struct {
15
+
Rkey string `json:"rkey"`
16
+
Nsid string `json:"nsid"`
17
+
Created int64 `json:"created"`
18
+
EventJson string `json:"event"`
19
+
}
20
+
21
+
func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error {
22
+
_, err := d.Exec(
23
+
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
24
+
event.Rkey,
25
+
event.Nsid,
26
+
event.EventJson,
27
+
time.Now().UnixNano(),
28
+
)
29
+
30
+
notifier.NotifyAll()
31
+
32
+
return err
33
+
}
34
+
35
+
func (d *DB) GetEvents(cursor int64) ([]Event, error) {
36
+
whereClause := ""
37
+
args := []any{}
38
+
if cursor > 0 {
39
+
whereClause = "where created > ?"
40
+
args = append(args, cursor)
41
+
}
42
+
43
+
query := fmt.Sprintf(`
44
+
select rkey, nsid, event, created
45
+
from events
46
+
%s
47
+
order by created asc
48
+
limit 100
49
+
`, whereClause)
50
+
51
+
rows, err := d.Query(query, args...)
52
+
if err != nil {
53
+
return nil, err
54
+
}
55
+
defer rows.Close()
56
+
57
+
var evts []Event
58
+
for rows.Next() {
59
+
var ev Event
60
+
if err := rows.Scan(&ev.Rkey, &ev.Nsid, &ev.EventJson, &ev.Created); err != nil {
61
+
return nil, err
62
+
}
63
+
evts = append(evts, ev)
64
+
}
65
+
66
+
if err := rows.Err(); err != nil {
67
+
return nil, err
68
+
}
69
+
70
+
return evts, nil
71
+
}
72
+
73
+
func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error {
74
+
eventJson, err := json.Marshal(s)
75
+
if err != nil {
76
+
return err
77
+
}
78
+
79
+
event := Event{
80
+
Rkey: rkey,
81
+
Nsid: tangled.PipelineStatusNSID,
82
+
Created: time.Now().UnixNano(),
83
+
EventJson: string(eventJson),
84
+
}
85
+
86
+
return d.InsertEvent(event, n)
87
+
}
88
+
89
+
func (d *DB) createStatusEvent(
90
+
workflowId models.WorkflowId,
91
+
statusKind models.StatusKind,
92
+
workflowError *string,
93
+
exitCode *int64,
94
+
n *notifier.Notifier,
95
+
) error {
96
+
now := time.Now()
97
+
pipelineAtUri := workflowId.PipelineId.AtUri()
98
+
s := tangled.PipelineStatus{
99
+
CreatedAt: now.Format(time.RFC3339),
100
+
Error: workflowError,
101
+
ExitCode: exitCode,
102
+
Pipeline: string(pipelineAtUri),
103
+
Workflow: workflowId.Name,
104
+
Status: string(statusKind),
105
+
}
106
+
107
+
eventJson, err := json.Marshal(s)
108
+
if err != nil {
109
+
return err
110
+
}
111
+
112
+
event := Event{
113
+
Rkey: tid.TID(),
114
+
Nsid: tangled.PipelineStatusNSID,
115
+
Created: now.UnixNano(),
116
+
EventJson: string(eventJson),
117
+
}
118
+
119
+
return d.InsertEvent(event, n)
120
+
121
+
}
122
+
123
+
func (d *DB) GetStatus(workflowId models.WorkflowId) (*tangled.PipelineStatus, error) {
124
+
pipelineAtUri := workflowId.PipelineId.AtUri()
125
+
126
+
var eventJson string
127
+
err := d.QueryRow(
128
+
`
129
+
select
130
+
event from events
131
+
where
132
+
nsid = ?
133
+
and json_extract(event, '$.pipeline') = ?
134
+
and json_extract(event, '$.workflow') = ?
135
+
order by
136
+
created desc
137
+
limit
138
+
1
139
+
`,
140
+
tangled.PipelineStatusNSID,
141
+
string(pipelineAtUri),
142
+
workflowId.Name,
143
+
).Scan(&eventJson)
144
+
145
+
if err != nil {
146
+
return nil, err
147
+
}
148
+
149
+
var status tangled.PipelineStatus
150
+
if err := json.Unmarshal([]byte(eventJson), &status); err != nil {
151
+
return nil, err
152
+
}
153
+
154
+
return &status, nil
155
+
}
156
+
157
+
func (d *DB) StatusPending(workflowId models.WorkflowId, n *notifier.Notifier) error {
158
+
return d.createStatusEvent(workflowId, models.StatusKindPending, nil, nil, n)
159
+
}
160
+
161
+
func (d *DB) StatusRunning(workflowId models.WorkflowId, n *notifier.Notifier) error {
162
+
return d.createStatusEvent(workflowId, models.StatusKindRunning, nil, nil, n)
163
+
}
164
+
165
+
func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error {
166
+
return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n)
167
+
}
168
+
169
+
func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
170
+
return d.createStatusEvent(workflowId, models.StatusKindSuccess, nil, nil, n)
171
+
}
172
+
173
+
func (d *DB) StatusTimeout(workflowId models.WorkflowId, n *notifier.Notifier) error {
174
+
return d.createStatusEvent(workflowId, models.StatusKindTimeout, nil, nil, n)
175
+
}
+44
spindle/db/known_dids.go
+44
spindle/db/known_dids.go
···
1
+
package db
2
+
3
+
func (d *DB) AddDid(did string) error {
4
+
_, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did)
5
+
return err
6
+
}
7
+
8
+
func (d *DB) RemoveDid(did string) error {
9
+
_, err := d.Exec(`delete from known_dids where did = ?`, did)
10
+
return err
11
+
}
12
+
13
+
func (d *DB) GetAllDids() ([]string, error) {
14
+
var dids []string
15
+
16
+
rows, err := d.Query(`select did from known_dids`)
17
+
if err != nil {
18
+
return nil, err
19
+
}
20
+
defer rows.Close()
21
+
22
+
for rows.Next() {
23
+
var did string
24
+
if err := rows.Scan(&did); err != nil {
25
+
return nil, err
26
+
}
27
+
dids = append(dids, did)
28
+
}
29
+
30
+
if err := rows.Err(); err != nil {
31
+
return nil, err
32
+
}
33
+
34
+
return dids, nil
35
+
}
36
+
37
+
func (d *DB) HasKnownDids() bool {
38
+
var count int
39
+
err := d.QueryRow(`select count(*) from known_dids`).Scan(&count)
40
+
if err != nil {
41
+
return false
42
+
}
43
+
return count > 0
44
+
}
+48
spindle/db/repos.go
+48
spindle/db/repos.go
···
1
+
package db
2
+
3
+
type Repo struct {
4
+
Knot string
5
+
Owner string
6
+
Name string
7
+
}
8
+
9
+
func (d *DB) AddRepo(knot, owner, name string) error {
10
+
_, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name)
11
+
return err
12
+
}
13
+
14
+
func (d *DB) Knots() ([]string, error) {
15
+
rows, err := d.Query(`select knot from repos`)
16
+
if err != nil {
17
+
return nil, err
18
+
}
19
+
20
+
var knots []string
21
+
for rows.Next() {
22
+
var knot string
23
+
if err := rows.Scan(&knot); err != nil {
24
+
return nil, err
25
+
}
26
+
knots = append(knots, knot)
27
+
}
28
+
29
+
if err = rows.Err(); err != nil {
30
+
return nil, err
31
+
}
32
+
33
+
return knots, nil
34
+
}
35
+
36
+
func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) {
37
+
var repo Repo
38
+
39
+
query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?"
40
+
err := d.DB.QueryRow(query, knot, owner, name).
41
+
Scan(&repo.Knot, &repo.Owner, &repo.Name)
42
+
43
+
if err != nil {
44
+
return nil, err
45
+
}
46
+
47
+
return &repo, nil
48
+
}
+21
spindle/engine/ansi_stripper.go
+21
spindle/engine/ansi_stripper.go
···
1
+
package engine
2
+
3
+
import (
4
+
"io"
5
+
6
+
"regexp"
7
+
)
8
+
9
+
// regex to match ANSI escape codes (e.g., color codes, cursor moves)
10
+
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
11
+
12
+
var re = regexp.MustCompile(ansi)
13
+
14
+
type ansiStrippingWriter struct {
15
+
underlying io.Writer
16
+
}
17
+
18
+
func (w *ansiStrippingWriter) Write(p []byte) (int, error) {
19
+
clean := re.ReplaceAll(p, []byte{})
20
+
return w.underlying.Write(clean)
21
+
}
+444
spindle/engine/engine.go
+444
spindle/engine/engine.go
···
1
+
package engine
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"log/slog"
9
+
"os"
10
+
"strings"
11
+
"sync"
12
+
"time"
13
+
14
+
"github.com/docker/docker/api/types/container"
15
+
"github.com/docker/docker/api/types/image"
16
+
"github.com/docker/docker/api/types/mount"
17
+
"github.com/docker/docker/api/types/network"
18
+
"github.com/docker/docker/api/types/volume"
19
+
"github.com/docker/docker/client"
20
+
"github.com/docker/docker/pkg/stdcopy"
21
+
"tangled.sh/tangled.sh/core/log"
22
+
"tangled.sh/tangled.sh/core/notifier"
23
+
"tangled.sh/tangled.sh/core/spindle/config"
24
+
"tangled.sh/tangled.sh/core/spindle/db"
25
+
"tangled.sh/tangled.sh/core/spindle/models"
26
+
)
27
+
28
+
const (
29
+
workspaceDir = "/tangled/workspace"
30
+
)
31
+
32
+
type cleanupFunc func(context.Context) error
33
+
34
+
type Engine struct {
35
+
docker client.APIClient
36
+
l *slog.Logger
37
+
db *db.DB
38
+
n *notifier.Notifier
39
+
cfg *config.Config
40
+
41
+
cleanupMu sync.Mutex
42
+
cleanup map[string][]cleanupFunc
43
+
}
44
+
45
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
46
+
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
47
+
if err != nil {
48
+
return nil, err
49
+
}
50
+
51
+
l := log.FromContext(ctx).With("component", "spindle")
52
+
53
+
e := &Engine{
54
+
docker: dcli,
55
+
l: l,
56
+
db: db,
57
+
n: n,
58
+
cfg: cfg,
59
+
}
60
+
61
+
e.cleanup = make(map[string][]cleanupFunc)
62
+
63
+
return e, nil
64
+
}
65
+
66
+
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
67
+
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68
+
69
+
wg := sync.WaitGroup{}
70
+
for _, w := range pipeline.Workflows {
71
+
wg.Add(1)
72
+
go func() error {
73
+
defer wg.Done()
74
+
wid := models.WorkflowId{
75
+
PipelineId: pipelineId,
76
+
Name: w.Name,
77
+
}
78
+
79
+
err := e.db.StatusRunning(wid, e.n)
80
+
if err != nil {
81
+
return err
82
+
}
83
+
84
+
err = e.SetupWorkflow(ctx, wid)
85
+
if err != nil {
86
+
e.l.Error("setting up worklow", "wid", wid, "err", err)
87
+
return err
88
+
}
89
+
defer e.DestroyWorkflow(ctx, wid)
90
+
91
+
reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
92
+
if err != nil {
93
+
e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error())
94
+
95
+
err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
96
+
if err != nil {
97
+
return err
98
+
}
99
+
100
+
return fmt.Errorf("pulling image: %w", err)
101
+
}
102
+
defer reader.Close()
103
+
io.Copy(os.Stdout, reader)
104
+
105
+
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
106
+
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
107
+
if err != nil {
108
+
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
109
+
workflowTimeout = 5 * time.Minute
110
+
}
111
+
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
112
+
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113
+
defer cancel()
114
+
115
+
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
116
+
if err != nil {
117
+
if errors.Is(err, ErrTimedOut) {
118
+
dbErr := e.db.StatusTimeout(wid, e.n)
119
+
if dbErr != nil {
120
+
return dbErr
121
+
}
122
+
} else {
123
+
dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n)
124
+
if dbErr != nil {
125
+
return dbErr
126
+
}
127
+
}
128
+
129
+
return fmt.Errorf("starting steps image: %w", err)
130
+
}
131
+
132
+
err = e.db.StatusSuccess(wid, e.n)
133
+
if err != nil {
134
+
return err
135
+
}
136
+
137
+
return nil
138
+
}()
139
+
}
140
+
141
+
wg.Wait()
142
+
}
143
+
144
+
// SetupWorkflow sets up a new network for the workflow and volumes for
145
+
// the workspace and Nix store. These are persisted across steps and are
146
+
// destroyed at the end of the workflow.
147
+
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error {
148
+
e.l.Info("setting up workflow", "workflow", wid)
149
+
150
+
_, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{
151
+
Name: workspaceVolume(wid),
152
+
Driver: "local",
153
+
})
154
+
if err != nil {
155
+
return err
156
+
}
157
+
e.registerCleanup(wid, func(ctx context.Context) error {
158
+
return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true)
159
+
})
160
+
161
+
_, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{
162
+
Name: nixVolume(wid),
163
+
Driver: "local",
164
+
})
165
+
if err != nil {
166
+
return err
167
+
}
168
+
e.registerCleanup(wid, func(ctx context.Context) error {
169
+
return e.docker.VolumeRemove(ctx, nixVolume(wid), true)
170
+
})
171
+
172
+
_, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
173
+
Driver: "bridge",
174
+
})
175
+
if err != nil {
176
+
return err
177
+
}
178
+
e.registerCleanup(wid, func(ctx context.Context) error {
179
+
return e.docker.NetworkRemove(ctx, networkName(wid))
180
+
})
181
+
182
+
return nil
183
+
}
184
+
185
+
// StartSteps starts all steps sequentially with the same base image.
186
+
// ONLY marks pipeline as failed if container's exit code is non-zero.
187
+
// All other errors are bubbled up.
188
+
// Fixed version of the step execution logic
189
+
func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
190
+
191
+
for stepIdx, step := range steps {
192
+
select {
193
+
case <-ctx.Done():
194
+
return ctx.Err()
195
+
default:
196
+
}
197
+
198
+
envs := ConstructEnvs(step.Environment)
199
+
envs.AddEnv("HOME", workspaceDir)
200
+
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201
+
202
+
hostConfig := hostConfig(wid)
203
+
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204
+
Image: image,
205
+
Cmd: []string{"bash", "-c", step.Command},
206
+
WorkingDir: workspaceDir,
207
+
Tty: false,
208
+
Hostname: "spindle",
209
+
Env: envs.Slice(),
210
+
}, hostConfig, nil, nil, "")
211
+
defer e.DestroyStep(ctx, resp.ID)
212
+
if err != nil {
213
+
return fmt.Errorf("creating container: %w", err)
214
+
}
215
+
216
+
err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil)
217
+
if err != nil {
218
+
return fmt.Errorf("connecting network: %w", err)
219
+
}
220
+
221
+
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
222
+
if err != nil {
223
+
return err
224
+
}
225
+
e.l.Info("started container", "name", resp.ID, "step", step.Name)
226
+
227
+
// start tailing logs in background
228
+
tailDone := make(chan error, 1)
229
+
go func() {
230
+
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
231
+
}()
232
+
233
+
// wait for container completion or timeout
234
+
waitDone := make(chan struct{})
235
+
var state *container.State
236
+
var waitErr error
237
+
238
+
go func() {
239
+
defer close(waitDone)
240
+
state, waitErr = e.WaitStep(ctx, resp.ID)
241
+
}()
242
+
243
+
select {
244
+
case <-waitDone:
245
+
246
+
// wait for tailing to complete
247
+
<-tailDone
248
+
249
+
case <-ctx.Done():
250
+
e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name)
251
+
err = e.DestroyStep(context.Background(), resp.ID)
252
+
if err != nil {
253
+
e.l.Error("failed to destroy step", "container", resp.ID, "error", err)
254
+
}
255
+
256
+
// wait for both goroutines to finish
257
+
<-waitDone
258
+
<-tailDone
259
+
260
+
return ErrTimedOut
261
+
}
262
+
263
+
select {
264
+
case <-ctx.Done():
265
+
return ctx.Err()
266
+
default:
267
+
}
268
+
269
+
if waitErr != nil {
270
+
return waitErr
271
+
}
272
+
273
+
err = e.DestroyStep(ctx, resp.ID)
274
+
if err != nil {
275
+
return err
276
+
}
277
+
278
+
if state.ExitCode != 0 {
279
+
e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled)
280
+
if state.OOMKilled {
281
+
return ErrOOMKilled
282
+
}
283
+
return ErrWorkflowFailed
284
+
}
285
+
}
286
+
287
+
return nil
288
+
}
289
+
290
+
func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) {
291
+
wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
292
+
select {
293
+
case err := <-errCh:
294
+
if err != nil {
295
+
return nil, err
296
+
}
297
+
case <-wait:
298
+
}
299
+
300
+
e.l.Info("waited for container", "name", containerID)
301
+
302
+
info, err := e.docker.ContainerInspect(ctx, containerID)
303
+
if err != nil {
304
+
return nil, err
305
+
}
306
+
307
+
return info.State, nil
308
+
}
309
+
310
+
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
311
+
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
312
+
if err != nil {
313
+
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
314
+
return err
315
+
}
316
+
defer wfLogger.Close()
317
+
318
+
ctl := wfLogger.ControlWriter(stepIdx, step)
319
+
ctl.Write([]byte(step.Name))
320
+
321
+
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
322
+
Follow: true,
323
+
ShowStdout: true,
324
+
ShowStderr: true,
325
+
Details: false,
326
+
Timestamps: false,
327
+
})
328
+
if err != nil {
329
+
return err
330
+
}
331
+
332
+
_, err = stdcopy.StdCopy(
333
+
wfLogger.DataWriter("stdout"),
334
+
wfLogger.DataWriter("stderr"),
335
+
logs,
336
+
)
337
+
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
338
+
return fmt.Errorf("failed to copy logs: %w", err)
339
+
}
340
+
341
+
return nil
342
+
}
343
+
344
+
func (e *Engine) DestroyStep(ctx context.Context, containerID string) error {
345
+
err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL
346
+
if err != nil && !isErrContainerNotFoundOrNotRunning(err) {
347
+
return err
348
+
}
349
+
350
+
if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{
351
+
RemoveVolumes: true,
352
+
RemoveLinks: false,
353
+
Force: false,
354
+
}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {
355
+
return err
356
+
}
357
+
358
+
return nil
359
+
}
360
+
361
+
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
362
+
e.cleanupMu.Lock()
363
+
key := wid.String()
364
+
365
+
fns := e.cleanup[key]
366
+
delete(e.cleanup, key)
367
+
e.cleanupMu.Unlock()
368
+
369
+
for _, fn := range fns {
370
+
if err := fn(ctx); err != nil {
371
+
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
372
+
}
373
+
}
374
+
return nil
375
+
}
376
+
377
+
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
378
+
e.cleanupMu.Lock()
379
+
defer e.cleanupMu.Unlock()
380
+
381
+
key := wid.String()
382
+
e.cleanup[key] = append(e.cleanup[key], fn)
383
+
}
384
+
385
+
func workspaceVolume(wid models.WorkflowId) string {
386
+
return fmt.Sprintf("workspace-%s", wid)
387
+
}
388
+
389
+
func nixVolume(wid models.WorkflowId) string {
390
+
return fmt.Sprintf("nix-%s", wid)
391
+
}
392
+
393
+
func networkName(wid models.WorkflowId) string {
394
+
return fmt.Sprintf("workflow-network-%s", wid)
395
+
}
396
+
397
+
func hostConfig(wid models.WorkflowId) *container.HostConfig {
398
+
hostConfig := &container.HostConfig{
399
+
Mounts: []mount.Mount{
400
+
{
401
+
Type: mount.TypeVolume,
402
+
Source: workspaceVolume(wid),
403
+
Target: workspaceDir,
404
+
},
405
+
{
406
+
Type: mount.TypeVolume,
407
+
Source: nixVolume(wid),
408
+
Target: "/nix",
409
+
},
410
+
{
411
+
Type: mount.TypeTmpfs,
412
+
Target: "/tmp",
413
+
ReadOnly: false,
414
+
TmpfsOptions: &mount.TmpfsOptions{
415
+
Mode: 0o1777, // world-writeable sticky bit
416
+
Options: [][]string{
417
+
{"exec"},
418
+
},
419
+
},
420
+
},
421
+
{
422
+
Type: mount.TypeVolume,
423
+
Source: "etc-nix-" + wid.String(),
424
+
Target: "/etc/nix",
425
+
},
426
+
},
427
+
ReadonlyRootfs: false,
428
+
CapDrop: []string{"ALL"},
429
+
CapAdd: []string{"CAP_DAC_OVERRIDE"},
430
+
SecurityOpt: []string{"no-new-privileges"},
431
+
ExtraHosts: []string{"host.docker.internal:host-gateway"},
432
+
}
433
+
434
+
return hostConfig
435
+
}
436
+
437
+
// thanks woodpecker
438
+
func isErrContainerNotFoundOrNotRunning(err error) bool {
439
+
// Error response from daemon: Cannot kill container: ...: No such container: ...
440
+
// Error response from daemon: Cannot kill container: ...: Container ... is not running"
441
+
// Error response from podman daemon: can only kill running containers. ... is in state exited
442
+
// Error: No such container: ...
443
+
return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers"))
444
+
}
+28
spindle/engine/envs.go
+28
spindle/engine/envs.go
···
1
+
package engine
2
+
3
+
import (
4
+
"fmt"
5
+
)
6
+
7
+
type EnvVars []string
8
+
9
+
// ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value}
10
+
// representation into a docker-friendly []string{"KEY=value", ...} slice.
11
+
func ConstructEnvs(envs map[string]string) EnvVars {
12
+
var dockerEnvs EnvVars
13
+
for k, v := range envs {
14
+
ev := fmt.Sprintf("%s=%s", k, v)
15
+
dockerEnvs = append(dockerEnvs, ev)
16
+
}
17
+
return dockerEnvs
18
+
}
19
+
20
+
// Slice returns the EnvVar as a []string slice.
21
+
func (ev EnvVars) Slice() []string {
22
+
return ev
23
+
}
24
+
25
+
// AddEnv adds a key=value string to the EnvVar.
26
+
func (ev *EnvVars) AddEnv(key, value string) {
27
+
*ev = append(*ev, fmt.Sprintf("%s=%s", key, value))
28
+
}
+48
spindle/engine/envs_test.go
+48
spindle/engine/envs_test.go
···
1
+
package engine
2
+
3
+
import (
4
+
"testing"
5
+
6
+
"github.com/stretchr/testify/assert"
7
+
)
8
+
9
+
func TestConstructEnvs(t *testing.T) {
10
+
tests := []struct {
11
+
name string
12
+
in map[string]string
13
+
want EnvVars
14
+
}{
15
+
{
16
+
name: "empty input",
17
+
in: make(map[string]string),
18
+
want: EnvVars{},
19
+
},
20
+
{
21
+
name: "single env var",
22
+
in: map[string]string{"FOO": "bar"},
23
+
want: EnvVars{"FOO=bar"},
24
+
},
25
+
{
26
+
name: "multiple env vars",
27
+
in: map[string]string{"FOO": "bar", "BAZ": "qux"},
28
+
want: EnvVars{"FOO=bar", "BAZ=qux"},
29
+
},
30
+
}
31
+
for _, tt := range tests {
32
+
t.Run(tt.name, func(t *testing.T) {
33
+
got := ConstructEnvs(tt.in)
34
+
if got == nil {
35
+
got = EnvVars{}
36
+
}
37
+
assert.ElementsMatch(t, tt.want, got)
38
+
})
39
+
}
40
+
}
41
+
42
+
func TestAddEnv(t *testing.T) {
43
+
ev := EnvVars{}
44
+
ev.AddEnv("FOO", "bar")
45
+
ev.AddEnv("BAZ", "qux")
46
+
want := EnvVars{"FOO=bar", "BAZ=qux"}
47
+
assert.ElementsMatch(t, want, ev)
48
+
}
+9
spindle/engine/errors.go
+9
spindle/engine/errors.go
+84
spindle/engine/logger.go
+84
spindle/engine/logger.go
···
1
+
package engine
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"io"
7
+
"os"
8
+
"path/filepath"
9
+
"strings"
10
+
11
+
"tangled.sh/tangled.sh/core/spindle/models"
12
+
)
13
+
14
+
type WorkflowLogger struct {
15
+
file *os.File
16
+
encoder *json.Encoder
17
+
}
18
+
19
+
func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) {
20
+
path := LogFilePath(baseDir, wid)
21
+
22
+
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
23
+
if err != nil {
24
+
return nil, fmt.Errorf("creating log file: %w", err)
25
+
}
26
+
27
+
return &WorkflowLogger{
28
+
file: file,
29
+
encoder: json.NewEncoder(file),
30
+
}, nil
31
+
}
32
+
33
+
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
34
+
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
35
+
return logFilePath
36
+
}
37
+
38
+
func (l *WorkflowLogger) Close() error {
39
+
return l.file.Close()
40
+
}
41
+
42
+
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
43
+
// TODO: emit stream
44
+
return &dataWriter{
45
+
logger: l,
46
+
stream: stream,
47
+
}
48
+
}
49
+
50
+
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
51
+
return &controlWriter{
52
+
logger: l,
53
+
idx: idx,
54
+
step: step,
55
+
}
56
+
}
57
+
58
+
type dataWriter struct {
59
+
logger *WorkflowLogger
60
+
stream string
61
+
}
62
+
63
+
func (w *dataWriter) Write(p []byte) (int, error) {
64
+
line := strings.TrimRight(string(p), "\r\n")
65
+
entry := models.NewDataLogLine(line, w.stream)
66
+
if err := w.logger.encoder.Encode(entry); err != nil {
67
+
return 0, err
68
+
}
69
+
return len(p), nil
70
+
}
71
+
72
+
type controlWriter struct {
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step models.Step
76
+
}
77
+
78
+
func (w *controlWriter) Write(_ []byte) (int, error) {
79
+
entry := models.NewControlLogLine(w.idx, w.step)
80
+
if err := w.logger.encoder.Encode(entry); err != nil {
81
+
return 0, err
82
+
}
83
+
return len(w.step.Name), nil
84
+
}
+138
spindle/ingester.go
+138
spindle/ingester.go
···
1
+
package spindle
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/eventconsumer"
10
+
11
+
"github.com/bluesky-social/jetstream/pkg/models"
12
+
)
13
+
14
+
type Ingester func(ctx context.Context, e *models.Event) error
15
+
16
+
func (s *Spindle) ingest() Ingester {
17
+
return func(ctx context.Context, e *models.Event) error {
18
+
var err error
19
+
defer func() {
20
+
eventTime := e.TimeUS
21
+
lastTimeUs := eventTime + 1
22
+
if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil {
23
+
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
24
+
}
25
+
}()
26
+
27
+
if e.Kind != models.EventKindCommit {
28
+
return nil
29
+
}
30
+
31
+
switch e.Commit.Collection {
32
+
case tangled.SpindleMemberNSID:
33
+
s.ingestMember(ctx, e)
34
+
case tangled.RepoNSID:
35
+
s.ingestRepo(ctx, e)
36
+
}
37
+
38
+
return err
39
+
}
40
+
}
41
+
42
+
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
43
+
did := e.Did
44
+
var err error
45
+
46
+
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
47
+
48
+
switch e.Commit.Operation {
49
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
50
+
raw := e.Commit.Record
51
+
record := tangled.SpindleMember{}
52
+
err = json.Unmarshal(raw, &record)
53
+
if err != nil {
54
+
l.Error("invalid record", "error", err)
55
+
return err
56
+
}
57
+
58
+
domain := s.cfg.Server.Hostname
59
+
if s.cfg.Server.Dev {
60
+
domain = s.cfg.Server.ListenAddr
61
+
}
62
+
recordInstance := record.Instance
63
+
64
+
if recordInstance != domain {
65
+
l.Error("domain mismatch", "domain", recordInstance, "expected", domain)
66
+
return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain)
67
+
}
68
+
69
+
ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain)
70
+
if err != nil || !ok {
71
+
l.Error("failed to add member", "did", did, "error", err)
72
+
return fmt.Errorf("failed to enforce permissions: %w", err)
73
+
}
74
+
75
+
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
76
+
l.Error("failed to add member", "error", err)
77
+
return fmt.Errorf("failed to add member: %w", err)
78
+
}
79
+
l.Info("added member from firehose", "member", record.Subject)
80
+
81
+
if err := s.db.AddDid(record.Subject); err != nil {
82
+
l.Error("failed to add did", "error", err)
83
+
return fmt.Errorf("failed to add did: %w", err)
84
+
}
85
+
s.jc.AddDid(record.Subject)
86
+
87
+
return nil
88
+
89
+
}
90
+
return nil
91
+
}
92
+
93
+
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
94
+
var err error
95
+
96
+
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
+
98
+
l.Info("ingesting repo record")
99
+
100
+
switch e.Commit.Operation {
101
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
102
+
raw := e.Commit.Record
103
+
record := tangled.Repo{}
104
+
err = json.Unmarshal(raw, &record)
105
+
if err != nil {
106
+
l.Error("invalid record", "error", err)
107
+
return err
108
+
}
109
+
110
+
domain := s.cfg.Server.Hostname
111
+
112
+
// no spindle configured for this repo
113
+
if record.Spindle == nil {
114
+
l.Info("no spindle configured", "did", record.Owner, "name", record.Name)
115
+
return nil
116
+
}
117
+
118
+
// this repo did not want this spindle
119
+
if *record.Spindle != domain {
120
+
l.Info("different spindle configured", "did", record.Owner, "name", record.Name, "spindle", *record.Spindle, "domain", domain)
121
+
return nil
122
+
}
123
+
124
+
// add this repo to the watch list
125
+
if err := s.db.AddRepo(record.Knot, record.Owner, record.Name); err != nil {
126
+
l.Error("failed to add repo", "error", err)
127
+
return fmt.Errorf("failed to add repo: %w", err)
128
+
}
129
+
130
+
// add this knot to the event consumer
131
+
src := eventconsumer.NewKnotSource(record.Knot)
132
+
s.ks.AddSource(context.Background(), src)
133
+
134
+
return nil
135
+
136
+
}
137
+
return nil
138
+
}
+112
spindle/models/models.go
+112
spindle/models/models.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"regexp"
6
+
"slices"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
)
12
+
13
+
var (
14
+
re = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
15
+
)
16
+
17
+
type PipelineId struct {
18
+
Knot string
19
+
Rkey string
20
+
}
21
+
22
+
func (p *PipelineId) AtUri() syntax.ATURI {
23
+
return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey))
24
+
}
25
+
26
+
type WorkflowId struct {
27
+
PipelineId
28
+
Name string
29
+
}
30
+
31
+
func (wid WorkflowId) String() string {
32
+
return fmt.Sprintf("%s-%s-%s", normalize(wid.Knot), wid.Rkey, normalize(wid.Name))
33
+
}
34
+
35
+
func normalize(name string) string {
36
+
normalized := re.ReplaceAllString(name, "-")
37
+
return normalized
38
+
}
39
+
40
+
type StatusKind string
41
+
42
+
var (
43
+
StatusKindPending StatusKind = "pending"
44
+
StatusKindRunning StatusKind = "running"
45
+
StatusKindFailed StatusKind = "failed"
46
+
StatusKindTimeout StatusKind = "timeout"
47
+
StatusKindCancelled StatusKind = "cancelled"
48
+
StatusKindSuccess StatusKind = "success"
49
+
50
+
StartStates [2]StatusKind = [2]StatusKind{
51
+
StatusKindPending,
52
+
StatusKindRunning,
53
+
}
54
+
FinishStates [4]StatusKind = [4]StatusKind{
55
+
StatusKindCancelled,
56
+
StatusKindFailed,
57
+
StatusKindSuccess,
58
+
StatusKindTimeout,
59
+
}
60
+
)
61
+
62
+
func (s StatusKind) String() string {
63
+
return string(s)
64
+
}
65
+
66
+
func (s StatusKind) IsStart() bool {
67
+
return slices.Contains(StartStates[:], s)
68
+
}
69
+
70
+
func (s StatusKind) IsFinish() bool {
71
+
return slices.Contains(FinishStates[:], s)
72
+
}
73
+
74
+
type LogKind string
75
+
76
+
var (
77
+
// step log data
78
+
LogKindData LogKind = "data"
79
+
// indicates start/end of a step
80
+
LogKindControl LogKind = "control"
81
+
)
82
+
83
+
type LogLine struct {
84
+
Kind LogKind `json:"kind"`
85
+
Content string `json:"content"`
86
+
87
+
// fields if kind is "data"
88
+
Stream string `json:"stream,omitempty"`
89
+
90
+
// fields if kind is "control"
91
+
StepId int `json:"step_id,omitempty"`
92
+
StepKind StepKind `json:"step_kind,omitempty"`
93
+
StepCommand string `json:"step_command,omitempty"`
94
+
}
95
+
96
+
func NewDataLogLine(content, stream string) LogLine {
97
+
return LogLine{
98
+
Kind: LogKindData,
99
+
Content: content,
100
+
Stream: stream,
101
+
}
102
+
}
103
+
104
+
func NewControlLogLine(idx int, step Step) LogLine {
105
+
return LogLine{
106
+
Kind: LogKindControl,
107
+
Content: step.Name,
108
+
StepId: idx,
109
+
StepKind: step.Kind,
110
+
StepCommand: step.Command,
111
+
}
112
+
}
+126
spindle/models/pipeline.go
+126
spindle/models/pipeline.go
···
1
+
package models
2
+
3
+
import (
4
+
"path"
5
+
6
+
"tangled.sh/tangled.sh/core/api/tangled"
7
+
"tangled.sh/tangled.sh/core/spindle/config"
8
+
)
9
+
10
+
type Pipeline struct {
11
+
Workflows []Workflow
12
+
}
13
+
14
+
type Step struct {
15
+
Command string
16
+
Name string
17
+
Environment map[string]string
18
+
Kind StepKind
19
+
}
20
+
21
+
type StepKind int
22
+
23
+
const (
24
+
// steps injected by the CI runner
25
+
StepKindSystem StepKind = iota
26
+
// steps defined by the user in the original pipeline
27
+
StepKindUser
28
+
)
29
+
30
+
type Workflow struct {
31
+
Steps []Step
32
+
Environment map[string]string
33
+
Name string
34
+
Image string
35
+
}
36
+
37
+
// setupSteps get added to start of Steps
38
+
type setupSteps []Step
39
+
40
+
// addStep adds a step to the beginning of the workflow's steps.
41
+
func (ss *setupSteps) addStep(step Step) {
42
+
*ss = append(*ss, step)
43
+
}
44
+
45
+
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
46
+
// In the process, dependencies are resolved: nixpkgs deps
47
+
// are constructed atop nixery and set as the Workflow.Image,
48
+
// and ones from custom registries
49
+
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
50
+
workflows := []Workflow{}
51
+
52
+
for _, twf := range pl.Workflows {
53
+
swf := &Workflow{}
54
+
for _, tstep := range twf.Steps {
55
+
sstep := Step{}
56
+
sstep.Environment = stepEnvToMap(tstep.Environment)
57
+
sstep.Command = tstep.Command
58
+
sstep.Name = tstep.Name
59
+
sstep.Kind = StepKindUser
60
+
swf.Steps = append(swf.Steps, sstep)
61
+
}
62
+
swf.Name = twf.Name
63
+
swf.Environment = workflowEnvToMap(twf.Environment)
64
+
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
65
+
66
+
swf.addNixProfileToPath()
67
+
swf.setGlobalEnvs()
68
+
setup := &setupSteps{}
69
+
70
+
setup.addStep(nixConfStep())
71
+
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev))
72
+
// this step could be empty
73
+
if s := dependencyStep(*twf); s != nil {
74
+
setup.addStep(*s)
75
+
}
76
+
77
+
// append setup steps in order to the start of workflow steps
78
+
swf.Steps = append(*setup, swf.Steps...)
79
+
80
+
workflows = append(workflows, *swf)
81
+
}
82
+
return &Pipeline{Workflows: workflows}
83
+
}
84
+
85
+
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
86
+
envMap := map[string]string{}
87
+
for _, env := range envs {
88
+
if env != nil {
89
+
envMap[env.Key] = env.Value
90
+
}
91
+
}
92
+
return envMap
93
+
}
94
+
95
+
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
96
+
envMap := map[string]string{}
97
+
for _, env := range envs {
98
+
if env != nil {
99
+
envMap[env.Key] = env.Value
100
+
}
101
+
}
102
+
return envMap
103
+
}
104
+
105
+
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
106
+
var dependencies string
107
+
for _, d := range deps {
108
+
if d.Registry == "nixpkgs" {
109
+
dependencies = path.Join(d.Packages...)
110
+
}
111
+
}
112
+
113
+
// load defaults from somewhere else
114
+
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
115
+
116
+
return path.Join(nixery, dependencies)
117
+
}
118
+
119
+
func (wf *Workflow) addNixProfileToPath() {
120
+
wf.Environment["PATH"] = "$PATH:/.nix-profile/bin"
121
+
}
122
+
123
+
func (wf *Workflow) setGlobalEnvs() {
124
+
wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes"
125
+
wf.Environment["HOME"] = "/tangled/workspace"
126
+
}
+125
spindle/models/setup_steps.go
+125
spindle/models/setup_steps.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"path"
6
+
"strings"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/workflow"
10
+
)
11
+
12
+
func nixConfStep() Step {
13
+
setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
14
+
echo 'build-users-group = ' >> /etc/nix/nix.conf`
15
+
return Step{
16
+
Command: setupCmd,
17
+
Name: "Configure Nix",
18
+
}
19
+
}
20
+
21
+
// cloneOptsAsSteps processes clone options and adds corresponding steps
22
+
// to the beginning of the workflow's step list if cloning is not skipped.
23
+
//
24
+
// the steps to do here are:
25
+
// - git init
26
+
// - git remote add origin <url>
27
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
28
+
// - git checkout FETCH_HEAD
29
+
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
30
+
if twf.Clone.Skip {
31
+
return Step{}
32
+
}
33
+
34
+
var commands []string
35
+
36
+
// initialize git repo in workspace
37
+
commands = append(commands, "git init")
38
+
39
+
// add repo as git remote
40
+
scheme := "https://"
41
+
if dev {
42
+
scheme = "http://"
43
+
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
44
+
}
45
+
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
46
+
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
47
+
48
+
// run git fetch
49
+
{
50
+
var fetchArgs []string
51
+
52
+
// default clone depth is 1
53
+
depth := 1
54
+
if twf.Clone.Depth > 1 {
55
+
depth = int(twf.Clone.Depth)
56
+
}
57
+
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
58
+
59
+
// optionally recurse submodules
60
+
if twf.Clone.Submodules {
61
+
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
62
+
}
63
+
64
+
// set remote to fetch from
65
+
fetchArgs = append(fetchArgs, "origin")
66
+
67
+
// set revision to checkout
68
+
switch workflow.TriggerKind(tr.Kind) {
69
+
case workflow.TriggerKindManual:
70
+
// TODO: unimplemented
71
+
case workflow.TriggerKindPush:
72
+
fetchArgs = append(fetchArgs, tr.Push.NewSha)
73
+
case workflow.TriggerKindPullRequest:
74
+
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
75
+
}
76
+
77
+
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
78
+
}
79
+
80
+
// run git checkout
81
+
commands = append(commands, "git checkout FETCH_HEAD")
82
+
83
+
cloneStep := Step{
84
+
Command: strings.Join(commands, "\n"),
85
+
Name: "Clone repository into workspace",
86
+
}
87
+
return cloneStep
88
+
}
89
+
90
+
// dependencyStep processes dependencies defined in the workflow.
91
+
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
92
+
// all packages and adds a single 'nix profile install' step to the
93
+
// beginning of the workflow's step list.
94
+
func dependencyStep(twf tangled.Pipeline_Workflow) *Step {
95
+
var customPackages []string
96
+
97
+
for _, d := range twf.Dependencies {
98
+
registry := d.Registry
99
+
packages := d.Packages
100
+
101
+
if registry == "nixpkgs" {
102
+
continue
103
+
}
104
+
105
+
// collect packages from custom registries
106
+
for _, pkg := range packages {
107
+
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
108
+
}
109
+
}
110
+
111
+
if len(customPackages) > 0 {
112
+
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
113
+
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
114
+
installStep := Step{
115
+
Command: cmd,
116
+
Name: "Install custom dependencies",
117
+
Environment: map[string]string{
118
+
"NIX_NO_COLOR": "1",
119
+
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
120
+
},
121
+
}
122
+
return &installStep
123
+
}
124
+
return nil
125
+
}
+55
spindle/queue/queue.go
+55
spindle/queue/queue.go
···
1
+
package queue
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type Job struct {
8
+
Run func() error
9
+
OnFail func(error)
10
+
}
11
+
12
+
type Queue struct {
13
+
jobs chan Job
14
+
workers int
15
+
wg sync.WaitGroup
16
+
}
17
+
18
+
func NewQueue(queueSize, numWorkers int) *Queue {
19
+
return &Queue{
20
+
jobs: make(chan Job, queueSize),
21
+
workers: numWorkers,
22
+
}
23
+
}
24
+
25
+
func (q *Queue) Enqueue(job Job) bool {
26
+
select {
27
+
case q.jobs <- job:
28
+
return true
29
+
default:
30
+
return false
31
+
}
32
+
}
33
+
34
+
func (q *Queue) Start() {
35
+
for range q.workers {
36
+
q.wg.Add(1)
37
+
go q.worker()
38
+
}
39
+
}
40
+
41
+
func (q *Queue) worker() {
42
+
defer q.wg.Done()
43
+
for job := range q.jobs {
44
+
if err := job.Run(); err != nil {
45
+
if job.OnFail != nil {
46
+
job.OnFail(err)
47
+
}
48
+
}
49
+
}
50
+
}
51
+
52
+
func (q *Queue) Stop() {
53
+
close(q.jobs)
54
+
q.wg.Wait()
55
+
}
+275
spindle/server.go
+275
spindle/server.go
···
1
+
package spindle
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"log/slog"
8
+
"net/http"
9
+
10
+
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/eventconsumer"
13
+
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
14
+
"tangled.sh/tangled.sh/core/jetstream"
15
+
"tangled.sh/tangled.sh/core/log"
16
+
"tangled.sh/tangled.sh/core/notifier"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/spindle/config"
19
+
"tangled.sh/tangled.sh/core/spindle/db"
20
+
"tangled.sh/tangled.sh/core/spindle/engine"
21
+
"tangled.sh/tangled.sh/core/spindle/models"
22
+
"tangled.sh/tangled.sh/core/spindle/queue"
23
+
)
24
+
25
+
const (
26
+
rbacDomain = "thisserver"
27
+
)
28
+
29
+
type Spindle struct {
30
+
jc *jetstream.JetstreamClient
31
+
db *db.DB
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
eng *engine.Engine
36
+
jq *queue.Queue
37
+
cfg *config.Config
38
+
ks *eventconsumer.Consumer
39
+
}
40
+
41
+
func Run(ctx context.Context) error {
42
+
logger := log.FromContext(ctx)
43
+
44
+
cfg, err := config.Load(ctx)
45
+
if err != nil {
46
+
return fmt.Errorf("failed to load config: %w", err)
47
+
}
48
+
49
+
d, err := db.Make(cfg.Server.DBPath)
50
+
if err != nil {
51
+
return fmt.Errorf("failed to setup db: %w", err)
52
+
}
53
+
54
+
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
55
+
if err != nil {
56
+
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
57
+
}
58
+
e.E.EnableAutoSave(true)
59
+
60
+
n := notifier.New()
61
+
62
+
eng, err := engine.New(ctx, cfg, d, &n)
63
+
if err != nil {
64
+
return err
65
+
}
66
+
67
+
jq := queue.NewQueue(100, 2)
68
+
69
+
collections := []string{
70
+
tangled.SpindleMemberNSID,
71
+
tangled.RepoNSID,
72
+
}
73
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
+
if err != nil {
75
+
return fmt.Errorf("failed to setup jetstream client: %w", err)
76
+
}
77
+
jc.AddDid(cfg.Server.Owner)
78
+
79
+
spindle := Spindle{
80
+
jc: jc,
81
+
e: e,
82
+
db: d,
83
+
l: logger,
84
+
n: &n,
85
+
eng: eng,
86
+
jq: jq,
87
+
cfg: cfg,
88
+
}
89
+
90
+
err = e.AddSpindle(rbacDomain)
91
+
if err != nil {
92
+
return fmt.Errorf("failed to set rbac domain: %w", err)
93
+
}
94
+
err = spindle.configureOwner()
95
+
if err != nil {
96
+
return err
97
+
}
98
+
logger.Info("owner set", "did", cfg.Server.Owner)
99
+
100
+
// starts a job queue runner in the background
101
+
jq.Start()
102
+
defer jq.Stop()
103
+
104
+
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
+
if err != nil {
106
+
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
107
+
}
108
+
109
+
err = jc.StartJetstream(ctx, spindle.ingest())
110
+
if err != nil {
111
+
return fmt.Errorf("failed to start jetstream consumer: %w", err)
112
+
}
113
+
114
+
// for each incoming sh.tangled.pipeline, we execute
115
+
// spindle.processPipeline, which in turn enqueues the pipeline
116
+
// job in the above registered queue.
117
+
ccfg := eventconsumer.NewConsumerConfig()
118
+
ccfg.Logger = logger
119
+
ccfg.Dev = cfg.Server.Dev
120
+
ccfg.ProcessFunc = spindle.processPipeline
121
+
ccfg.CursorStore = cursorStore
122
+
knownKnots, err := d.Knots()
123
+
if err != nil {
124
+
return err
125
+
}
126
+
for _, knot := range knownKnots {
127
+
logger.Info("adding source start", "knot", knot)
128
+
ccfg.Sources[eventconsumer.NewKnotSource(knot)] = struct{}{}
129
+
}
130
+
spindle.ks = eventconsumer.NewConsumer(*ccfg)
131
+
132
+
go func() {
133
+
logger.Info("starting knot event consumer")
134
+
spindle.ks.Start(ctx)
135
+
}()
136
+
137
+
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
138
+
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
139
+
140
+
return nil
141
+
}
142
+
143
+
func (s *Spindle) Router() http.Handler {
144
+
mux := chi.NewRouter()
145
+
146
+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
147
+
w.Write([]byte(
148
+
` ****
149
+
*** ***
150
+
*** ** ****** **
151
+
** * *****
152
+
* ** **
153
+
* * * ***************
154
+
** ** *# **
155
+
* ** ** *** **
156
+
* * ** ** * ******
157
+
* ** ** * ** * *
158
+
** ** *** ** ** *
159
+
** ** * ** * *
160
+
** **** ** * *
161
+
** *** ** ** **
162
+
*** ** *****
163
+
********************
164
+
**
165
+
*
166
+
#**************
167
+
**
168
+
********
169
+
170
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`))
171
+
})
172
+
mux.HandleFunc("/events", s.Events)
173
+
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
+
w.Write([]byte(s.cfg.Server.Owner))
175
+
})
176
+
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
177
+
return mux
178
+
}
179
+
180
+
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
181
+
if msg.Nsid == tangled.PipelineNSID {
182
+
tpl := tangled.Pipeline{}
183
+
err := json.Unmarshal(msg.EventJson, &tpl)
184
+
if err != nil {
185
+
fmt.Println("error unmarshalling", err)
186
+
return err
187
+
}
188
+
189
+
if tpl.TriggerMetadata == nil {
190
+
return fmt.Errorf("no trigger metadata found")
191
+
}
192
+
193
+
if tpl.TriggerMetadata.Repo == nil {
194
+
return fmt.Errorf("no repo data found")
195
+
}
196
+
197
+
// filter by repos
198
+
_, err = s.db.GetRepo(
199
+
tpl.TriggerMetadata.Repo.Knot,
200
+
tpl.TriggerMetadata.Repo.Did,
201
+
tpl.TriggerMetadata.Repo.Repo,
202
+
)
203
+
if err != nil {
204
+
return err
205
+
}
206
+
207
+
pipelineId := models.PipelineId{
208
+
Knot: src.Key(),
209
+
Rkey: msg.Rkey,
210
+
}
211
+
212
+
for _, w := range tpl.Workflows {
213
+
if w != nil {
214
+
err := s.db.StatusPending(models.WorkflowId{
215
+
PipelineId: pipelineId,
216
+
Name: w.Name,
217
+
}, s.n)
218
+
if err != nil {
219
+
return err
220
+
}
221
+
}
222
+
}
223
+
224
+
spl := models.ToPipeline(tpl, *s.cfg)
225
+
226
+
ok := s.jq.Enqueue(queue.Job{
227
+
Run: func() error {
228
+
s.eng.StartWorkflows(ctx, spl, pipelineId)
229
+
return nil
230
+
},
231
+
OnFail: func(jobError error) {
232
+
s.l.Error("pipeline run failed", "error", jobError)
233
+
},
234
+
})
235
+
if ok {
236
+
s.l.Info("pipeline enqueued successfully", "id", msg.Rkey)
237
+
} else {
238
+
s.l.Error("failed to enqueue pipeline: queue is full")
239
+
}
240
+
}
241
+
242
+
return nil
243
+
}
244
+
245
+
func (s *Spindle) configureOwner() error {
246
+
cfgOwner := s.cfg.Server.Owner
247
+
248
+
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
switch len(existing) {
254
+
case 0:
255
+
// no owner configured, continue
256
+
case 1:
257
+
// find existing owner
258
+
existingOwner := existing[0]
259
+
260
+
// no ownership change, this is okay
261
+
if existingOwner == s.cfg.Server.Owner {
262
+
break
263
+
}
264
+
265
+
// remove existing owner
266
+
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
267
+
if err != nil {
268
+
return nil
269
+
}
270
+
default:
271
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
272
+
}
273
+
274
+
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
275
+
}
+242
spindle/stream.go
+242
spindle/stream.go
···
1
+
package spindle
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"strconv"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/spindle/engine"
13
+
"tangled.sh/tangled.sh/core/spindle/models"
14
+
15
+
"github.com/go-chi/chi/v5"
16
+
"github.com/gorilla/websocket"
17
+
"github.com/hpcloud/tail"
18
+
)
19
+
20
+
var upgrader = websocket.Upgrader{
21
+
ReadBufferSize: 1024,
22
+
WriteBufferSize: 1024,
23
+
}
24
+
25
+
func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) {
26
+
l := s.l.With("handler", "Events")
27
+
l.Debug("received new connection")
28
+
29
+
conn, err := upgrader.Upgrade(w, r, nil)
30
+
if err != nil {
31
+
l.Error("websocket upgrade failed", "err", err)
32
+
w.WriteHeader(http.StatusInternalServerError)
33
+
return
34
+
}
35
+
defer conn.Close()
36
+
l.Debug("upgraded http to wss")
37
+
38
+
ch := s.n.Subscribe()
39
+
defer s.n.Unsubscribe(ch)
40
+
41
+
ctx, cancel := context.WithCancel(r.Context())
42
+
defer cancel()
43
+
go func() {
44
+
for {
45
+
if _, _, err := conn.NextReader(); err != nil {
46
+
l.Error("failed to read", "err", err)
47
+
cancel()
48
+
return
49
+
}
50
+
}
51
+
}()
52
+
53
+
defaultCursor := time.Now().UnixNano()
54
+
cursorStr := r.URL.Query().Get("cursor")
55
+
cursor, err := strconv.ParseInt(cursorStr, 10, 64)
56
+
if err != nil {
57
+
l.Error("empty or invalid cursor", "invalidCursor", cursorStr, "default", defaultCursor)
58
+
}
59
+
if cursor == 0 {
60
+
cursor = defaultCursor
61
+
}
62
+
63
+
// complete backfill first before going to live data
64
+
l.Debug("going through backfill", "cursor", cursor)
65
+
if err := s.streamPipelines(conn, &cursor); err != nil {
66
+
l.Error("failed to backfill", "err", err)
67
+
return
68
+
}
69
+
70
+
for {
71
+
// wait for new data or timeout
72
+
select {
73
+
case <-ctx.Done():
74
+
l.Debug("stopping stream: client closed connection")
75
+
return
76
+
case <-ch:
77
+
// we have been notified of new data
78
+
l.Debug("going through live data", "cursor", cursor)
79
+
if err := s.streamPipelines(conn, &cursor); err != nil {
80
+
l.Error("failed to stream", "err", err)
81
+
return
82
+
}
83
+
case <-time.After(30 * time.Second):
84
+
// send a keep-alive
85
+
l.Debug("sent keepalive")
86
+
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
87
+
l.Error("failed to write control", "err", err)
88
+
}
89
+
}
90
+
}
91
+
}
92
+
93
+
func (s *Spindle) Logs(w http.ResponseWriter, r *http.Request) {
94
+
wid, err := getWorkflowID(r)
95
+
if err != nil {
96
+
http.Error(w, err.Error(), http.StatusBadRequest)
97
+
return
98
+
}
99
+
100
+
l := s.l.With("handler", "Logs")
101
+
l = s.l.With("wid", wid)
102
+
103
+
conn, err := upgrader.Upgrade(w, r, nil)
104
+
if err != nil {
105
+
l.Error("websocket upgrade failed", "err", err)
106
+
http.Error(w, "failed to upgrade", http.StatusInternalServerError)
107
+
return
108
+
}
109
+
defer func() {
110
+
_ = conn.WriteControl(
111
+
websocket.CloseMessage,
112
+
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"),
113
+
time.Now().Add(time.Second),
114
+
)
115
+
conn.Close()
116
+
}()
117
+
l.Debug("upgraded http to wss")
118
+
119
+
ctx, cancel := context.WithCancel(r.Context())
120
+
defer cancel()
121
+
122
+
go func() {
123
+
for {
124
+
if _, _, err := conn.NextReader(); err != nil {
125
+
l.Debug("client disconnected", "err", err)
126
+
cancel()
127
+
return
128
+
}
129
+
}
130
+
}()
131
+
132
+
if err := s.streamLogsFromDisk(ctx, conn, wid); err != nil {
133
+
l.Info("log stream ended", "err", err)
134
+
}
135
+
136
+
l.Info("logs connection closed")
137
+
}
138
+
139
+
func (s *Spindle) streamLogsFromDisk(ctx context.Context, conn *websocket.Conn, wid models.WorkflowId) error {
140
+
status, err := s.db.GetStatus(wid)
141
+
if err != nil {
142
+
return err
143
+
}
144
+
isFinished := models.StatusKind(status.Status).IsFinish()
145
+
146
+
filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid)
147
+
148
+
config := tail.Config{
149
+
Follow: !isFinished,
150
+
ReOpen: !isFinished,
151
+
MustExist: false,
152
+
Location: &tail.SeekInfo{
153
+
Offset: 0,
154
+
Whence: io.SeekStart,
155
+
},
156
+
// Logger: tail.DiscardingLogger,
157
+
}
158
+
159
+
t, err := tail.TailFile(filePath, config)
160
+
if err != nil {
161
+
return fmt.Errorf("failed to tail log file: %w", err)
162
+
}
163
+
defer t.Stop()
164
+
165
+
for {
166
+
select {
167
+
case <-ctx.Done():
168
+
return ctx.Err()
169
+
case line := <-t.Lines:
170
+
if line == nil && isFinished {
171
+
return fmt.Errorf("tail completed")
172
+
}
173
+
174
+
if line == nil {
175
+
return fmt.Errorf("tail channel closed unexpectedly")
176
+
}
177
+
178
+
if line.Err != nil {
179
+
return fmt.Errorf("error tailing log file: %w", line.Err)
180
+
}
181
+
182
+
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
183
+
return fmt.Errorf("failed to write to websocket: %w", err)
184
+
}
185
+
}
186
+
}
187
+
}
188
+
189
+
func (s *Spindle) streamPipelines(conn *websocket.Conn, cursor *int64) error {
190
+
events, err := s.db.GetEvents(*cursor)
191
+
if err != nil {
192
+
s.l.Debug("err", "err", err)
193
+
return err
194
+
}
195
+
s.l.Debug("ops", "ops", events)
196
+
197
+
for _, event := range events {
198
+
// first extract the inner json into a map
199
+
var eventJson map[string]any
200
+
err := json.Unmarshal([]byte(event.EventJson), &eventJson)
201
+
if err != nil {
202
+
s.l.Error("failed to unmarshal event", "err", err)
203
+
return err
204
+
}
205
+
206
+
jsonMsg, err := json.Marshal(map[string]any{
207
+
"rkey": event.Rkey,
208
+
"nsid": event.Nsid,
209
+
"event": eventJson,
210
+
})
211
+
if err != nil {
212
+
s.l.Error("failed to marshal record", "err", err)
213
+
return err
214
+
}
215
+
216
+
if err := conn.WriteMessage(websocket.TextMessage, jsonMsg); err != nil {
217
+
s.l.Debug("err", "err", err)
218
+
return err
219
+
}
220
+
*cursor = event.Created
221
+
}
222
+
223
+
return nil
224
+
}
225
+
226
+
func getWorkflowID(r *http.Request) (models.WorkflowId, error) {
227
+
knot := chi.URLParam(r, "knot")
228
+
rkey := chi.URLParam(r, "rkey")
229
+
name := chi.URLParam(r, "name")
230
+
231
+
if knot == "" || rkey == "" || name == "" {
232
+
return models.WorkflowId{}, fmt.Errorf("missing required parameters")
233
+
}
234
+
235
+
return models.WorkflowId{
236
+
PipelineId: models.PipelineId{
237
+
Knot: knot,
238
+
Rkey: rkey,
239
+
},
240
+
Name: name,
241
+
}, nil
242
+
}
+9
tid/tid.go
+9
tid/tid.go
+8
-2
types/repo.go
+8
-2
types/repo.go
···
109
109
Status ForkStatus `json:"status"`
110
110
}
111
111
112
+
type RepoLanguageDetails struct {
113
+
Name string
114
+
Percentage float32
115
+
Color string
116
+
}
117
+
112
118
type RepoLanguageResponse struct {
113
-
// Language: Percentage
114
-
Languages map[string]int `json:"languages"`
119
+
// Language: File count
120
+
Languages map[string]int64 `json:"languages"`
115
121
}
+8
-1
workflow/compile.go
+8
-1
workflow/compile.go
···
97
97
Command: s.Command,
98
98
Name: s.Name,
99
99
}
100
+
for k, v := range s.Environment {
101
+
e := &tangled.Pipeline_Pair{
102
+
Key: k,
103
+
Value: v,
104
+
}
105
+
step.Environment = append(step.Environment, e)
106
+
}
100
107
cw.Steps = append(cw.Steps, &step)
101
108
}
102
109
for k, v := range w.Environment {
103
-
e := &tangled.Pipeline_Workflow_Environment_Elem{
110
+
e := &tangled.Pipeline_Pair{
104
111
Key: k,
105
112
Value: v,
106
113
}
+1
-1
workflow/compile_test.go
+1
-1
workflow/compile_test.go
+18
-10
workflow/def.go
+18
-10
workflow/def.go
···
4
4
"errors"
5
5
"fmt"
6
6
"slices"
7
+
"strings"
7
8
8
9
"tangled.sh/tangled.sh/core/api/tangled"
9
10
···
45
46
}
46
47
47
48
Step struct {
48
-
Name string `yaml:"name"`
49
-
Command string `yaml:"command"`
49
+
Name string `yaml:"name"`
50
+
Command string `yaml:"command"`
51
+
Environment map[string]string `yaml:"environment"`
50
52
}
51
53
52
54
StringList []string
55
+
56
+
TriggerKind string
53
57
)
54
58
55
59
const (
56
-
TriggerKindPush string = "push"
57
-
TriggerKindPullRequest string = "pull_request"
58
-
TriggerKindManual string = "manual"
60
+
WorkflowDir = ".tangled/workflows"
61
+
62
+
TriggerKindPush TriggerKind = "push"
63
+
TriggerKindPullRequest TriggerKind = "pull_request"
64
+
TriggerKindManual TriggerKind = "manual"
59
65
)
66
+
67
+
func (t TriggerKind) String() string {
68
+
return strings.ReplaceAll(string(t), "_", " ")
69
+
}
60
70
61
71
func FromFile(name string, contents []byte) (Workflow, error) {
62
72
var wf Workflow
···
126
136
if refName.IsBranch() {
127
137
return slices.Contains(c.Branch, refName.Short())
128
138
}
129
-
fmt.Println("no", c.Branch, refName.Short())
130
-
131
139
return false
132
140
}
133
141
···
168
176
}
169
177
170
178
// conversion utilities to atproto records
171
-
func (d Dependencies) AsRecord() []tangled.Pipeline_Dependencies_Elem {
172
-
var deps []tangled.Pipeline_Dependencies_Elem
179
+
func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency {
180
+
var deps []*tangled.Pipeline_Dependency
173
181
for registry, packages := range d {
174
-
deps = append(deps, tangled.Pipeline_Dependencies_Elem{
182
+
deps = append(deps, &tangled.Pipeline_Dependency{
175
183
Registry: registry,
176
184
Packages: packages,
177
185
})
+9
workflow/def_test.go
+9
workflow/def_test.go
···
105
105
environment:
106
106
HOME: /home/foo bar/baz
107
107
CGO_ENABLED: 1
108
+
109
+
steps:
110
+
- name: Something
111
+
command: echo "hello"
112
+
environment:
113
+
FOO: bar
114
+
BAZ: qux
108
115
`
109
116
110
117
wf, err := FromFile("test.yml", []byte(yamlData))
···
113
120
assert.Len(t, wf.Environment, 2)
114
121
assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"])
115
122
assert.Equal(t, "1", wf.Environment["CGO_ENABLED"])
123
+
assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"])
124
+
assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"])
116
125
}