forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+10994 -1739
.tangled
api
appview
avatar
src
cmd
appview
eventconsumer
punchcardPopulate
spindle
docs
eventconsumer
guard
hook
knotclient
knotserver
lexicons
nix
notifier
rbac
spindle
tid
types
workflow
+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
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: ["master"] 4 + 5 + dependencies: 6 + nixpkgs: 7 + - go 8 + - alejandra 9 + 10 + steps: 11 + - name: "nix fmt" 12 + command: | 13 + alejandra -c nix/**/*.nix flake.nix 14 + 15 + - name: "go fmt" 16 + command: | 17 + gofmt -l . 18 +
+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
··· 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
··· 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
··· 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
··· 32 32 type RepoPull_Source struct { 33 33 Branch string `json:"branch" cborgen:"branch"` 34 34 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 35 + Sha string `json:"sha" cborgen:"sha"` 35 36 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 45 45 var args []any 46 46 for _, filter := range filters { 47 47 conditions = append(conditions, filter.Condition()) 48 - args = append(args, filter.arg) 48 + args = append(args, filter.Arg()...) 49 49 } 50 50 51 51 whereClause := ""
+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
··· 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
··· 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
··· 102 102 wg.Wait() 103 103 return results 104 104 } 105 + 106 + func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 107 + id, err := syntax.ParseAtIdentifier(arg) 108 + if err != nil { 109 + return err 110 + } 111 + 112 + return r.directory.Purge(ctx, *id) 113 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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, &params.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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 19 19 href="#{{ .CommentId }}" 20 20 class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 21 id="{{ .CommentId }}"> 22 - {{ .Created | timeFmt }} 22 + {{ template "repo/fragments/time" .Created }} 23 23 </a> 24 24 25 25 <button
+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
··· 4 4 {{ define "extrameta" }} 5 5 {{ $title := printf "%s &middot; issue #%d &middot; %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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + {{ define "repo/pipelines/fragments/logLine" }} 2 + <div id="step-body-{{ .Id }}" hx-swap-oob="beforeend" class="whitespace-pre"><p>{{ .Content }}</p></div> 3 + {{ end }} 4 +
+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
··· 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
··· 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
··· 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
··· 1 + {{ define "title" }}pipelines &middot; {{ .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
··· 1 + {{ define "title" }} {{ .Workflow }} &middot; pipeline {{ .Pipeline.Id }} &middot; {{ .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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "%s &middot; pull #%d &middot; %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
··· 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
··· 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>
+2 -2
appview/pages/templates/repo/tags.html
··· 35 35 <span>{{ .Tag.Tagger.Name }}</span> 36 36 37 37 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 - <time>{{ shortTimeFmt .Tag.Tagger.When }}</time> 38 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 39 {{ end }} 40 40 </div> 41 41 </div> ··· 54 54 {{ slice .Tag.Target.String 0 8 }} 55 55 </a> 56 56 <span>{{ .Tag.Tagger.Name }}</span> 57 - <time>{{ timeFmt .Tag.Tagger.When }}</time> 57 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 58 {{ end }} 59 59 </div> 60 60 </div>
+9 -3
appview/pages/templates/repo/tree.html
··· 11 11 {{ template "repo/fragments/meta" . }} 12 12 {{ $title := printf "%s at %s &middot; %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
··· 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
··· 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; 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
··· 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
··· 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
··· 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
··· 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
··· 1 + {{ define "user/fragments/picHandle" }} 2 + <img 3 + src="{{ tinyAvatar . }}" 4 + alt="{{ . }}" 5 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 + /> 7 + {{ . | truncateAt30 }} 8 + {{ end }}
+5
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 + {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ . }}" class="flex items-center"> 3 + {{ template "user/fragments/picHandle" . }} 4 + </a> 5 + {{ end }}
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 37 37 dateStr := day.Format("2006-01-02") 38 38 _, err := stmt.Exec(did, dateStr, count) 39 39 if err != nil { 40 - log.Println("Failed to insert for date %s: %v", dateStr, err) 40 + log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 41 } 42 42 } 43 43
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package cursor 2 + 3 + type Store interface { 4 + Set(knot string, cursor int64) 5 + Get(knot string) (cursor int64) 6 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 28 28 "type": "string", 29 29 "description": "knot where the repo was created" 30 30 }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 31 35 "description": { 32 36 "type": "string", 33 37 "format": "datetime",
+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
··· 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
··· 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
··· 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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package engine 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + ErrTimedOut = errors.New("timed out") 8 + ErrWorkflowFailed = errors.New("workflow failed") 9 + )
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package tid 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + var TIDClock = syntax.NewTIDClock(0) 6 + 7 + func TID() string { 8 + return TIDClock.Next().String() 9 + }
+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
··· 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
··· 9 9 ) 10 10 11 11 var trigger = tangled.Pipeline_TriggerMetadata{ 12 - Kind: TriggerKindPush, 12 + Kind: string(TriggerKindPush), 13 13 Push: &tangled.Pipeline_PushTriggerData{ 14 14 Ref: "refs/heads/main", 15 15 OldSha: strings.Repeat("0", 40),
+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
··· 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 }