Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+2626 -3389
api
appview
cmd
cborgen
docs
knotserver
lexicons
comment
pipeline
pulls
nix
orm
rbac2
spindle
tap
types
+495 -20
api/tangled/cbor_gen.go
··· 561 561 562 562 return nil 563 563 } 564 + func (t *Comment) MarshalCBOR(w io.Writer) error { 565 + if t == nil { 566 + _, err := w.Write(cbg.CborNull) 567 + return err 568 + } 569 + 570 + cw := cbg.NewCborWriter(w) 571 + fieldCount := 7 572 + 573 + if t.Mentions == nil { 574 + fieldCount-- 575 + } 576 + 577 + if t.References == nil { 578 + fieldCount-- 579 + } 580 + 581 + if t.ReplyTo == nil { 582 + fieldCount-- 583 + } 584 + 585 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 586 + return err 587 + } 588 + 589 + // t.Body (string) (string) 590 + if len("body") > 1000000 { 591 + return xerrors.Errorf("Value in field \"body\" was too long") 592 + } 593 + 594 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 595 + return err 596 + } 597 + if _, err := cw.WriteString(string("body")); err != nil { 598 + return err 599 + } 600 + 601 + if len(t.Body) > 1000000 { 602 + return xerrors.Errorf("Value in field t.Body was too long") 603 + } 604 + 605 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 606 + return err 607 + } 608 + if _, err := cw.WriteString(string(t.Body)); err != nil { 609 + return err 610 + } 611 + 612 + // t.LexiconTypeID (string) (string) 613 + if len("$type") > 1000000 { 614 + return xerrors.Errorf("Value in field \"$type\" was too long") 615 + } 616 + 617 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 618 + return err 619 + } 620 + if _, err := cw.WriteString(string("$type")); err != nil { 621 + return err 622 + } 623 + 624 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil { 625 + return err 626 + } 627 + if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { 628 + return err 629 + } 630 + 631 + // t.ReplyTo (string) (string) 632 + if t.ReplyTo != nil { 633 + 634 + if len("replyTo") > 1000000 { 635 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 636 + } 637 + 638 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 639 + return err 640 + } 641 + if _, err := cw.WriteString(string("replyTo")); err != nil { 642 + return err 643 + } 644 + 645 + if t.ReplyTo == nil { 646 + if _, err := cw.Write(cbg.CborNull); err != nil { 647 + return err 648 + } 649 + } else { 650 + if len(*t.ReplyTo) > 1000000 { 651 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 652 + } 653 + 654 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 655 + return err 656 + } 657 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 658 + return err 659 + } 660 + } 661 + } 662 + 663 + // t.Subject (string) (string) 664 + if len("subject") > 1000000 { 665 + return xerrors.Errorf("Value in field \"subject\" was too long") 666 + } 667 + 668 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 669 + return err 670 + } 671 + if _, err := cw.WriteString(string("subject")); err != nil { 672 + return err 673 + } 674 + 675 + if len(t.Subject) > 1000000 { 676 + return xerrors.Errorf("Value in field t.Subject was too long") 677 + } 678 + 679 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 680 + return err 681 + } 682 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 683 + return err 684 + } 685 + 686 + // t.Mentions ([]string) (slice) 687 + if t.Mentions != nil { 688 + 689 + if len("mentions") > 1000000 { 690 + return xerrors.Errorf("Value in field \"mentions\" was too long") 691 + } 692 + 693 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 694 + return err 695 + } 696 + if _, err := cw.WriteString(string("mentions")); err != nil { 697 + return err 698 + } 699 + 700 + if len(t.Mentions) > 8192 { 701 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 702 + } 703 + 704 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 705 + return err 706 + } 707 + for _, v := range t.Mentions { 708 + if len(v) > 1000000 { 709 + return xerrors.Errorf("Value in field v was too long") 710 + } 711 + 712 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 713 + return err 714 + } 715 + if _, err := cw.WriteString(string(v)); err != nil { 716 + return err 717 + } 718 + 719 + } 720 + } 721 + 722 + // t.CreatedAt (string) (string) 723 + if len("createdAt") > 1000000 { 724 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 725 + } 726 + 727 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 728 + return err 729 + } 730 + if _, err := cw.WriteString(string("createdAt")); err != nil { 731 + return err 732 + } 733 + 734 + if len(t.CreatedAt) > 1000000 { 735 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 736 + } 737 + 738 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 739 + return err 740 + } 741 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 742 + return err 743 + } 744 + 745 + // t.References ([]string) (slice) 746 + if t.References != nil { 747 + 748 + if len("references") > 1000000 { 749 + return xerrors.Errorf("Value in field \"references\" was too long") 750 + } 751 + 752 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 753 + return err 754 + } 755 + if _, err := cw.WriteString(string("references")); err != nil { 756 + return err 757 + } 758 + 759 + if len(t.References) > 8192 { 760 + return xerrors.Errorf("Slice value in field t.References was too long") 761 + } 762 + 763 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 764 + return err 765 + } 766 + for _, v := range t.References { 767 + if len(v) > 1000000 { 768 + return xerrors.Errorf("Value in field v was too long") 769 + } 770 + 771 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 772 + return err 773 + } 774 + if _, err := cw.WriteString(string(v)); err != nil { 775 + return err 776 + } 777 + 778 + } 779 + } 780 + return nil 781 + } 782 + 783 + func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { 784 + *t = Comment{} 785 + 786 + cr := cbg.NewCborReader(r) 787 + 788 + maj, extra, err := cr.ReadHeader() 789 + if err != nil { 790 + return err 791 + } 792 + defer func() { 793 + if err == io.EOF { 794 + err = io.ErrUnexpectedEOF 795 + } 796 + }() 797 + 798 + if maj != cbg.MajMap { 799 + return fmt.Errorf("cbor input should be of type map") 800 + } 801 + 802 + if extra > cbg.MaxLength { 803 + return fmt.Errorf("Comment: map struct too large (%d)", extra) 804 + } 805 + 806 + n := extra 807 + 808 + nameBuf := make([]byte, 10) 809 + for i := uint64(0); i < n; i++ { 810 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 811 + if err != nil { 812 + return err 813 + } 814 + 815 + if !ok { 816 + // Field doesn't exist on this type, so ignore it 817 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 818 + return err 819 + } 820 + continue 821 + } 822 + 823 + switch string(nameBuf[:nameLen]) { 824 + // t.Body (string) (string) 825 + case "body": 826 + 827 + { 828 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 829 + if err != nil { 830 + return err 831 + } 832 + 833 + t.Body = string(sval) 834 + } 835 + // t.LexiconTypeID (string) (string) 836 + case "$type": 837 + 838 + { 839 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 840 + if err != nil { 841 + return err 842 + } 843 + 844 + t.LexiconTypeID = string(sval) 845 + } 846 + // t.ReplyTo (string) (string) 847 + case "replyTo": 848 + 849 + { 850 + b, err := cr.ReadByte() 851 + if err != nil { 852 + return err 853 + } 854 + if b != cbg.CborNull[0] { 855 + if err := cr.UnreadByte(); err != nil { 856 + return err 857 + } 858 + 859 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 860 + if err != nil { 861 + return err 862 + } 863 + 864 + t.ReplyTo = (*string)(&sval) 865 + } 866 + } 867 + // t.Subject (string) (string) 868 + case "subject": 869 + 870 + { 871 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 872 + if err != nil { 873 + return err 874 + } 875 + 876 + t.Subject = string(sval) 877 + } 878 + // t.Mentions ([]string) (slice) 879 + case "mentions": 880 + 881 + maj, extra, err = cr.ReadHeader() 882 + if err != nil { 883 + return err 884 + } 885 + 886 + if extra > 8192 { 887 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 888 + } 889 + 890 + if maj != cbg.MajArray { 891 + return fmt.Errorf("expected cbor array") 892 + } 893 + 894 + if extra > 0 { 895 + t.Mentions = make([]string, extra) 896 + } 897 + 898 + for i := 0; i < int(extra); i++ { 899 + { 900 + var maj byte 901 + var extra uint64 902 + var err error 903 + _ = maj 904 + _ = extra 905 + _ = err 906 + 907 + { 908 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 909 + if err != nil { 910 + return err 911 + } 912 + 913 + t.Mentions[i] = string(sval) 914 + } 915 + 916 + } 917 + } 918 + // t.CreatedAt (string) (string) 919 + case "createdAt": 920 + 921 + { 922 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 923 + if err != nil { 924 + return err 925 + } 926 + 927 + t.CreatedAt = string(sval) 928 + } 929 + // t.References ([]string) (slice) 930 + case "references": 931 + 932 + maj, extra, err = cr.ReadHeader() 933 + if err != nil { 934 + return err 935 + } 936 + 937 + if extra > 8192 { 938 + return fmt.Errorf("t.References: array too large (%d)", extra) 939 + } 940 + 941 + if maj != cbg.MajArray { 942 + return fmt.Errorf("expected cbor array") 943 + } 944 + 945 + if extra > 0 { 946 + t.References = make([]string, extra) 947 + } 948 + 949 + for i := 0; i < int(extra); i++ { 950 + { 951 + var maj byte 952 + var extra uint64 953 + var err error 954 + _ = maj 955 + _ = extra 956 + _ = err 957 + 958 + { 959 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 960 + if err != nil { 961 + return err 962 + } 963 + 964 + t.References[i] = string(sval) 965 + } 966 + 967 + } 968 + } 969 + 970 + default: 971 + // Field doesn't exist on this type, so ignore it 972 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 973 + return err 974 + } 975 + } 976 + } 977 + 978 + return nil 979 + } 564 980 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 565 981 if t == nil { 566 982 _, err := w.Write(cbg.CborNull) ··· 7934 8350 } 7935 8351 7936 8352 cw := cbg.NewCborWriter(w) 7937 - fieldCount := 9 8353 + fieldCount := 10 7938 8354 7939 8355 if t.Body == nil { 7940 8356 fieldCount-- 7941 8357 } 7942 8358 7943 8359 if t.Mentions == nil { 8360 + fieldCount-- 8361 + } 8362 + 8363 + if t.Patch == nil { 7944 8364 fieldCount-- 7945 8365 } 7946 8366 ··· 8008 8428 } 8009 8429 8010 8430 // t.Patch (string) (string) 8011 - if len("patch") > 1000000 { 8012 - return xerrors.Errorf("Value in field \"patch\" was too long") 8013 - } 8431 + if t.Patch != nil { 8014 8432 8015 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8016 - return err 8017 - } 8018 - if _, err := cw.WriteString(string("patch")); err != nil { 8019 - return err 8020 - } 8433 + if len("patch") > 1000000 { 8434 + return xerrors.Errorf("Value in field \"patch\" was too long") 8435 + } 8021 8436 8022 - if len(t.Patch) > 1000000 { 8023 - return xerrors.Errorf("Value in field t.Patch was too long") 8024 - } 8437 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8438 + return err 8439 + } 8440 + if _, err := cw.WriteString(string("patch")); err != nil { 8441 + return err 8442 + } 8443 + 8444 + if t.Patch == nil { 8445 + if _, err := cw.Write(cbg.CborNull); err != nil { 8446 + return err 8447 + } 8448 + } else { 8449 + if len(*t.Patch) > 1000000 { 8450 + return xerrors.Errorf("Value in field t.Patch was too long") 8451 + } 8025 8452 8026 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil { 8027 - return err 8028 - } 8029 - if _, err := cw.WriteString(string(t.Patch)); err != nil { 8030 - return err 8453 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil { 8454 + return err 8455 + } 8456 + if _, err := cw.WriteString(string(*t.Patch)); err != nil { 8457 + return err 8458 + } 8459 + } 8031 8460 } 8032 8461 8033 8462 // t.Title (string) (string) ··· 8147 8576 return err 8148 8577 } 8149 8578 8579 + // t.PatchBlob (util.LexBlob) (struct) 8580 + if len("patchBlob") > 1000000 { 8581 + return xerrors.Errorf("Value in field \"patchBlob\" was too long") 8582 + } 8583 + 8584 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil { 8585 + return err 8586 + } 8587 + if _, err := cw.WriteString(string("patchBlob")); err != nil { 8588 + return err 8589 + } 8590 + 8591 + if err := t.PatchBlob.MarshalCBOR(cw); err != nil { 8592 + return err 8593 + } 8594 + 8150 8595 // t.References ([]string) (slice) 8151 8596 if t.References != nil { 8152 8597 ··· 8262 8707 case "patch": 8263 8708 8264 8709 { 8265 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 8710 + b, err := cr.ReadByte() 8266 8711 if err != nil { 8267 8712 return err 8268 8713 } 8714 + if b != cbg.CborNull[0] { 8715 + if err := cr.UnreadByte(); err != nil { 8716 + return err 8717 + } 8269 8718 8270 - t.Patch = string(sval) 8719 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8720 + if err != nil { 8721 + return err 8722 + } 8723 + 8724 + t.Patch = (*string)(&sval) 8725 + } 8271 8726 } 8272 8727 // t.Title (string) (string) 8273 8728 case "title": ··· 8370 8825 } 8371 8826 8372 8827 t.CreatedAt = string(sval) 8828 + } 8829 + // t.PatchBlob (util.LexBlob) (struct) 8830 + case "patchBlob": 8831 + 8832 + { 8833 + 8834 + b, err := cr.ReadByte() 8835 + if err != nil { 8836 + return err 8837 + } 8838 + if b != cbg.CborNull[0] { 8839 + if err := cr.UnreadByte(); err != nil { 8840 + return err 8841 + } 8842 + t.PatchBlob = new(util.LexBlob) 8843 + if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil { 8844 + return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err) 8845 + } 8846 + } 8847 + 8373 8848 } 8374 8849 // t.References ([]string) (slice) 8375 8850 case "references":
-34
api/tangled/pipelinecancelPipeline.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.pipeline.cancelPipeline 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline" 15 - ) 16 - 17 - // PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call. 18 - type PipelineCancelPipeline_Input struct { 19 - // pipeline: pipeline at-uri 20 - Pipeline string `json:"pipeline" cborgen:"pipeline"` 21 - // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet 22 - Repo string `json:"repo" cborgen:"repo"` 23 - // workflow: workflow name 24 - Workflow string `json:"workflow" cborgen:"workflow"` 25 - } 26 - 27 - // PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline". 28 - func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error { 29 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil { 30 - return err 31 - } 32 - 33 - return nil 34 - }
+12 -9
api/tangled/repopull.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPull 19 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 - Patch string `json:"patch" cborgen:"patch"` 25 - References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 27 - Target *RepoPull_Target `json:"target" cborgen:"target"` 28 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + // patch: (deprecated) use patchBlob instead 25 + Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"` 26 + // patchBlob: patch content 27 + PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` 28 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 29 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 30 + Target *RepoPull_Target `json:"target" cborgen:"target"` 31 + Title string `json:"title" cborgen:"title"` 29 32 } 30 33 31 34 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+27
api/tangled/tangledcomment.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.comment 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + CommentNSID = "sh.tangled.comment" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.comment", &Comment{}) 17 + } // 18 + // RECORDTYPE: Comment 19 + type Comment struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 25 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 + Subject string `json:"subject" cborgen:"subject"` 27 + }
+199
appview/db/comments.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 15 + ) 16 + 17 + func PutComment(tx *sql.Tx, c *models.Comment) error { 18 + result, err := tx.Exec( 19 + `insert into comments ( 20 + did, 21 + rkey, 22 + subject_at, 23 + reply_to, 24 + body, 25 + pull_submission_id, 26 + created 27 + ) 28 + values (?, ?, ?, ?, ?, ?, ?) 29 + on conflict(did, rkey) do update set 30 + subject_at = excluded.subject_at, 31 + reply_to = excluded.reply_to, 32 + body = excluded.body, 33 + edited = case 34 + when 35 + comments.subject_at != excluded.subject_at 36 + or comments.body != excluded.body 37 + or comments.reply_to != excluded.reply_to 38 + then ? 39 + else comments.edited 40 + end`, 41 + c.Did, 42 + c.Rkey, 43 + c.Subject, 44 + c.ReplyTo, 45 + c.Body, 46 + c.PullSubmissionId, 47 + c.Created.Format(time.RFC3339), 48 + time.Now().Format(time.RFC3339), 49 + ) 50 + if err != nil { 51 + return err 52 + } 53 + 54 + c.Id, err = result.LastInsertId() 55 + if err != nil { 56 + return err 57 + } 58 + 59 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 60 + return fmt.Errorf("put reference_links: %w", err) 61 + } 62 + 63 + return nil 64 + } 65 + 66 + func DeleteComments(e Execer, filters ...orm.Filter) error { 67 + var conditions []string 68 + var args []any 69 + for _, filter := range filters { 70 + conditions = append(conditions, filter.Condition()) 71 + args = append(args, filter.Arg()...) 72 + } 73 + 74 + whereClause := "" 75 + if conditions != nil { 76 + whereClause = " where " + strings.Join(conditions, " and ") 77 + } 78 + 79 + query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 80 + 81 + _, err := e.Exec(query, args...) 82 + return err 83 + } 84 + 85 + func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 86 + commentMap := make(map[string]*models.Comment) 87 + 88 + var conditions []string 89 + var args []any 90 + for _, filter := range filters { 91 + conditions = append(conditions, filter.Condition()) 92 + args = append(args, filter.Arg()...) 93 + } 94 + 95 + whereClause := "" 96 + if conditions != nil { 97 + whereClause = " where " + strings.Join(conditions, " and ") 98 + } 99 + 100 + query := fmt.Sprintf(` 101 + select 102 + id, 103 + did, 104 + rkey, 105 + subject_at, 106 + reply_to, 107 + body, 108 + pull_submission_id, 109 + created, 110 + edited, 111 + deleted 112 + from 113 + comments 114 + %s 115 + `, whereClause) 116 + 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 120 + } 121 + 122 + for rows.Next() { 123 + var comment models.Comment 124 + var created string 125 + var rkey, edited, deleted, replyTo sql.Null[string] 126 + err := rows.Scan( 127 + &comment.Id, 128 + &comment.Did, 129 + &rkey, 130 + &comment.Subject, 131 + &replyTo, 132 + &comment.Body, 133 + &comment.PullSubmissionId, 134 + &created, 135 + &edited, 136 + &deleted, 137 + ) 138 + if err != nil { 139 + return nil, err 140 + } 141 + 142 + // this is a remnant from old times, newer comments always have rkey 143 + if rkey.Valid { 144 + comment.Rkey = rkey.V 145 + } 146 + 147 + if t, err := time.Parse(time.RFC3339, created); err == nil { 148 + comment.Created = t 149 + } 150 + 151 + if edited.Valid { 152 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 153 + comment.Edited = &t 154 + } 155 + } 156 + 157 + if deleted.Valid { 158 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 159 + comment.Deleted = &t 160 + } 161 + } 162 + 163 + if replyTo.Valid { 164 + rt := syntax.ATURI(replyTo.V) 165 + comment.ReplyTo = &rt 166 + } 167 + 168 + atUri := comment.AtUri().String() 169 + commentMap[atUri] = &comment 170 + } 171 + 172 + if err := rows.Err(); err != nil { 173 + return nil, err 174 + } 175 + defer rows.Close() 176 + 177 + // collect references from each comments 178 + commentAts := slices.Collect(maps.Keys(commentMap)) 179 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 180 + if err != nil { 181 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 182 + } 183 + for commentAt, references := range allReferencs { 184 + if comment, ok := commentMap[commentAt.String()]; ok { 185 + comment.References = references 186 + } 187 + } 188 + 189 + var comments []models.Comment 190 + for _, c := range commentMap { 191 + comments = append(comments, *c) 192 + } 193 + 194 + sort.Slice(comments, func(i, j int) bool { 195 + return comments[i].Created.After(comments[j].Created) 196 + }) 197 + 198 + return comments, nil 199 + }
+81
appview/db/db.go
··· 1173 1173 return err 1174 1174 }) 1175 1175 1176 + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1177 + _, err := tx.Exec(` 1178 + drop table if exists comments; 1179 + 1180 + create table comments ( 1181 + -- identifiers 1182 + id integer primary key autoincrement, 1183 + did text not null, 1184 + collection text not null default 'sh.tangled.comment', 1185 + rkey text not null, 1186 + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, 1187 + 1188 + -- at identifiers 1189 + subject_at text not null, 1190 + reply_to text, -- at_uri of parent comment 1191 + 1192 + pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds 1193 + 1194 + -- content 1195 + body text not null, 1196 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1197 + edited text, 1198 + deleted text, 1199 + 1200 + -- constraints 1201 + unique(did, rkey) 1202 + ); 1203 + 1204 + insert into comments ( 1205 + did, 1206 + collection, 1207 + rkey, 1208 + subject_at, 1209 + reply_to, 1210 + body, 1211 + created, 1212 + edited, 1213 + deleted 1214 + ) 1215 + select 1216 + did, 1217 + 'sh.tangled.repo.issue.comment', 1218 + rkey, 1219 + issue_at, 1220 + reply_to, 1221 + body, 1222 + created, 1223 + edited, 1224 + deleted 1225 + from issue_comments 1226 + where rkey is not null; 1227 + 1228 + insert into comments ( 1229 + did, 1230 + collection, 1231 + rkey, 1232 + subject_at, 1233 + pull_submission_id, 1234 + body, 1235 + created 1236 + ) 1237 + select 1238 + c.owner_did, 1239 + 'sh.tangled.repo.pull.comment', 1240 + substr( 1241 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1242 + instr( 1243 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1244 + '/' 1245 + ) + 1 1246 + ), -- rkey 1247 + p.at_uri, 1248 + c.submission_id, 1249 + c.body, 1250 + c.created 1251 + from pull_comments c 1252 + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id; 1253 + `) 1254 + return err 1255 + }) 1256 + 1176 1257 return &DB{ 1177 1258 db, 1178 1259 logger,
+6 -186
appview/db/issues.go
··· 100 100 } 101 101 102 102 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 - issueMap := make(map[string]*models.Issue) // at-uri -> issue 103 + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue 104 104 105 105 var conditions []string 106 106 var args []any ··· 196 196 } 197 197 } 198 198 199 - atUri := issue.AtUri().String() 200 - issueMap[atUri] = &issue 199 + issueMap[issue.AtUri()] = &issue 201 200 } 202 201 203 202 // collect reverse repos ··· 229 228 // collect comments 230 229 issueAts := slices.Collect(maps.Keys(issueMap)) 231 230 232 - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 231 + comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts)) 233 232 if err != nil { 234 233 return nil, fmt.Errorf("failed to query comments: %w", err) 235 234 } 236 235 for i := range comments { 237 - issueAt := comments[i].IssueAt 236 + issueAt := comments[i].Subject 238 237 if issue, ok := issueMap[issueAt]; ok { 239 238 issue.Comments = append(issue.Comments, comments[i]) 240 239 } ··· 246 245 return nil, fmt.Errorf("failed to query labels: %w", err) 247 246 } 248 247 for issueAt, labels := range allLabels { 249 - if issue, ok := issueMap[issueAt.String()]; ok { 248 + if issue, ok := issueMap[issueAt]; ok { 250 249 issue.Labels = labels 251 250 } 252 251 } ··· 257 256 return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 257 } 259 258 for issueAt, references := range allReferencs { 260 - if issue, ok := issueMap[issueAt.String()]; ok { 259 + if issue, ok := issueMap[issueAt]; ok { 261 260 issue.References = references 262 261 } 263 262 } ··· 349 348 } 350 349 351 350 return ids, nil 352 - } 353 - 354 - func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 - result, err := tx.Exec( 356 - `insert into issue_comments ( 357 - did, 358 - rkey, 359 - issue_at, 360 - body, 361 - reply_to, 362 - created, 363 - edited 364 - ) 365 - values (?, ?, ?, ?, ?, ?, null) 366 - on conflict(did, rkey) do update set 367 - issue_at = excluded.issue_at, 368 - body = excluded.body, 369 - edited = case 370 - when 371 - issue_comments.issue_at != excluded.issue_at 372 - or issue_comments.body != excluded.body 373 - or issue_comments.reply_to != excluded.reply_to 374 - then ? 375 - else issue_comments.edited 376 - end`, 377 - c.Did, 378 - c.Rkey, 379 - c.IssueAt, 380 - c.Body, 381 - c.ReplyTo, 382 - c.Created.Format(time.RFC3339), 383 - time.Now().Format(time.RFC3339), 384 - ) 385 - if err != nil { 386 - return 0, err 387 - } 388 - 389 - id, err := result.LastInsertId() 390 - if err != nil { 391 - return 0, err 392 - } 393 - 394 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 - return 0, fmt.Errorf("put reference_links: %w", err) 396 - } 397 - 398 - return id, nil 399 - } 400 - 401 - func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 402 - var conditions []string 403 - var args []any 404 - for _, filter := range filters { 405 - conditions = append(conditions, filter.Condition()) 406 - args = append(args, filter.Arg()...) 407 - } 408 - 409 - whereClause := "" 410 - if conditions != nil { 411 - whereClause = " where " + strings.Join(conditions, " and ") 412 - } 413 - 414 - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 415 - 416 - _, err := e.Exec(query, args...) 417 - return err 418 - } 419 - 420 - func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 - commentMap := make(map[string]*models.IssueComment) 422 - 423 - var conditions []string 424 - var args []any 425 - for _, filter := range filters { 426 - conditions = append(conditions, filter.Condition()) 427 - args = append(args, filter.Arg()...) 428 - } 429 - 430 - whereClause := "" 431 - if conditions != nil { 432 - whereClause = " where " + strings.Join(conditions, " and ") 433 - } 434 - 435 - query := fmt.Sprintf(` 436 - select 437 - id, 438 - did, 439 - rkey, 440 - issue_at, 441 - reply_to, 442 - body, 443 - created, 444 - edited, 445 - deleted 446 - from 447 - issue_comments 448 - %s 449 - `, whereClause) 450 - 451 - rows, err := e.Query(query, args...) 452 - if err != nil { 453 - return nil, err 454 - } 455 - defer rows.Close() 456 - 457 - for rows.Next() { 458 - var comment models.IssueComment 459 - var created string 460 - var rkey, edited, deleted, replyTo sql.Null[string] 461 - err := rows.Scan( 462 - &comment.Id, 463 - &comment.Did, 464 - &rkey, 465 - &comment.IssueAt, 466 - &replyTo, 467 - &comment.Body, 468 - &created, 469 - &edited, 470 - &deleted, 471 - ) 472 - if err != nil { 473 - return nil, err 474 - } 475 - 476 - // this is a remnant from old times, newer comments always have rkey 477 - if rkey.Valid { 478 - comment.Rkey = rkey.V 479 - } 480 - 481 - if t, err := time.Parse(time.RFC3339, created); err == nil { 482 - comment.Created = t 483 - } 484 - 485 - if edited.Valid { 486 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 487 - comment.Edited = &t 488 - } 489 - } 490 - 491 - if deleted.Valid { 492 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 493 - comment.Deleted = &t 494 - } 495 - } 496 - 497 - if replyTo.Valid { 498 - comment.ReplyTo = &replyTo.V 499 - } 500 - 501 - atUri := comment.AtUri().String() 502 - commentMap[atUri] = &comment 503 - } 504 - 505 - if err = rows.Err(); err != nil { 506 - return nil, err 507 - } 508 - 509 - // collect references for each comments 510 - commentAts := slices.Collect(maps.Keys(commentMap)) 511 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 512 - if err != nil { 513 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 514 - } 515 - for commentAt, references := range allReferencs { 516 - if comment, ok := commentMap[commentAt.String()]; ok { 517 - comment.References = references 518 - } 519 - } 520 - 521 - var comments []models.IssueComment 522 - for _, c := range commentMap { 523 - comments = append(comments, *c) 524 - } 525 - 526 - sort.Slice(comments, func(i, j int) bool { 527 - return comments[i].Created.After(comments[j].Created) 528 - }) 529 - 530 - return comments, nil 531 351 } 532 352 533 353 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+6 -6
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 9 "tangled.org/core/appview/models" 11 10 "tangled.org/core/orm" 12 11 ) ··· 217 216 } 218 217 defer rows.Close() 219 218 220 - pipelines := make(map[syntax.ATURI]models.Pipeline) 219 + pipelines := make(map[string]models.Pipeline) 221 220 for rows.Next() { 222 221 var p models.Pipeline 223 222 var t models.Trigger ··· 254 253 p.Trigger = &t 255 254 p.Statuses = make(map[string]models.WorkflowStatus) 256 255 257 - pipelines[p.AtUri()] = p 256 + k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 257 + pipelines[k] = p 258 258 } 259 259 260 260 // get all statuses ··· 314 314 return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err) 315 315 } 316 316 317 - pipelineAt := ps.PipelineAt() 317 + key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey) 318 318 319 319 // extract 320 - pipeline, ok := pipelines[pipelineAt] 320 + pipeline, ok := pipelines[key] 321 321 if !ok { 322 322 continue 323 323 } ··· 331 331 332 332 // reassign 333 333 pipeline.Statuses[ps.Workflow] = statuses 334 - pipelines[pipelineAt] = pipeline 334 + pipelines[key] = pipeline 335 335 } 336 336 337 337 var all []models.Pipeline
+18 -11
appview/db/profile.go
··· 20 20 timeline := models.ProfileTimeline{ 21 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 22 } 23 - currentMonth := time.Now().Month() 23 + now := time.Now() 24 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 25 26 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 30 30 31 31 // group pulls by month 32 32 for _, pull := range pulls { 33 - pullMonth := pull.Created.Month() 33 + monthsAgo := monthsBetween(pull.Created, now) 34 34 35 - if currentMonth-pullMonth >= TimeframeMonths { 35 + if monthsAgo >= TimeframeMonths { 36 36 // shouldn't happen; but times are weird 37 37 continue 38 38 } 39 39 40 - idx := currentMonth - pullMonth 40 + idx := monthsAgo 41 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 42 43 43 *items = append(*items, &pull) ··· 53 53 } 54 54 55 55 for _, issue := range issues { 56 - issueMonth := issue.Created.Month() 56 + monthsAgo := monthsBetween(issue.Created, now) 57 57 58 - if currentMonth-issueMonth >= TimeframeMonths { 58 + if monthsAgo >= TimeframeMonths { 59 59 // shouldn't happen; but times are weird 60 60 continue 61 61 } 62 62 63 - idx := currentMonth - issueMonth 63 + idx := monthsAgo 64 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 65 66 66 *items = append(*items, &issue) ··· 77 77 if repo.Source != "" { 78 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 79 if err != nil { 80 - return nil, err 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 81 82 } 82 83 } 83 84 84 - repoMonth := repo.Created.Month() 85 + monthsAgo := monthsBetween(repo.Created, now) 85 86 86 - if currentMonth-repoMonth >= TimeframeMonths { 87 + if monthsAgo >= TimeframeMonths { 87 88 // shouldn't happen; but times are weird 88 89 continue 89 90 } 90 91 91 - idx := currentMonth - repoMonth 92 + idx := monthsAgo 92 93 93 94 items := &timeline.ByMonth[idx].RepoEvents 94 95 *items = append(*items, models.RepoEvent{ ··· 98 99 } 99 100 100 101 return &timeline, nil 102 + } 103 + 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 101 108 } 102 109 103 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+6 -121
appview/db/pulls.go
··· 447 447 return nil, err 448 448 } 449 449 450 - // Get comments for all submissions using GetPullComments 450 + // Get comments for all submissions using GetComments 451 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 452 - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 452 + comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) 453 453 if err != nil { 454 454 return nil, fmt.Errorf("failed to get pull comments: %w", err) 455 455 } 456 456 for _, comment := range comments { 457 - if submission, ok := submissionMap[comment.SubmissionId]; ok { 458 - submission.Comments = append(submission.Comments, comment) 457 + if comment.PullSubmissionId != nil { 458 + if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { 459 + submission.Comments = append(submission.Comments, comment) 460 + } 459 461 } 460 462 } 461 463 ··· 475 477 return m, nil 476 478 } 477 479 478 - func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 479 - var conditions []string 480 - var args []any 481 - for _, filter := range filters { 482 - conditions = append(conditions, filter.Condition()) 483 - args = append(args, filter.Arg()...) 484 - } 485 - 486 - whereClause := "" 487 - if conditions != nil { 488 - whereClause = " where " + strings.Join(conditions, " and ") 489 - } 490 - 491 - query := fmt.Sprintf(` 492 - select 493 - id, 494 - pull_id, 495 - submission_id, 496 - repo_at, 497 - owner_did, 498 - comment_at, 499 - body, 500 - created 501 - from 502 - pull_comments 503 - %s 504 - order by 505 - created asc 506 - `, whereClause) 507 - 508 - rows, err := e.Query(query, args...) 509 - if err != nil { 510 - return nil, err 511 - } 512 - defer rows.Close() 513 - 514 - commentMap := make(map[string]*models.PullComment) 515 - for rows.Next() { 516 - var comment models.PullComment 517 - var createdAt string 518 - err := rows.Scan( 519 - &comment.ID, 520 - &comment.PullId, 521 - &comment.SubmissionId, 522 - &comment.RepoAt, 523 - &comment.OwnerDid, 524 - &comment.CommentAt, 525 - &comment.Body, 526 - &createdAt, 527 - ) 528 - if err != nil { 529 - return nil, err 530 - } 531 - 532 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 533 - comment.Created = t 534 - } 535 - 536 - atUri := comment.AtUri().String() 537 - commentMap[atUri] = &comment 538 - } 539 - 540 - if err := rows.Err(); err != nil { 541 - return nil, err 542 - } 543 - 544 - // collect references for each comments 545 - commentAts := slices.Collect(maps.Keys(commentMap)) 546 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 547 - if err != nil { 548 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 549 - } 550 - for commentAt, references := range allReferencs { 551 - if comment, ok := commentMap[commentAt.String()]; ok { 552 - comment.References = references 553 - } 554 - } 555 - 556 - var comments []models.PullComment 557 - for _, c := range commentMap { 558 - comments = append(comments, *c) 559 - } 560 - 561 - sort.Slice(comments, func(i, j int) bool { 562 - return comments[i].Created.Before(comments[j].Created) 563 - }) 564 - 565 - return comments, nil 566 - } 567 - 568 480 // timeframe here is directly passed into the sql query filter, and any 569 481 // timeframe in the past should be negative; e.g.: "-3 months" 570 482 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 639 551 } 640 552 641 553 return pulls, nil 642 - } 643 - 644 - func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 645 - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 646 - res, err := tx.Exec( 647 - query, 648 - comment.OwnerDid, 649 - comment.RepoAt, 650 - comment.SubmissionId, 651 - comment.CommentAt, 652 - comment.PullId, 653 - comment.Body, 654 - ) 655 - if err != nil { 656 - return 0, err 657 - } 658 - 659 - i, err := res.LastInsertId() 660 - if err != nil { 661 - return 0, err 662 - } 663 - 664 - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 - return 0, fmt.Errorf("put reference_links: %w", err) 666 - } 667 - 668 - return i, nil 669 554 } 670 555 671 556 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
+1 -1
appview/db/punchcard.go
··· 78 78 punch.Count = int(count.Int64) 79 79 } 80 80 81 - punchcard.Punches[punch.Date.YearDay()] = punch 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 82 82 punchcard.Total += punch.Count 83 83 } 84 84
+20 -32
appview/db/reference.go
··· 11 11 "tangled.org/core/orm" 12 12 ) 13 13 14 - // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 15 15 // It will ignore missing refLinks. 16 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 17 var ( ··· 53 53 values %s 54 54 ) 55 55 select 56 - i.did, i.rkey, 57 - c.did, c.rkey 56 + i.at_uri, c.at_uri 58 57 from input inp 59 58 join repos r 60 59 on r.did = inp.owner_did ··· 62 61 join issues i 63 62 on i.repo_at = r.at_uri 64 63 and i.issue_id = inp.issue_id 65 - left join issue_comments c 64 + left join comments c 66 65 on inp.comment_id is not null 67 - and c.issue_at = i.at_uri 66 + and c.subject_at = i.at_uri 68 67 and c.id = inp.comment_id 69 68 `, 70 69 strings.Join(vals, ","), ··· 79 78 80 79 for rows.Next() { 81 80 // Scan rows 82 - var issueOwner, issueRkey string 83 - var commentOwner, commentRkey sql.NullString 81 + var issueUri string 82 + var commentUri sql.NullString 84 83 var uri syntax.ATURI 85 - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 84 + if err := rows.Scan(&issueUri, &commentUri); err != nil { 86 85 return nil, err 87 86 } 88 - if commentOwner.Valid && commentRkey.Valid { 89 - uri = syntax.ATURI(fmt.Sprintf( 90 - "at://%s/%s/%s", 91 - commentOwner.String, 92 - tangled.RepoIssueCommentNSID, 93 - commentRkey.String, 94 - )) 87 + if commentUri.Valid { 88 + uri = syntax.ATURI(commentUri.String) 95 89 } else { 96 - uri = syntax.ATURI(fmt.Sprintf( 97 - "at://%s/%s/%s", 98 - issueOwner, 99 - tangled.RepoIssueNSID, 100 - issueRkey, 101 - )) 90 + uri = syntax.ATURI(issueUri) 102 91 } 103 92 uris = append(uris, uri) 104 93 } ··· 124 113 values %s 125 114 ) 126 115 select 127 - p.owner_did, p.rkey, 128 - c.comment_at 116 + p.owner_did, p.rkey, c.at_uri 129 117 from input inp 130 118 join repos r 131 119 on r.did = inp.owner_did ··· 133 121 join pulls p 134 122 on p.repo_at = r.at_uri 135 123 and p.pull_id = inp.pull_id 136 - left join pull_comments c 124 + left join comments c 137 125 on inp.comment_id is not null 138 - and c.repo_at = r.at_uri and c.pull_id = p.pull_id 126 + and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) 139 127 and c.id = inp.comment_id 140 128 `, 141 129 strings.Join(vals, ","), ··· 283 271 return nil, fmt.Errorf("get issue backlinks: %w", err) 284 272 } 285 273 backlinks = append(backlinks, ls...) 286 - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 274 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 287 275 if err != nil { 288 276 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 289 277 } ··· 293 281 return nil, fmt.Errorf("get pull backlinks: %w", err) 294 282 } 295 283 backlinks = append(backlinks, ls...) 296 - ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 284 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 297 285 if err != nil { 298 286 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 287 } ··· 352 340 rows, err := e.Query( 353 341 fmt.Sprintf( 354 342 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 355 - from issue_comments c 343 + from comments c 356 344 join issues i 357 - on i.at_uri = c.issue_at 345 + on i.at_uri = c.subject_at 358 346 join repos r 359 347 on r.at_uri = i.repo_at 360 348 where %s`, ··· 428 416 if len(aturis) == 0 { 429 417 return nil, nil 430 418 } 431 - filter := orm.FilterIn("c.comment_at", aturis) 419 + filter := orm.FilterIn("c.at_uri", aturis) 432 420 rows, err := e.Query( 433 421 fmt.Sprintf( 434 422 `select r.did, r.name, p.pull_id, c.id, p.title, p.state 435 423 from repos r 436 424 join pulls p 437 425 on r.at_uri = p.repo_at 438 - join pull_comments c 439 - on r.at_uri = c.repo_at and p.pull_id = c.pull_id 426 + join comments c 427 + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at 440 428 where %s`, 441 429 filter.Condition(), 442 430 ),
+19 -11
appview/ingester.go
··· 79 79 err = i.ingestString(e) 80 80 case tangled.RepoIssueNSID: 81 81 err = i.ingestIssue(ctx, e) 82 - case tangled.RepoIssueCommentNSID: 83 - err = i.ingestIssueComment(e) 82 + case tangled.CommentNSID: 83 + err = i.ingestComment(e) 84 84 case tangled.LabelDefinitionNSID: 85 85 err = i.ingestLabelDefinition(e) 86 86 case tangled.LabelOpNSID: ··· 868 868 return nil 869 869 } 870 870 871 - func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 871 + func (i *Ingester) ingestComment(e *jmodels.Event) error { 872 872 did := e.Did 873 873 rkey := e.Commit.RKey 874 874 875 875 var err error 876 876 877 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 877 + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 878 878 l.Info("ingesting record") 879 879 880 880 ddb, ok := i.Db.Execer.(*db.DB) ··· 885 885 switch e.Commit.Operation { 886 886 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 887 887 raw := json.RawMessage(e.Commit.Record) 888 - record := tangled.RepoIssueComment{} 888 + record := tangled.Comment{} 889 889 err = json.Unmarshal(raw, &record) 890 890 if err != nil { 891 891 return fmt.Errorf("invalid record: %w", err) 892 892 } 893 893 894 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 894 + comment, err := models.CommentFromRecord(did, rkey, record) 895 895 if err != nil { 896 896 return fmt.Errorf("failed to parse comment from record: %w", err) 897 897 } 898 898 899 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 899 + // TODO: ingest pull comments 900 + // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. 901 + // so we cannot know which round this comment is pointing to 902 + if comment.Subject.Collection().String() == tangled.RepoPullNSID { 903 + l.Info("skip ingesting pull comments") 904 + return nil 905 + } 906 + 907 + if err := comment.Validate(); err != nil { 900 908 return fmt.Errorf("failed to validate comment: %w", err) 901 909 } 902 910 ··· 906 914 } 907 915 defer tx.Rollback() 908 916 909 - _, err = db.AddIssueComment(tx, *comment) 917 + err = db.PutComment(tx, comment) 910 918 if err != nil { 911 - return fmt.Errorf("failed to create issue comment: %w", err) 919 + return fmt.Errorf("failed to create comment: %w", err) 912 920 } 913 921 914 922 return tx.Commit() 915 923 916 924 case jmodels.CommitOperationDelete: 917 - if err := db.DeleteIssueComments( 925 + if err := db.DeleteComments( 918 926 ddb, 919 927 orm.FilterEq("did", did), 920 928 orm.FilterEq("rkey", rkey), 921 929 ); err != nil { 922 - return fmt.Errorf("failed to delete issue comment record: %w", err) 930 + return fmt.Errorf("failed to delete comment record: %w", err) 923 931 } 924 932 925 933 return nil
+31 -29
appview/issues/issues.go
··· 403 403 404 404 body := r.FormValue("body") 405 405 if body == "" { 406 - rp.pages.Notice(w, "issue", "Body is required") 406 + rp.pages.Notice(w, "issue-comment", "Body is required") 407 407 return 408 408 } 409 409 410 - replyToUri := r.FormValue("reply-to") 411 - var replyTo *string 412 - if replyToUri != "" { 413 - replyTo = &replyToUri 410 + var replyTo *syntax.ATURI 411 + replyToRaw := r.FormValue("reply-to") 412 + if replyToRaw != "" { 413 + aturi, err := syntax.ParseATURI(r.FormValue("reply-to")) 414 + if err != nil { 415 + rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 416 + return 417 + } 418 + replyTo = &aturi 414 419 } 415 420 416 421 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 422 418 - comment := models.IssueComment{ 419 - Did: user.Did, 423 + comment := models.Comment{ 424 + Did: syntax.DID(user.Did), 420 425 Rkey: tid.TID(), 421 - IssueAt: issue.AtUri().String(), 426 + Subject: issue.AtUri(), 422 427 ReplyTo: replyTo, 423 428 Body: body, 424 429 Created: time.Now(), 425 430 Mentions: mentions, 426 431 References: references, 427 432 } 428 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 433 + if err = comment.Validate(); err != nil { 429 434 l.Error("failed to validate comment", "err", err) 430 435 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 431 436 return ··· 441 446 442 447 // create a record first 443 448 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 444 - Collection: tangled.RepoIssueCommentNSID, 445 - Repo: comment.Did, 449 + Collection: tangled.CommentNSID, 450 + Repo: user.Did, 446 451 Rkey: comment.Rkey, 447 452 Record: &lexutil.LexiconTypeDecoder{ 448 453 Val: &record, ··· 468 473 } 469 474 defer tx.Rollback() 470 475 471 - commentId, err := db.AddIssueComment(tx, comment) 476 + err = db.PutComment(tx, &comment) 472 477 if err != nil { 473 478 l.Error("failed to create comment", "err", err) 474 479 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 484 489 // reset atUri to make rollback a no-op 485 490 atUri = "" 486 491 487 - // notify about the new comment 488 - comment.Id = commentId 489 - 490 - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 492 + rp.notifier.NewComment(r.Context(), &comment) 491 493 492 494 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 493 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 495 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 494 496 } 495 497 496 498 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 505 507 } 506 508 507 509 commentId := chi.URLParam(r, "commentId") 508 - comments, err := db.GetIssueComments( 510 + comments, err := db.GetComments( 509 511 rp.db, 510 512 orm.FilterEq("id", commentId), 511 513 ) ··· 541 543 } 542 544 543 545 commentId := chi.URLParam(r, "commentId") 544 - comments, err := db.GetIssueComments( 546 + comments, err := db.GetComments( 545 547 rp.db, 546 548 orm.FilterEq("id", commentId), 547 549 ) ··· 557 559 } 558 560 comment := comments[0] 559 561 560 - if comment.Did != user.Did { 562 + if comment.Did.String() != user.Did { 561 563 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 562 564 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 565 return ··· 597 599 } 598 600 defer tx.Rollback() 599 601 600 - _, err = db.AddIssueComment(tx, newComment) 602 + err = db.PutComment(tx, &newComment) 601 603 if err != nil { 602 604 l.Error("failed to perferom update-description query", "err", err) 603 605 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 608 610 // rkey is optional, it was introduced later 609 611 if newComment.Rkey != "" { 610 612 // update the record on pds 611 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 613 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey) 612 614 if err != nil { 613 615 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 614 616 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 616 618 } 617 619 618 620 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 - Collection: tangled.RepoIssueCommentNSID, 621 + Collection: tangled.CommentNSID, 620 622 Repo: user.Did, 621 623 Rkey: newComment.Rkey, 622 624 SwapRecord: ex.Cid, ··· 651 653 } 652 654 653 655 commentId := chi.URLParam(r, "commentId") 654 - comments, err := db.GetIssueComments( 656 + comments, err := db.GetComments( 655 657 rp.db, 656 658 orm.FilterEq("id", commentId), 657 659 ) ··· 687 689 } 688 690 689 691 commentId := chi.URLParam(r, "commentId") 690 - comments, err := db.GetIssueComments( 692 + comments, err := db.GetComments( 691 693 rp.db, 692 694 orm.FilterEq("id", commentId), 693 695 ) ··· 723 725 } 724 726 725 727 commentId := chi.URLParam(r, "commentId") 726 - comments, err := db.GetIssueComments( 728 + comments, err := db.GetComments( 727 729 rp.db, 728 730 orm.FilterEq("id", commentId), 729 731 ) ··· 739 741 } 740 742 comment := comments[0] 741 743 742 - if comment.Did != user.Did { 744 + if comment.Did.String() != user.Did { 743 745 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 744 746 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 745 747 return ··· 752 754 753 755 // optimistic deletion 754 756 deleted := time.Now() 755 - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 757 + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 756 758 if err != nil { 757 759 l.Error("failed to delete comment", "err", err) 758 760 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 768 770 return 769 771 } 770 772 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 771 - Collection: tangled.RepoIssueCommentNSID, 773 + Collection: tangled.CommentNSID, 772 774 Repo: user.Did, 773 775 Rkey: comment.Rkey, 774 776 })
-5
appview/knots/knots.go
··· 666 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 667 return 668 668 } 669 - if memberId.Handle.IsInvalidHandle() { 670 - l.Error("failed to resolve member identity to handle") 671 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 672 - return 673 - } 674 669 675 670 // remove from enforcer 676 671 err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+4
appview/middleware/middleware.go
··· 223 223 ) 224 224 if err != nil { 225 225 log.Println("failed to resolve repo", "err", err) 226 + w.WriteHeader(http.StatusNotFound) 226 227 mw.pages.ErrorKnot404(w) 227 228 return 228 229 } ··· 240 241 f, err := mw.repoResolver.Resolve(r) 241 242 if err != nil { 242 243 log.Println("failed to fully resolve repo", err) 244 + w.WriteHeader(http.StatusNotFound) 243 245 mw.pages.ErrorKnot404(w) 244 246 return 245 247 } ··· 288 290 f, err := mw.repoResolver.Resolve(r) 289 291 if err != nil { 290 292 log.Println("failed to fully resolve repo", err) 293 + w.WriteHeader(http.StatusNotFound) 291 294 mw.pages.ErrorKnot404(w) 292 295 return 293 296 } ··· 324 327 f, err := mw.repoResolver.Resolve(r) 325 328 if err != nil { 326 329 log.Println("failed to fully resolve repo", err) 330 + w.WriteHeader(http.StatusNotFound) 327 331 mw.pages.ErrorKnot404(w) 328 332 return 329 333 }
+117
appview/models/comment.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Comment struct { 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + Subject syntax.ATURI 17 + ReplyTo *syntax.ATURI 18 + Body string 19 + Created time.Time 20 + Edited *time.Time 21 + Deleted *time.Time 22 + Mentions []syntax.DID 23 + References []syntax.ATURI 24 + PullSubmissionId *int 25 + } 26 + 27 + func (c *Comment) AtUri() syntax.ATURI { 28 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.CommentNSID, c.Rkey)) 29 + } 30 + 31 + func (c *Comment) AsRecord() tangled.Comment { 32 + mentions := make([]string, len(c.Mentions)) 33 + for i, did := range c.Mentions { 34 + mentions[i] = string(did) 35 + } 36 + references := make([]string, len(c.References)) 37 + for i, uri := range c.References { 38 + references[i] = string(uri) 39 + } 40 + var replyTo *string 41 + if c.ReplyTo != nil { 42 + replyToStr := c.ReplyTo.String() 43 + replyTo = &replyToStr 44 + } 45 + return tangled.Comment{ 46 + Subject: c.Subject.String(), 47 + Body: c.Body, 48 + CreatedAt: c.Created.Format(time.RFC3339), 49 + ReplyTo: replyTo, 50 + Mentions: mentions, 51 + References: references, 52 + } 53 + } 54 + 55 + func (c *Comment) IsTopLevel() bool { 56 + return c.ReplyTo == nil 57 + } 58 + 59 + func (c *Comment) IsReply() bool { 60 + return c.ReplyTo != nil 61 + } 62 + 63 + func (c *Comment) Validate() error { 64 + // TODO: sanitize the body and then trim space 65 + if sb := strings.TrimSpace(c.Body); sb == "" { 66 + return fmt.Errorf("body is empty after HTML sanitization") 67 + } 68 + 69 + // if it's for PR, PullSubmissionId should not be nil 70 + if c.Subject.Collection().String() == tangled.RepoPullNSID { 71 + if c.PullSubmissionId == nil { 72 + return fmt.Errorf("PullSubmissionId should not be nil") 73 + } 74 + } 75 + return nil 76 + } 77 + 78 + func CommentFromRecord(did, rkey string, record tangled.Comment) (*Comment, error) { 79 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 80 + if err != nil { 81 + created = time.Now() 82 + } 83 + 84 + ownerDid := did 85 + 86 + if _, err = syntax.ParseATURI(record.Subject); err != nil { 87 + return nil, err 88 + } 89 + 90 + i := record 91 + mentions := make([]syntax.DID, len(record.Mentions)) 92 + for i, did := range record.Mentions { 93 + mentions[i] = syntax.DID(did) 94 + } 95 + references := make([]syntax.ATURI, len(record.References)) 96 + for i, uri := range i.References { 97 + references[i] = syntax.ATURI(uri) 98 + } 99 + var replyTo *syntax.ATURI 100 + if record.ReplyTo != nil { 101 + replyToAtUri := syntax.ATURI(*record.ReplyTo) 102 + replyTo = &replyToAtUri 103 + } 104 + 105 + comment := Comment{ 106 + Did: syntax.DID(ownerDid), 107 + Rkey: rkey, 108 + Body: record.Body, 109 + Subject: syntax.ATURI(record.Subject), 110 + ReplyTo: replyTo, 111 + Created: created, 112 + Mentions: mentions, 113 + References: references, 114 + } 115 + 116 + return &comment, nil 117 + }
+8 -89
appview/models/issue.go
··· 26 26 27 27 // optionally, populate this when querying for reverse mappings 28 28 // like comment counts, parent repo etc. 29 - Comments []IssueComment 29 + Comments []Comment 30 30 Labels LabelState 31 31 Repo *Repo 32 32 } ··· 62 62 } 63 63 64 64 type CommentListItem struct { 65 - Self *IssueComment 66 - Replies []*IssueComment 65 + Self *Comment 66 + Replies []*Comment 67 67 } 68 68 69 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 88 89 89 func (i *Issue) CommentList() []CommentListItem { 90 90 // Create a map to quickly find comments by their aturi 91 - toplevel := make(map[string]*CommentListItem) 92 - var replies []*IssueComment 91 + toplevel := make(map[syntax.ATURI]*CommentListItem) 92 + var replies []*Comment 93 93 94 94 // collect top level comments into the map 95 95 for _, comment := range i.Comments { 96 96 if comment.IsTopLevel() { 97 - toplevel[comment.AtUri().String()] = &CommentListItem{ 97 + toplevel[comment.AtUri()] = &CommentListItem{ 98 98 Self: &comment, 99 99 } 100 100 } else { ··· 115 115 } 116 116 117 117 // sort everything 118 - sortFunc := func(a, b *IssueComment) bool { 118 + sortFunc := func(a, b *Comment) bool { 119 119 return a.Created.Before(b.Created) 120 120 } 121 121 sort.Slice(listing, func(i, j int) bool { ··· 144 144 addParticipant(i.Did) 145 145 146 146 for _, c := range i.Comments { 147 - addParticipant(c.Did) 147 + addParticipant(c.Did.String()) 148 148 } 149 149 150 150 return participants ··· 171 171 Open: true, // new issues are open by default 172 172 } 173 173 } 174 - 175 - type IssueComment struct { 176 - Id int64 177 - Did string 178 - Rkey string 179 - IssueAt string 180 - ReplyTo *string 181 - Body string 182 - Created time.Time 183 - Edited *time.Time 184 - Deleted *time.Time 185 - Mentions []syntax.DID 186 - References []syntax.ATURI 187 - } 188 - 189 - func (i *IssueComment) AtUri() syntax.ATURI { 190 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 191 - } 192 - 193 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 194 - mentions := make([]string, len(i.Mentions)) 195 - for i, did := range i.Mentions { 196 - mentions[i] = string(did) 197 - } 198 - references := make([]string, len(i.References)) 199 - for i, uri := range i.References { 200 - references[i] = string(uri) 201 - } 202 - return tangled.RepoIssueComment{ 203 - Body: i.Body, 204 - Issue: i.IssueAt, 205 - CreatedAt: i.Created.Format(time.RFC3339), 206 - ReplyTo: i.ReplyTo, 207 - Mentions: mentions, 208 - References: references, 209 - } 210 - } 211 - 212 - func (i *IssueComment) IsTopLevel() bool { 213 - return i.ReplyTo == nil 214 - } 215 - 216 - func (i *IssueComment) IsReply() bool { 217 - return i.ReplyTo != nil 218 - } 219 - 220 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 221 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 222 - if err != nil { 223 - created = time.Now() 224 - } 225 - 226 - ownerDid := did 227 - 228 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 229 - return nil, err 230 - } 231 - 232 - i := record 233 - mentions := make([]syntax.DID, len(record.Mentions)) 234 - for i, did := range record.Mentions { 235 - mentions[i] = syntax.DID(did) 236 - } 237 - references := make([]syntax.ATURI, len(record.References)) 238 - for i, uri := range i.References { 239 - references[i] = syntax.ATURI(uri) 240 - } 241 - 242 - comment := IssueComment{ 243 - Did: ownerDid, 244 - Rkey: rkey, 245 - Body: record.Body, 246 - IssueAt: record.Issue, 247 - ReplyTo: record.ReplyTo, 248 - Created: created, 249 - Mentions: mentions, 250 - References: references, 251 - } 252 - 253 - return &comment, nil 254 - }
-10
appview/models/pipeline.go
··· 1 1 package models 2 2 3 3 import ( 4 - "fmt" 5 4 "slices" 6 5 "time" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/go-git/go-git/v5/plumbing" 10 - "tangled.org/core/api/tangled" 11 9 spindle "tangled.org/core/spindle/models" 12 10 "tangled.org/core/workflow" 13 11 ) ··· 25 23 // populate when querying for reverse mappings 26 24 Trigger *Trigger 27 25 Statuses map[string]WorkflowStatus 28 - } 29 - 30 - func (p *Pipeline) AtUri() syntax.ATURI { 31 - return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) 32 26 } 33 27 34 28 type WorkflowStatus struct { ··· 134 128 Error *string 135 129 ExitCode int 136 130 } 137 - 138 - func (ps *PipelineStatus) PipelineAt() syntax.ATURI { 139 - return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey)) 140 - }
+3 -47
appview/models/pull.go
··· 83 83 Repo *Repo 84 84 } 85 85 86 + // NOTE: This method does not include patch blob in returned atproto record 86 87 func (p Pull) AsRecord() tangled.RepoPull { 87 88 var source *tangled.RepoPull_Source 88 89 if p.PullSource != nil { ··· 113 114 Repo: p.RepoAt.String(), 114 115 Branch: p.TargetBranch, 115 116 }, 116 - Patch: p.LatestPatch(), 117 117 Source: source, 118 118 } 119 119 return record ··· 138 138 RoundNumber int 139 139 Patch string 140 140 Combined string 141 - Comments []PullComment 141 + Comments []Comment 142 142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 143 143 144 144 // meta 145 145 Created time.Time 146 146 } 147 147 148 - type PullComment struct { 149 - // ids 150 - ID int 151 - PullId int 152 - SubmissionId int 153 - 154 - // at ids 155 - RepoAt string 156 - OwnerDid string 157 - CommentAt string 158 - 159 - // content 160 - Body string 161 - 162 - // meta 163 - Mentions []syntax.DID 164 - References []syntax.ATURI 165 - 166 - // meta 167 - Created time.Time 168 - } 169 - 170 - func (p *PullComment) AtUri() syntax.ATURI { 171 - return syntax.ATURI(p.CommentAt) 172 - } 173 - 174 - // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 - // mentions := make([]string, len(p.Mentions)) 176 - // for i, did := range p.Mentions { 177 - // mentions[i] = string(did) 178 - // } 179 - // references := make([]string, len(p.References)) 180 - // for i, uri := range p.References { 181 - // references[i] = string(uri) 182 - // } 183 - // return tangled.RepoPullComment{ 184 - // Pull: p.PullAt, 185 - // Body: p.Body, 186 - // Mentions: mentions, 187 - // References: references, 188 - // CreatedAt: p.Created.Format(time.RFC3339), 189 - // } 190 - // } 191 - 192 148 func (p *Pull) LastRoundNumber() int { 193 149 return len(p.Submissions) - 1 194 150 } ··· 289 245 addParticipant(s.PullAt.Authority().String()) 290 246 291 247 for _, c := range s.Comments { 292 - addParticipant(c.OwnerDid) 248 + addParticipant(c.Did.String()) 293 249 } 294 250 295 251 return participants
+111 -113
appview/notify/db/db.go
··· 74 74 // no-op 75 75 } 76 76 77 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 77 + func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment) { 78 + var ( 79 + // built the recipients list: 80 + // - the owner of the repo 81 + // - | if the comment is a reply -> everybody on that thread 82 + // | if the comment is a top level -> just the issue owner 83 + // - remove mentioned users from the recipients list 84 + recipients = sets.New[syntax.DID]() 85 + entityType string 86 + entityId string 87 + repoId *int64 88 + issueId *int64 89 + pullId *int64 90 + ) 91 + 92 + subjectDid, err := comment.Subject.Authority().AsDID() 79 93 if err != nil { 80 - log.Printf("failed to fetch collaborators: %v", err) 94 + log.Printf("NewComment: expected did based at-uri for comment.subject") 81 95 return 82 96 } 97 + switch comment.Subject.Collection() { 98 + case tangled.RepoIssueNSID: 99 + issues, err := db.GetIssues( 100 + n.db, 101 + orm.FilterEq("did", subjectDid), 102 + orm.FilterEq("rkey", comment.Subject.RecordKey()), 103 + ) 104 + if err != nil { 105 + log.Printf("NewComment: failed to get issues: %v", err) 106 + return 107 + } 108 + if len(issues) == 0 { 109 + log.Printf("NewComment: no issue found for %s", comment.Subject) 110 + return 111 + } 112 + issue := issues[0] 83 113 84 - // build the recipients list 85 - // - owner of the repo 86 - // - collaborators in the repo 87 - // - remove users already mentioned 88 - recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 - for _, c := range collaborators { 90 - recipients.Insert(c.SubjectDid) 114 + recipients.Insert(syntax.DID(issue.Repo.Did)) 115 + if comment.IsReply() { 116 + // if this comment is a reply, then notify everybody in that thread 117 + parentAtUri := *comment.ReplyTo 118 + 119 + // find the parent thread, and add all DIDs from here to the recipient list 120 + for _, t := range issue.CommentList() { 121 + if t.Self.AtUri() == parentAtUri { 122 + for _, p := range t.Participants() { 123 + recipients.Insert(p) 124 + } 125 + } 126 + } 127 + } else { 128 + // not a reply, notify just the issue author 129 + recipients.Insert(syntax.DID(issue.Did)) 130 + } 131 + 132 + entityType = "issue" 133 + entityId = issue.AtUri().String() 134 + repoId = &issue.Repo.Id 135 + issueId = &issue.Id 136 + case tangled.RepoPullNSID: 137 + pulls, err := db.GetPullsWithLimit( 138 + n.db, 139 + 1, 140 + orm.FilterEq("owner_did", subjectDid), 141 + orm.FilterEq("rkey", comment.Subject.RecordKey()), 142 + ) 143 + if err != nil { 144 + log.Printf("NewComment: failed to get pulls: %v", err) 145 + return 146 + } 147 + if len(pulls) == 0 { 148 + log.Printf("NewComment: no pull found for %s", comment.Subject) 149 + return 150 + } 151 + pull := pulls[0] 152 + 153 + pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 154 + if err != nil { 155 + log.Printf("NewComment: failed to get repos: %v", err) 156 + return 157 + } 158 + 159 + recipients.Insert(syntax.DID(pull.Repo.Did)) 160 + for _, p := range pull.Participants() { 161 + recipients.Insert(syntax.DID(p)) 162 + } 163 + 164 + entityType = "pull" 165 + entityId = pull.AtUri().String() 166 + repoId = &pull.Repo.Id 167 + p := int64(pull.ID) 168 + pullId = &p 169 + default: 170 + return // no-op 91 171 } 92 - for _, m := range mentions { 172 + 173 + for _, m := range comment.Mentions { 93 174 recipients.Remove(m) 94 175 } 95 176 96 - actorDid := syntax.DID(issue.Did) 97 - entityType := "issue" 98 - entityId := issue.AtUri().String() 99 - repoId := &issue.Repo.Id 100 - issueId := &issue.Id 101 - var pullId *int64 102 - 103 177 n.notifyEvent( 104 - actorDid, 178 + comment.Did, 105 179 recipients, 106 - models.NotificationTypeIssueCreated, 180 + models.NotificationTypeIssueCommented, 107 181 entityType, 108 182 entityId, 109 183 repoId, ··· 111 185 pullId, 112 186 ) 113 187 n.notifyEvent( 114 - actorDid, 115 - sets.Collect(slices.Values(mentions)), 188 + comment.Did, 189 + sets.Collect(slices.Values(comment.Mentions)), 116 190 models.NotificationTypeUserMentioned, 117 191 entityType, 118 192 entityId, ··· 122 196 ) 123 197 } 124 198 125 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 126 - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 199 + func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 200 + // no-op 201 + } 202 + 203 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 204 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 127 205 if err != nil { 128 - log.Printf("NewIssueComment: failed to get issues: %v", err) 206 + log.Printf("failed to fetch collaborators: %v", err) 129 207 return 130 208 } 131 - if len(issues) == 0 { 132 - log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 133 - return 134 - } 135 - issue := issues[0] 136 209 137 - // built the recipients list: 138 - // - the owner of the repo 139 - // - | if the comment is a reply -> everybody on that thread 140 - // | if the comment is a top level -> just the issue owner 141 - // - remove mentioned users from the recipients list 210 + // build the recipients list 211 + // - owner of the repo 212 + // - collaborators in the repo 213 + // - remove users already mentioned 142 214 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 143 - 144 - if comment.IsReply() { 145 - // if this comment is a reply, then notify everybody in that thread 146 - parentAtUri := *comment.ReplyTo 147 - 148 - // find the parent thread, and add all DIDs from here to the recipient list 149 - for _, t := range issue.CommentList() { 150 - if t.Self.AtUri().String() == parentAtUri { 151 - for _, p := range t.Participants() { 152 - recipients.Insert(p) 153 - } 154 - } 155 - } 156 - } else { 157 - // not a reply, notify just the issue author 158 - recipients.Insert(syntax.DID(issue.Did)) 215 + for _, c := range collaborators { 216 + recipients.Insert(c.SubjectDid) 159 217 } 160 - 161 218 for _, m := range mentions { 162 219 recipients.Remove(m) 163 220 } 164 221 165 - actorDid := syntax.DID(comment.Did) 222 + actorDid := syntax.DID(issue.Did) 166 223 entityType := "issue" 167 224 entityId := issue.AtUri().String() 168 225 repoId := &issue.Repo.Id ··· 172 229 n.notifyEvent( 173 230 actorDid, 174 231 recipients, 175 - models.NotificationTypeIssueCommented, 232 + models.NotificationTypeIssueCreated, 176 233 entityType, 177 234 entityId, 178 235 repoId, ··· 252 309 actorDid, 253 310 recipients, 254 311 eventType, 255 - entityType, 256 - entityId, 257 - repoId, 258 - issueId, 259 - pullId, 260 - ) 261 - } 262 - 263 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 264 - pull, err := db.GetPull(n.db, 265 - syntax.ATURI(comment.RepoAt), 266 - comment.PullId, 267 - ) 268 - if err != nil { 269 - log.Printf("NewPullComment: failed to get pulls: %v", err) 270 - return 271 - } 272 - 273 - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 274 - if err != nil { 275 - log.Printf("NewPullComment: failed to get repos: %v", err) 276 - return 277 - } 278 - 279 - // build up the recipients list: 280 - // - repo owner 281 - // - all pull participants 282 - // - remove those already mentioned 283 - recipients := sets.Singleton(syntax.DID(repo.Did)) 284 - for _, p := range pull.Participants() { 285 - recipients.Insert(syntax.DID(p)) 286 - } 287 - for _, m := range mentions { 288 - recipients.Remove(m) 289 - } 290 - 291 - actorDid := syntax.DID(comment.OwnerDid) 292 - eventType := models.NotificationTypePullCommented 293 - entityType := "pull" 294 - entityId := pull.AtUri().String() 295 - repoId := &repo.Id 296 - var issueId *int64 297 - p := int64(pull.ID) 298 - pullId := &p 299 - 300 - n.notifyEvent( 301 - actorDid, 302 - recipients, 303 - eventType, 304 - entityType, 305 - entityId, 306 - repoId, 307 - issueId, 308 - pullId, 309 - ) 310 - n.notifyEvent( 311 - actorDid, 312 - sets.Collect(slices.Values(mentions)), 313 - models.NotificationTypeUserMentioned, 314 312 entityType, 315 313 entityId, 316 314 repoId,
+8 -8
appview/notify/merged_notifier.go
··· 53 53 m.fanout("DeleteStar", ctx, star) 54 54 } 55 55 56 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 57 - m.fanout("NewIssue", ctx, issue, mentions) 56 + func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment) { 57 + m.fanout("NewComment", ctx, comment) 58 58 } 59 59 60 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 61 - m.fanout("NewIssueComment", ctx, comment, mentions) 60 + func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 61 + m.fanout("DeleteComment", ctx, comment) 62 + } 63 + 64 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 65 + m.fanout("NewIssue", ctx, issue, mentions) 62 66 } 63 67 64 68 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 79 83 80 84 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 81 85 m.fanout("NewPull", ctx, pull) 82 - } 83 - 84 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 - m.fanout("NewPullComment", ctx, comment, mentions) 86 86 } 87 87 88 88 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+7 -7
appview/notify/notifier.go
··· 13 13 NewStar(ctx context.Context, star *models.Star) 14 14 DeleteStar(ctx context.Context, star *models.Star) 15 15 16 + NewComment(ctx context.Context, comment *models.Comment) 17 + DeleteComment(ctx context.Context, comment *models.Comment) 18 + 16 19 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 20 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 21 DeleteIssue(ctx context.Context, issue *models.Issue) 20 22 ··· 22 24 DeleteFollow(ctx context.Context, follow *models.Follow) 23 25 24 26 NewPull(ctx context.Context, pull *models.Pull) 25 - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 27 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 27 28 28 29 UpdateProfile(ctx context.Context, profile *models.Profile) ··· 42 43 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 43 44 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 45 46 + func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {} 47 + func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} 48 + 45 49 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 - } 48 50 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 51 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 50 52 51 53 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 52 54 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 53 55 54 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 - } 56 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 57 57 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 58 58 59 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+5 -20
appview/notify/posthog/notifier.go
··· 86 86 } 87 87 } 88 88 89 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 - err := n.client.Enqueue(posthog.Capture{ 91 - DistinctId: comment.OwnerDid, 92 - Event: "new_pull_comment", 93 - Properties: posthog.Properties{ 94 - "repo_at": comment.RepoAt, 95 - "pull_id": comment.PullId, 96 - "mentions": mentions, 97 - }, 98 - }) 99 - if err != nil { 100 - log.Println("failed to enqueue posthog event:", err) 101 - } 102 - } 103 - 104 89 func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 105 90 err := n.client.Enqueue(posthog.Capture{ 106 91 DistinctId: pull.OwnerDid, ··· 180 165 } 181 166 } 182 167 183 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 168 + func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) { 184 169 err := n.client.Enqueue(posthog.Capture{ 185 - DistinctId: comment.Did, 186 - Event: "new_issue_comment", 170 + DistinctId: comment.Did.String(), 171 + Event: "new_comment", 187 172 Properties: posthog.Properties{ 188 - "issue_at": comment.IssueAt, 189 - "mentions": mentions, 173 + "subject_at": comment.Subject, 174 + "mentions": comment.Mentions, 190 175 }, 191 176 }) 192 177 if err != nil {
+4 -4
appview/pages/pages.go
··· 988 988 LoggedInUser *oauth.User 989 989 RepoInfo repoinfo.RepoInfo 990 990 Issue *models.Issue 991 - Comment *models.IssueComment 991 + Comment *models.Comment 992 992 } 993 993 994 994 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 999 999 LoggedInUser *oauth.User 1000 1000 RepoInfo repoinfo.RepoInfo 1001 1001 Issue *models.Issue 1002 - Comment *models.IssueComment 1002 + Comment *models.Comment 1003 1003 } 1004 1004 1005 1005 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1010 1010 LoggedInUser *oauth.User 1011 1011 RepoInfo repoinfo.RepoInfo 1012 1012 Issue *models.Issue 1013 - Comment *models.IssueComment 1013 + Comment *models.Comment 1014 1014 } 1015 1015 1016 1016 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1021 1021 LoggedInUser *oauth.User 1022 1022 RepoInfo repoinfo.RepoInfo 1023 1023 Issue *models.Issue 1024 - Comment *models.IssueComment 1024 + Comment *models.Comment 1025 1025 } 1026 1026 1027 1027 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+1 -1
appview/pages/templates/repo/fragments/diff.html
··· 17 17 {{ else }} 18 18 {{ range $idx, $hunk := $diff }} 19 19 {{ with $hunk }} 20 - <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 20 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 21 <summary class="list-none cursor-pointer sticky top-0"> 22 22 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 23 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+35 -35
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 13 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 14 14 {{- range .LeftLines -}} 15 15 {{- if .IsEmpty -}} 16 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 - </div> 16 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 18 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 19 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 20 + </span> 21 21 {{- else if eq .Op.String "-" -}} 22 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 - <div class="px-2">{{ .Content }}</div> 26 - </div> 22 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 24 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 25 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 26 + </span> 27 27 {{- else if eq .Op.String " " -}} 28 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 - <div class="px-2">{{ .Content }}</div> 32 - </div> 28 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 30 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 31 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 32 + </span> 33 33 {{- end -}} 34 34 {{- end -}} 35 - {{- end -}}</div></div></pre> 35 + {{- end -}}</div></div></div> 36 36 37 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 37 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 38 38 {{- range .RightLines -}} 39 39 {{- if .IsEmpty -}} 40 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 - </div> 40 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 42 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 43 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 44 + </span> 45 45 {{- else if eq .Op.String "+" -}} 46 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 - <div class="px-2" >{{ .Content }}</div> 50 - </div> 46 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span> 48 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 49 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 50 + </span> 51 51 {{- else if eq .Op.String " " -}} 52 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 - <div class="px-2">{{ .Content }}</div> 56 - </div> 52 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span> 54 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 55 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 56 + </span> 57 57 {{- end -}} 58 58 {{- end -}} 59 - {{- end -}}</div></div></pre> 59 + {{- end -}}</div></div></div> 60 60 </div> 61 61 {{ end }}
+21 -22
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 1 {{ define "repo/fragments/unifiedDiff" }} 2 2 {{ $name := .Id }} 3 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 3 + <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 4 {{- $oldStart := .OldPosition -}} 5 5 {{- $newStart := .NewPosition -}} 6 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 8 {{- $lineNrSepStyle1 := "" -}} 9 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 15 {{- range .Lines -}} 16 16 {{- if eq .Op.String "+" -}} 17 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 - <div class="px-2">{{ .Line }}</div> 22 - </div> 17 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span> 19 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span> 20 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 21 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 22 + </span> 23 23 {{- $newStart = add64 $newStart 1 -}} 24 24 {{- end -}} 25 25 {{- if eq .Op.String "-" -}} 26 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 - <div class="px-2">{{ .Line }}</div> 31 - </div> 26 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span> 28 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span> 29 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 30 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 31 + </span> 32 32 {{- $oldStart = add64 $oldStart 1 -}} 33 33 {{- end -}} 34 34 {{- if eq .Op.String " " -}} 35 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 - <div class="px-2">{{ .Line }}</div> 40 - </div> 35 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span> 37 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span> 38 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 39 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 40 + </span> 41 41 {{- $newStart = add64 $newStart 1 -}} 42 42 {{- $oldStart = add64 $oldStart 1 -}} 43 43 {{- end -}} 44 44 {{- end -}} 45 - {{- end -}}</div></div></pre> 45 + {{- end -}}</div></div></div> 46 46 {{ end }} 47 -
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did }} 3 + {{ template "user/fragments/picHandleLink" .Comment.Did.String }} 4 4 {{ template "hats" $ }} 5 5 {{ template "timestamp" . }} 6 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 7 7 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 8 {{ template "editIssueComment" . }} 9 9 {{ template "deleteIssueComment" . }}
-14
appview/pages/templates/repo/pipelines/workflow.html
··· 12 12 {{ block "sidebar" . }} {{ end }} 13 13 </div> 14 14 <div class="col-span-1 md:col-span-3"> 15 - <!-- TODO(boltless): explictly check for pipeline cancel permission --> 16 - {{ if $.RepoInfo.Roles.IsOwner }} 17 - <div class="flex justify-between mb-2"> 18 - <div id="workflow-error" class="text-red-500 dark:text-red-400"></div> 19 - <button 20 - class="btn" 21 - hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 22 - hx-swap="none" 23 - {{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}} 24 - disabled 25 - {{- end }} 26 - >Cancel</button> 27 - </div> 28 - {{ end }} 29 15 {{ block "logs" . }} {{ end }} 30 16 </div> 31 17 </section>
+3 -3
appview/pages/templates/repo/pulls/pull.html
··· 165 165 166 166 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 167 {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 168 + <div id="comment-{{$c.Id}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 169 {{ if gt $cidx 0 }} 170 170 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 171 {{ end }} 172 172 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 173 + {{ template "user/fragments/picHandleLink" $c.Did.String }} 174 174 <span class="before:content-['ยท']"></span> 175 - <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> 175 + <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> 176 176 </div> 177 177 <div class="prose dark:prose-invert"> 178 178 {{ $c.Body | markdown }}
+1 -86
appview/pipelines/pipelines.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 - "fmt" 8 7 "log/slog" 9 8 "net/http" 10 9 "strings" 11 10 "time" 12 11 13 - "tangled.org/core/api/tangled" 14 12 "tangled.org/core/appview/config" 15 13 "tangled.org/core/appview/db" 16 - "tangled.org/core/appview/middleware" 17 - "tangled.org/core/appview/models" 18 14 "tangled.org/core/appview/oauth" 19 15 "tangled.org/core/appview/pages" 20 16 "tangled.org/core/appview/reporesolver" ··· 40 36 logger *slog.Logger 41 37 } 42 38 43 - func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 39 + func (p *Pipelines) Router() http.Handler { 44 40 r := chi.NewRouter() 45 41 r.Get("/", p.Index) 46 42 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 47 43 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 48 - r. 49 - With(mw.RepoPermissionMiddleware("repo:owner")). 50 - Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel) 51 44 52 45 return r 53 46 } ··· 321 314 } 322 315 } 323 316 } 324 - } 325 - 326 - func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) { 327 - l := p.logger.With("handler", "Cancel") 328 - 329 - var ( 330 - pipelineId = chi.URLParam(r, "pipeline") 331 - workflow = chi.URLParam(r, "workflow") 332 - ) 333 - if pipelineId == "" || workflow == "" { 334 - http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest) 335 - return 336 - } 337 - 338 - f, err := p.repoResolver.Resolve(r) 339 - if err != nil { 340 - l.Error("failed to get repo and knot", "err", err) 341 - http.Error(w, "bad repo/knot", http.StatusBadRequest) 342 - return 343 - } 344 - 345 - pipeline, err := func() (models.Pipeline, error) { 346 - ps, err := db.GetPipelineStatuses( 347 - p.db, 348 - 1, 349 - orm.FilterEq("repo_owner", f.Did), 350 - orm.FilterEq("repo_name", f.Name), 351 - orm.FilterEq("knot", f.Knot), 352 - orm.FilterEq("id", pipelineId), 353 - ) 354 - if err != nil { 355 - return models.Pipeline{}, err 356 - } 357 - if len(ps) != 1 { 358 - return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps)) 359 - } 360 - return ps[0], nil 361 - }() 362 - if err != nil { 363 - l.Error("pipeline query failed", "err", err) 364 - http.Error(w, "pipeline not found", http.StatusNotFound) 365 - } 366 - var ( 367 - spindle = f.Spindle 368 - knot = f.Knot 369 - rkey = pipeline.Rkey 370 - ) 371 - 372 - if spindle == "" || knot == "" || rkey == "" { 373 - http.Error(w, "invalid repo info", http.StatusBadRequest) 374 - return 375 - } 376 - 377 - spindleClient, err := p.oauth.ServiceClient( 378 - r, 379 - oauth.WithService(f.Spindle), 380 - oauth.WithLxm(tangled.PipelineCancelPipelineNSID), 381 - oauth.WithDev(p.config.Core.Dev), 382 - oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time 383 - ) 384 - 385 - err = tangled.PipelineCancelPipeline( 386 - r.Context(), 387 - spindleClient, 388 - &tangled.PipelineCancelPipeline_Input{ 389 - Repo: string(f.RepoAt()), 390 - Pipeline: pipeline.AtUri().String(), 391 - Workflow: workflow, 392 - }, 393 - ) 394 - err = fmt.Errorf("boo! new error") 395 - errorId := "workflow-error" 396 - if err != nil { 397 - l.Error("failed to cancel workflow", "err", err) 398 - p.pages.Notice(w, errorId, "Failed to cancel workflow") 399 - return 400 - } 401 - l.Debug("canceled pipeline", "uri", pipeline.AtUri()) 402 317 } 403 318 404 319 // either a message or an error
+1 -1
appview/pulls/opengraph.go
··· 277 277 } 278 278 279 279 // Get comment count from database 280 - comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 280 + comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri())) 281 281 if err != nil { 282 282 log.Printf("failed to get pull comments: %v", err) 283 283 }
+72 -59
appview/pulls/pulls.go
··· 741 741 } 742 742 defer tx.Rollback() 743 743 744 - createdAt := time.Now().Format(time.RFC3339) 744 + comment := models.Comment{ 745 + Did: syntax.DID(user.Did), 746 + Rkey: tid.TID(), 747 + Subject: pull.AtUri(), 748 + ReplyTo: nil, 749 + Body: body, 750 + Created: time.Now(), 751 + Mentions: mentions, 752 + References: references, 753 + PullSubmissionId: &pull.Submissions[roundNumber].ID, 754 + } 755 + if err = comment.Validate(); err != nil { 756 + log.Println("failed to validate comment", err) 757 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 758 + return 759 + } 760 + record := comment.AsRecord() 745 761 746 762 client, err := s.oauth.AuthorizedClient(r) 747 763 if err != nil { ··· 749 765 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 750 766 return 751 767 } 752 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 753 - Collection: tangled.RepoPullCommentNSID, 768 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 769 + Collection: tangled.CommentNSID, 754 770 Repo: user.Did, 755 - Rkey: tid.TID(), 771 + Rkey: comment.Rkey, 756 772 Record: &lexutil.LexiconTypeDecoder{ 757 - Val: &tangled.RepoPullComment{ 758 - Pull: pull.AtUri().String(), 759 - Body: body, 760 - CreatedAt: createdAt, 761 - }, 773 + Val: &record, 762 774 }, 763 775 }) 764 776 if err != nil { ··· 767 779 return 768 780 } 769 781 770 - comment := &models.PullComment{ 771 - OwnerDid: user.Did, 772 - RepoAt: f.RepoAt().String(), 773 - PullId: pull.PullId, 774 - Body: body, 775 - CommentAt: atResp.Uri, 776 - SubmissionId: pull.Submissions[roundNumber].ID, 777 - Mentions: mentions, 778 - References: references, 779 - } 780 - 781 782 // Create the pull comment in the database with the commentAt field 782 - commentId, err := db.NewPullComment(tx, comment) 783 + err = db.PutComment(tx, &comment) 783 784 if err != nil { 784 785 log.Println("failed to create pull comment", err) 785 786 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 793 794 return 794 795 } 795 796 796 - s.notifier.NewPullComment(r.Context(), comment, mentions) 797 + s.notifier.NewComment(r.Context(), &comment) 797 798 798 799 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 799 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 800 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 800 801 return 801 802 } 802 803 } ··· 1241 1242 return 1242 1243 } 1243 1244 1245 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1246 + if err != nil { 1247 + log.Println("failed to upload patch", err) 1248 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1249 + return 1250 + } 1251 + 1244 1252 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1245 1253 Collection: tangled.RepoPullNSID, 1246 1254 Repo: user.Did, ··· 1252 1260 Repo: string(repo.RepoAt()), 1253 1261 Branch: targetBranch, 1254 1262 }, 1255 - Patch: patch, 1263 + PatchBlob: blob.Blob, 1256 1264 Source: recordPullSource, 1257 1265 CreatedAt: time.Now().Format(time.RFC3339), 1258 1266 }, ··· 1328 1336 // apply all record creations at once 1329 1337 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1330 1338 for _, p := range stack { 1339 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch())) 1340 + if err != nil { 1341 + log.Println("failed to upload patch blob", err) 1342 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1343 + return 1344 + } 1345 + 1331 1346 record := p.AsRecord() 1332 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1347 + record.PatchBlob = blob.Blob 1348 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1333 1349 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1334 1350 Collection: tangled.RepoPullNSID, 1335 1351 Rkey: &p.Rkey, ··· 1337 1353 Val: &record, 1338 1354 }, 1339 1355 }, 1340 - } 1341 - writes = append(writes, &write) 1356 + }) 1342 1357 } 1343 1358 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1344 1359 Repo: user.Did, ··· 1871 1886 return 1872 1887 } 1873 1888 1874 - var recordPullSource *tangled.RepoPull_Source 1875 - if pull.IsBranchBased() { 1876 - recordPullSource = &tangled.RepoPull_Source{ 1877 - Branch: pull.PullSource.Branch, 1878 - Sha: sourceRev, 1879 - } 1889 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1890 + if err != nil { 1891 + log.Println("failed to upload patch blob", err) 1892 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1893 + return 1880 1894 } 1881 - if pull.IsForkBased() { 1882 - repoAt := pull.PullSource.RepoAt.String() 1883 - recordPullSource = &tangled.RepoPull_Source{ 1884 - Branch: pull.PullSource.Branch, 1885 - Repo: &repoAt, 1886 - Sha: sourceRev, 1887 - } 1888 - } 1895 + record := pull.AsRecord() 1896 + record.PatchBlob = blob.Blob 1897 + record.CreatedAt = time.Now().Format(time.RFC3339) 1889 1898 1890 1899 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1891 1900 Collection: tangled.RepoPullNSID, ··· 1893 1902 Rkey: pull.Rkey, 1894 1903 SwapRecord: ex.Cid, 1895 1904 Record: &lexutil.LexiconTypeDecoder{ 1896 - Val: &tangled.RepoPull{ 1897 - Title: pull.Title, 1898 - Target: &tangled.RepoPull_Target{ 1899 - Repo: string(repo.RepoAt()), 1900 - Branch: pull.TargetBranch, 1901 - }, 1902 - Patch: patch, // new patch 1903 - Source: recordPullSource, 1904 - CreatedAt: time.Now().Format(time.RFC3339), 1905 - }, 1905 + Val: &record, 1906 1906 }, 1907 1907 }) 1908 1908 if err != nil { ··· 1988 1988 } 1989 1989 defer tx.Rollback() 1990 1990 1991 + client, err := s.oauth.AuthorizedClient(r) 1992 + if err != nil { 1993 + log.Println("failed to authorize client") 1994 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1995 + return 1996 + } 1997 + 1991 1998 // pds updates to make 1992 1999 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1993 2000 ··· 2021 2028 return 2022 2029 } 2023 2030 2031 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2032 + if err != nil { 2033 + log.Println("failed to upload patch blob", err) 2034 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2035 + return 2036 + } 2024 2037 record := p.AsRecord() 2038 + record.PatchBlob = blob.Blob 2025 2039 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2026 2040 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2027 2041 Collection: tangled.RepoPullNSID, ··· 2056 2070 return 2057 2071 } 2058 2072 2073 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2074 + if err != nil { 2075 + log.Println("failed to upload patch blob", err) 2076 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2077 + return 2078 + } 2059 2079 record := np.AsRecord() 2060 - 2080 + record.PatchBlob = blob.Blob 2061 2081 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2062 2082 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2063 2083 Collection: tangled.RepoPullNSID, ··· 2091 2111 if err != nil { 2092 2112 log.Println("failed to resubmit pull", err) 2093 2113 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2094 - return 2095 - } 2096 - 2097 - client, err := s.oauth.AuthorizedClient(r) 2098 - if err != nil { 2099 - log.Println("failed to authorize client") 2100 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2101 2114 return 2102 2115 } 2103 2116
+1
appview/repo/archive.go
··· 18 18 l := rp.logger.With("handler", "DownloadArchive") 19 19 ref := chi.URLParam(r, "ref") 20 20 ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 21 22 f, err := rp.repoResolver.Resolve(r) 22 23 if err != nil { 23 24 l.Error("failed to get repo and knot", "err", err)
+26 -1
appview/reporesolver/resolver.go
··· 63 63 } 64 64 65 65 // get dir/ref 66 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 67 ref := chi.URLParam(r, "ref") 68 68 69 69 repoAt := repo.RepoAt() ··· 130 130 } 131 131 132 132 return repoInfo 133 + } 134 + 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 146 + } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 133 158 } 134 159 135 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
-5
appview/spindles/spindles.go
··· 653 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 654 return 655 655 } 656 - if memberId.Handle.IsInvalidHandle() { 657 - l.Error("failed to resolve member identity to handle") 658 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 659 - return 660 - } 661 656 662 657 tx, err := s.Db.Begin() 663 658 if err != nil {
+86
appview/state/knotstream.go
··· 18 18 "tangled.org/core/log" 19 19 "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 + "tangled.org/core/workflow" 21 22 23 + "github.com/bluesky-social/indigo/atproto/syntax" 22 24 "github.com/go-git/go-git/v5/plumbing" 23 25 "github.com/posthog/posthog-go" 24 26 ) ··· 65 67 switch msg.Nsid { 66 68 case tangled.GitRefUpdateNSID: 67 69 return ingestRefUpdate(d, enforcer, posthog, dev, source, msg) 70 + case tangled.PipelineNSID: 71 + return ingestPipeline(d, source, msg) 68 72 } 69 73 70 74 return nil ··· 186 190 187 191 return tx.Commit() 188 192 } 193 + 194 + func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { 195 + var record tangled.Pipeline 196 + err := json.Unmarshal(msg.EventJson, &record) 197 + if err != nil { 198 + return err 199 + } 200 + 201 + if record.TriggerMetadata == nil { 202 + return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 203 + } 204 + 205 + if record.TriggerMetadata.Repo == nil { 206 + return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 207 + } 208 + 209 + // does this repo have a spindle configured? 210 + repos, err := db.GetRepos( 211 + d, 212 + 0, 213 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 215 + ) 216 + if err != nil { 217 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 218 + } 219 + if len(repos) != 1 { 220 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 221 + } 222 + if repos[0].Spindle == "" { 223 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 224 + } 225 + 226 + // trigger info 227 + var trigger models.Trigger 228 + var sha string 229 + trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 230 + switch trigger.Kind { 231 + case workflow.TriggerKindPush: 232 + trigger.PushRef = &record.TriggerMetadata.Push.Ref 233 + trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 234 + trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 235 + sha = *trigger.PushNewSha 236 + case workflow.TriggerKindPullRequest: 237 + trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 238 + trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 239 + trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 240 + trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 241 + sha = *trigger.PRSourceSha 242 + } 243 + 244 + tx, err := d.Begin() 245 + if err != nil { 246 + return fmt.Errorf("failed to start txn: %w", err) 247 + } 248 + 249 + triggerId, err := db.AddTrigger(tx, trigger) 250 + if err != nil { 251 + return fmt.Errorf("failed to add trigger entry: %w", err) 252 + } 253 + 254 + pipeline := models.Pipeline{ 255 + Rkey: msg.Rkey, 256 + Knot: source.Key(), 257 + RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 258 + RepoName: record.TriggerMetadata.Repo.Repo, 259 + TriggerId: int(triggerId), 260 + Sha: sha, 261 + } 262 + 263 + err = db.AddPipeline(tx, pipeline) 264 + if err != nil { 265 + return fmt.Errorf("failed to add pipeline: %w", err) 266 + } 267 + 268 + err = tx.Commit() 269 + if err != nil { 270 + return fmt.Errorf("failed to commit txn: %w", err) 271 + } 272 + 273 + return nil 274 + }
+6 -4
appview/state/profile.go
··· 163 163 } 164 164 165 165 // populate commit counts in the timeline, using the punchcard 166 - currentMonth := time.Now().Month() 166 + now := time.Now() 167 167 for _, p := range profile.Punchcard.Punches { 168 - idx := currentMonth - p.Date.Month() 169 - if int(idx) < len(timeline.ByMonth) { 170 - timeline.ByMonth[idx].Commits += p.Count 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 171 173 } 172 174 } 173 175
+5 -3
appview/state/router.go
··· 96 96 r.Mount("/", s.RepoRouter(mw)) 97 97 r.Mount("/issues", s.IssuesRouter(mw)) 98 98 r.Mount("/pulls", s.PullsRouter(mw)) 99 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 99 + r.Mount("/pipelines", s.PipelinesRouter()) 100 100 r.Mount("/labels", s.LabelsRouter()) 101 101 102 102 // These routes get proxied to the knot ··· 109 109 }) 110 110 111 111 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 112 + w.WriteHeader(http.StatusNotFound) 112 113 s.pages.Error404(w) 113 114 }) 114 115 ··· 182 183 r.Get("/brand", s.Brand) 183 184 184 185 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 186 + w.WriteHeader(http.StatusNotFound) 185 187 s.pages.Error404(w) 186 188 }) 187 189 return r ··· 313 315 return repo.Router(mw) 314 316 } 315 317 316 - func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 318 + func (s *State) PipelinesRouter() http.Handler { 317 319 pipes := pipelines.New( 318 320 s.oauth, 319 321 s.repoResolver, ··· 325 327 s.enforcer, 326 328 log.SubLogger(s.logger, "pipelines"), 327 329 ) 328 - return pipes.Router(mw) 330 + return pipes.Router() 329 331 } 330 332 331 333 func (s *State) LabelsRouter() http.Handler {
-89
appview/state/spindlestream.go
··· 20 20 "tangled.org/core/orm" 21 21 "tangled.org/core/rbac" 22 22 spindle "tangled.org/core/spindle/models" 23 - "tangled.org/core/workflow" 24 23 ) 25 24 26 25 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 63 62 func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc { 64 63 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 65 64 switch msg.Nsid { 66 - case tangled.PipelineNSID: 67 - return ingestPipeline(logger, d, source, msg) 68 65 case tangled.PipelineStatusNSID: 69 66 return ingestPipelineStatus(ctx, logger, d, source, msg) 70 67 } 71 68 72 69 return nil 73 70 } 74 - } 75 - 76 - func ingestPipeline(l *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error { 77 - var record tangled.Pipeline 78 - err := json.Unmarshal(msg.EventJson, &record) 79 - if err != nil { 80 - return err 81 - } 82 - 83 - if record.TriggerMetadata == nil { 84 - return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 85 - } 86 - 87 - if record.TriggerMetadata.Repo == nil { 88 - return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 89 - } 90 - 91 - // does this repo have a spindle configured? 92 - repos, err := db.GetRepos( 93 - d, 94 - 0, 95 - orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 96 - orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 97 - ) 98 - if err != nil { 99 - return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 100 - } 101 - if len(repos) != 1 { 102 - return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 103 - } 104 - if repos[0].Spindle == "" { 105 - return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 106 - } 107 - 108 - // trigger info 109 - var trigger models.Trigger 110 - var sha string 111 - trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 112 - switch trigger.Kind { 113 - case workflow.TriggerKindPush: 114 - trigger.PushRef = &record.TriggerMetadata.Push.Ref 115 - trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 116 - trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 117 - sha = *trigger.PushNewSha 118 - case workflow.TriggerKindPullRequest: 119 - trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 120 - trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 121 - trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 122 - trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 123 - sha = *trigger.PRSourceSha 124 - } 125 - 126 - tx, err := d.Begin() 127 - if err != nil { 128 - return fmt.Errorf("failed to start txn: %w", err) 129 - } 130 - 131 - triggerId, err := db.AddTrigger(tx, trigger) 132 - if err != nil { 133 - return fmt.Errorf("failed to add trigger entry: %w", err) 134 - } 135 - 136 - // TODO: we shouldn't even use knot to identify pipelines 137 - knot := record.TriggerMetadata.Repo.Knot 138 - pipeline := models.Pipeline{ 139 - Rkey: msg.Rkey, 140 - Knot: knot, 141 - RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 142 - RepoName: record.TriggerMetadata.Repo.Repo, 143 - TriggerId: int(triggerId), 144 - Sha: sha, 145 - } 146 - 147 - err = db.AddPipeline(tx, pipeline) 148 - if err != nil { 149 - return fmt.Errorf("failed to add pipeline: %w", err) 150 - } 151 - 152 - err = tx.Commit() 153 - if err != nil { 154 - return fmt.Errorf("failed to commit txn: %w", err) 155 - } 156 - 157 - l.Info("added pipeline", "pipeline", pipeline) 158 - 159 - return nil 160 71 } 161 72 162 73 func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
+1 -1
appview/state/state.go
··· 117 117 tangled.SpindleNSID, 118 118 tangled.StringNSID, 119 119 tangled.RepoIssueNSID, 120 - tangled.RepoIssueCommentNSID, 120 + tangled.CommentNSID, 121 121 tangled.LabelDefinitionNSID, 122 122 tangled.LabelOpNSID, 123 123 },
-27
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 - "tangled.org/core/appview/db" 8 7 "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 8 ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 9 37 10 func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 11 if issue.Title == "" {
+1
cmd/cborgen/cborgen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 + tangled.Comment{}, 18 19 tangled.FeedReaction{}, 19 20 tangled.FeedStar{}, 20 21 tangled.GitRefUpdate{},
+23 -25
docs/DOCS.md
··· 2 2 title: Tangled docs 3 3 author: The Tangled Contributors 4 4 date: 21 Sun, Dec 2025 5 - --- 6 - 7 - # Introduction 8 - 9 - Tangled is a decentralized code hosting and collaboration 10 - platform. Every component of Tangled is open-source and 11 - self-hostable. [tangled.org](https://tangled.org) also 12 - provides hosting and CI services that are free to use. 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 13 10 14 - There are several models for decentralized code 15 - collaboration platforms, ranging from ActivityPubโ€™s 16 - (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 17 - Our approach attempts to be the best of both worlds by 18 - adopting the AT Protocolโ€”a protocol for building decentralized 19 - social applications with a central identity 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 20 17 21 - Our approach to this is the idea of โ€œknotsโ€. Knots are 22 - lightweight, headless servers that enable users to host Git 23 - repositories with ease. Knots are designed for either single 24 - or multi-tenant use which is perfect for self-hosting on a 25 - Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 26 - default, Tangled provides managed knots where you can host 27 - your repositories for free. 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 28 25 29 - The appview at tangled.org acts as a consolidated "view" 30 - into the whole network, allowing users to access, clone and 31 - contribute to repositories hosted across different knots 32 - seamlessly. 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 33 31 34 32 # Quick start guide 35 33
+3
docs/mode.html
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
+74 -35
docs/template.html
··· 37 37 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 38 39 39 </head> 40 - <body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen"> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 41 $for(include-before)$ 42 42 $include-before$ 43 43 $endfor$ 44 44 45 45 $if(toc)$ 46 - <!-- mobile topbar toc --> 47 - <details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4"> 48 - <summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white"> 46 + <!-- mobile TOC trigger --> 47 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 48 + <button 49 + type="button" 50 + popovertarget="mobile-toc-popover" 51 + popovertargetaction="toggle" 52 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 53 + > 54 + ${ menu.svg() } 49 55 $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 50 - <span class="group-open:hidden inline">${ menu.svg() }</span> 51 - <span class="hidden group-open:inline">${ x.svg() }</span> 52 - </summary> 53 - ${ table-of-contents:toc.html() } 54 - </details> 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="mobile-toc-popover" 61 + popover 62 + class="mobile-toc-popover 63 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 64 + h-full overflow-y-auto shadow-sm 65 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 66 + > 67 + <div class="flex flex-col min-h-full"> 68 + <div class="flex-1 space-y-4"> 69 + <button 70 + type="button" 71 + popovertarget="mobile-toc-popover" 72 + popovertargetaction="toggle" 73 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 74 + ${ x.svg() } 75 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 + </button> 77 + ${ search.html() } 78 + ${ table-of-contents:toc.html() } 79 + </div> 80 + ${ single-page:mode.html() } 81 + </div> 82 + </div> 83 + 55 84 <!-- desktop sidebar toc --> 56 - <nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50"> 57 - $if(toc-title)$ 58 - <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 59 - $endif$ 60 - ${ table-of-contents:toc.html() } 85 + <nav 86 + id="$idprefix$TOC" 87 + role="doc-toc" 88 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 89 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 90 + p-4 z-50 overflow-y-auto"> 91 + ${ search.html() } 92 + <div class="flex-1"> 93 + $if(toc-title)$ 94 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 95 + $endif$ 96 + ${ table-of-contents:toc.html() } 97 + </div> 98 + ${ single-page:mode.html() } 61 99 </nav> 62 100 $endif$ 63 101 64 102 <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 65 103 <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 66 104 $if(top)$ 67 - $-- only print title block if this is NOT the top page 105 + $-- only print title block if this is NOT the top page 68 106 $else$ 69 107 $if(title)$ 70 - <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 71 - <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 72 - $if(subtitle)$ 73 - <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 74 - $endif$ 75 - $for(author)$ 76 - <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 77 - $endfor$ 78 - $if(date)$ 79 - <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 80 - $endif$ 81 - $if(abstract)$ 82 - <div class="mt-6 p-4 bg-gray-50 rounded-lg"> 83 - <div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div> 84 - <div class="text-gray-700">$abstract$</div> 85 - </div> 86 - $endif$ 87 - $endif$ 88 - </header> 108 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 109 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 110 + $if(subtitle)$ 111 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 112 + $endif$ 113 + $for(author)$ 114 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 115 + $endfor$ 116 + $if(date)$ 117 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 118 + $endif$ 119 + $endif$ 120 + </header> 121 + $endif$ 122 + 123 + $if(abstract)$ 124 + <article class="prose dark:prose-invert max-w-none"> 125 + $abstract$ 126 + </article> 89 127 $endif$ 128 + 90 129 <article class="prose dark:prose-invert max-w-none"> 91 130 $body$ 92 131 </article> 93 132 </main> 94 - <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 "> 133 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 95 134 <div class="max-w-4xl mx-auto px-8 py-4"> 96 135 <div class="flex justify-between gap-4"> 97 136 <span class="flex-1">
+3 -32
flake.nix
··· 76 76 }; 77 77 buildGoApplication = 78 78 (self.callPackage "${gomod2nix}/builder" { 79 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 80 80 }).buildGoApplication; 81 81 modules = ./nix/gomod2nix.toml; 82 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { ··· 94 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 95 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 96 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 - did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 98 - bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 99 - bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 100 - tap = self.callPackage ./nix/pkgs/tap.nix {}; 101 97 }); 102 98 in { 103 99 overlays.default = final: prev: { 104 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs did-method-plc bluesky-jetstream bluesky-relay tap; 100 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs; 105 101 }; 106 102 107 103 packages = forAllSystems (system: let ··· 110 106 staticPackages = mkPackageSet pkgs.pkgsStatic; 111 107 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 112 108 in { 113 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs did-method-plc bluesky-jetstream bluesky-relay tap; 109 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs; 114 110 115 111 pkgsStatic-appview = staticPackages.appview; 116 112 pkgsStatic-knot = staticPackages.knot; ··· 309 305 imports = [./nix/modules/spindle.nix]; 310 306 311 307 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 312 - services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap; 313 - }; 314 - nixosModules.did-method-plc = { 315 - lib, 316 - pkgs, 317 - ... 318 - }: { 319 - imports = [./nix/modules/did-method-plc.nix]; 320 - services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 321 - }; 322 - nixosModules.bluesky-relay = { 323 - lib, 324 - pkgs, 325 - ... 326 - }: { 327 - imports = [./nix/modules/bluesky-relay.nix]; 328 - services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 329 - }; 330 - nixosModules.bluesky-jetstream = { 331 - lib, 332 - pkgs, 333 - ... 334 - }: { 335 - imports = [./nix/modules/bluesky-jetstream.nix]; 336 - services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 337 308 }; 338 309 }; 339 310 }
-1
go.mod
··· 29 29 github.com/gorilla/feeds v1.2.0 30 30 github.com/gorilla/sessions v1.4.0 31 31 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 32 - github.com/hashicorp/go-version v1.8.0 33 32 github.com/hiddeco/sshsig v0.2.0 34 33 github.com/hpcloud/tail v1.0.0 35 34 github.com/ipfs/go-cid v0.5.0
-2
go.sum
··· 264 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 - github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 - github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 269 267 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 270 268 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 271 269 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+1
input.css
··· 255 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 256 } 257 257 } 258 + 258 259 } 259 260 260 261 /* Background */
+136
knotserver/ingester.go
··· 7 7 "io" 8 8 "net/http" 9 9 "net/url" 10 + "path/filepath" 10 11 "strings" 11 12 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 16 17 securejoin "github.com/cyphar/filepath-securejoin" 17 18 "tangled.org/core/api/tangled" 18 19 "tangled.org/core/knotserver/db" 20 + "tangled.org/core/knotserver/git" 19 21 "tangled.org/core/log" 20 22 "tangled.org/core/rbac" 23 + "tangled.org/core/workflow" 21 24 ) 22 25 23 26 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 82 85 return nil 83 86 } 84 87 88 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 89 + raw := json.RawMessage(event.Commit.Record) 90 + did := event.Did 91 + 92 + var record tangled.RepoPull 93 + if err := json.Unmarshal(raw, &record); err != nil { 94 + return fmt.Errorf("failed to unmarshal record: %w", err) 95 + } 96 + 97 + l := log.FromContext(ctx) 98 + l = l.With("handler", "processPull") 99 + l = l.With("did", did) 100 + 101 + if record.Target == nil { 102 + return fmt.Errorf("ignoring pull record: target repo is nil") 103 + } 104 + 105 + l = l.With("target_repo", record.Target.Repo) 106 + l = l.With("target_branch", record.Target.Branch) 107 + 108 + if record.Source == nil { 109 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 110 + } 111 + 112 + if record.Source.Repo != nil { 113 + return fmt.Errorf("ignoring pull record: fork based pull") 114 + } 115 + 116 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 117 + if err != nil { 118 + return fmt.Errorf("failed to parse ATURI: %w", err) 119 + } 120 + 121 + // resolve this aturi to extract the repo record 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 + if err != nil || ident.Handle.IsInvalidHandle() { 124 + return fmt.Errorf("failed to resolve handle: %w", err) 125 + } 126 + 127 + xrpcc := xrpc.Client{ 128 + Host: ident.PDSEndpoint(), 129 + } 130 + 131 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 132 + if err != nil { 133 + return fmt.Errorf("failed to resolver repo: %w", err) 134 + } 135 + 136 + repo := resp.Value.Val.(*tangled.Repo) 137 + 138 + if repo.Knot != h.c.Server.Hostname { 139 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 140 + } 141 + 142 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 143 + if err != nil { 144 + return fmt.Errorf("failed to construct relative repo path: %w", err) 145 + } 146 + 147 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 148 + if err != nil { 149 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 150 + } 151 + 152 + gr, err := git.Open(repoPath, record.Source.Sha) 153 + if err != nil { 154 + return fmt.Errorf("failed to open git repository: %w", err) 155 + } 156 + 157 + workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 158 + if err != nil { 159 + return fmt.Errorf("failed to open workflow directory: %w", err) 160 + } 161 + 162 + var pipeline workflow.RawPipeline 163 + for _, e := range workflowDir { 164 + if !e.IsFile() { 165 + continue 166 + } 167 + 168 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 169 + contents, err := gr.RawContent(fpath) 170 + if err != nil { 171 + continue 172 + } 173 + 174 + pipeline = append(pipeline, workflow.RawWorkflow{ 175 + Name: e.Name, 176 + Contents: contents, 177 + }) 178 + } 179 + 180 + trigger := tangled.Pipeline_PullRequestTriggerData{ 181 + Action: "create", 182 + SourceBranch: record.Source.Branch, 183 + SourceSha: record.Source.Sha, 184 + TargetBranch: record.Target.Branch, 185 + } 186 + 187 + compiler := workflow.Compiler{ 188 + Trigger: tangled.Pipeline_TriggerMetadata{ 189 + Kind: string(workflow.TriggerKindPullRequest), 190 + PullRequest: &trigger, 191 + Repo: &tangled.Pipeline_TriggerRepo{ 192 + Did: ident.DID.String(), 193 + Knot: repo.Knot, 194 + Repo: repo.Name, 195 + }, 196 + }, 197 + } 198 + 199 + cp := compiler.Compile(compiler.Parse(pipeline)) 200 + eventJson, err := json.Marshal(cp) 201 + if err != nil { 202 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 203 + } 204 + 205 + // do not run empty pipelines 206 + if cp.Workflows == nil { 207 + return nil 208 + } 209 + 210 + ev := db.Event{ 211 + Rkey: TID(), 212 + Nsid: tangled.PipelineNSID, 213 + EventJson: string(eventJson), 214 + } 215 + 216 + return h.db.InsertEvent(ev, h.n) 217 + } 218 + 85 219 // duplicated from add collaborator 86 220 func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 87 221 raw := json.RawMessage(event.Commit.Record) ··· 204 338 err = h.processPublicKey(ctx, event) 205 339 case tangled.KnotMemberNSID: 206 340 err = h.processKnotMember(ctx, event) 341 + case tangled.RepoPullNSID: 342 + err = h.processPull(ctx, event) 207 343 case tangled.RepoCollaboratorNSID: 208 344 err = h.processCollaborator(ctx, event) 209 345 }
+109 -1
knotserver/internal.go
··· 23 23 "tangled.org/core/log" 24 24 "tangled.org/core/notifier" 25 25 "tangled.org/core/rbac" 26 + "tangled.org/core/workflow" 26 27 ) 27 28 28 29 type InternalHandle struct { ··· 175 176 } 176 177 177 178 for _, line := range lines { 178 - // TODO: pass pushOptions to refUpdate 179 179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 180 180 if err != nil { 181 181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 185 185 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 186 if err != nil { 187 187 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 + // non-fatal 189 + } 190 + 191 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 192 + if err != nil { 193 + l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 194 // non-fatal 189 195 } 190 196 } ··· 235 241 } 236 242 237 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244 + } 245 + 246 + func (h *InternalHandle) triggerPipeline( 247 + clientMsgs *[]string, 248 + line git.PostReceiveLine, 249 + gitUserDid string, 250 + repoDid string, 251 + repoName string, 252 + pushOptions PushOptions, 253 + ) error { 254 + if pushOptions.skipCi { 255 + return nil 256 + } 257 + 258 + didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 259 + if err != nil { 260 + return err 261 + } 262 + 263 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 264 + if err != nil { 265 + return err 266 + } 267 + 268 + gr, err := git.Open(repoPath, line.Ref) 269 + if err != nil { 270 + return err 271 + } 272 + 273 + workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 274 + if err != nil { 275 + return err 276 + } 277 + 278 + var pipeline workflow.RawPipeline 279 + for _, e := range workflowDir { 280 + if !e.IsFile() { 281 + continue 282 + } 283 + 284 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 285 + contents, err := gr.RawContent(fpath) 286 + if err != nil { 287 + continue 288 + } 289 + 290 + pipeline = append(pipeline, workflow.RawWorkflow{ 291 + Name: e.Name, 292 + Contents: contents, 293 + }) 294 + } 295 + 296 + trigger := tangled.Pipeline_PushTriggerData{ 297 + Ref: line.Ref, 298 + OldSha: line.OldSha.String(), 299 + NewSha: line.NewSha.String(), 300 + } 301 + 302 + compiler := workflow.Compiler{ 303 + Trigger: tangled.Pipeline_TriggerMetadata{ 304 + Kind: string(workflow.TriggerKindPush), 305 + Push: &trigger, 306 + Repo: &tangled.Pipeline_TriggerRepo{ 307 + Did: repoDid, 308 + Knot: h.c.Server.Hostname, 309 + Repo: repoName, 310 + }, 311 + }, 312 + } 313 + 314 + cp := compiler.Compile(compiler.Parse(pipeline)) 315 + eventJson, err := json.Marshal(cp) 316 + if err != nil { 317 + return err 318 + } 319 + 320 + for _, e := range compiler.Diagnostics.Errors { 321 + *clientMsgs = append(*clientMsgs, e.String()) 322 + } 323 + 324 + if pushOptions.verboseCi { 325 + if compiler.Diagnostics.IsEmpty() { 326 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 327 + } 328 + 329 + for _, w := range compiler.Diagnostics.Warnings { 330 + *clientMsgs = append(*clientMsgs, w.String()) 331 + } 332 + } 333 + 334 + // do not run empty pipelines 335 + if cp.Workflows == nil { 336 + return nil 337 + } 338 + 339 + event := db.Event{ 340 + Rkey: TID(), 341 + Nsid: tangled.PipelineNSID, 342 + EventJson: string(eventJson), 343 + } 344 + 345 + return h.db.InsertEvent(event, h.n) 238 346 } 239 347 240 348 func (h *InternalHandle) emitCompareLink(
+1
knotserver/server.go
··· 79 79 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 80 80 tangled.PublicKeyNSID, 81 81 tangled.KnotMemberNSID, 82 + tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 84 }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil {
+51
lexicons/comment/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.comment", 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 + "body", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "body": { 23 + "type": "string" 24 + }, 25 + "createdAt": { 26 + "type": "string", 27 + "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 + }, 33 + "mentions": { 34 + "type": "array", 35 + "items": { 36 + "type": "string", 37 + "format": "did" 38 + } 39 + }, 40 + "references": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "at-uri" 45 + } 46 + } 47 + } 48 + } 49 + } 50 + } 51 + }
-33
lexicons/pipeline/cancelPipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline.cancelPipeline", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Cancel a running pipeline", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["repo", "pipeline", "workflow"], 13 - "properties": { 14 - "repo": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "repo at-uri, spindle can't resolve repo from pipeline at-uri yet" 18 - }, 19 - "pipeline": { 20 - "type": "string", 21 - "format": "at-uri", 22 - "description": "pipeline at-uri" 23 - }, 24 - "workflow": { 25 - "type": "string", 26 - "description": "workflow name" 27 - } 28 - } 29 - } 30 - } 31 - } 32 - } 33 - }
+10 -2
lexicons/pulls/pull.json
··· 12 12 "required": [ 13 13 "target", 14 14 "title", 15 - "patch", 15 + "patchBlob", 16 16 "createdAt" 17 17 ], 18 18 "properties": { ··· 27 27 "type": "string" 28 28 }, 29 29 "patch": { 30 - "type": "string" 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 31 39 }, 32 40 "source": { 33 41 "type": "ref",
-3
nix/gomod2nix.toml
··· 304 304 [mod."github.com/hashicorp/go-sockaddr"] 305 305 version = "v1.0.7" 306 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 - [mod."github.com/hashicorp/go-version"] 308 - version = "v1.8.0" 309 - hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 310 307 [mod."github.com/hashicorp/golang-lru"] 311 308 version = "v1.0.2" 312 309 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
-64
nix/modules/bluesky-jetstream.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.bluesky-jetstream; 8 - in 9 - with lib; { 10 - options.services.bluesky-jetstream = { 11 - enable = mkEnableOption "jetstream server"; 12 - package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 - 14 - # dataDir = mkOption { 15 - # type = types.str; 16 - # default = "/var/lib/jetstream"; 17 - # description = "directory to store data (pebbleDB)"; 18 - # }; 19 - livenessTtl = mkOption { 20 - type = types.int; 21 - default = 15; 22 - description = "time to restart when no event detected (seconds)"; 23 - }; 24 - websocketUrl = mkOption { 25 - type = types.str; 26 - default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 - description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 - }; 29 - }; 30 - config = mkIf cfg.enable { 31 - systemd.services.bluesky-jetstream = { 32 - description = "bluesky jetstream"; 33 - after = ["network.target" "pds.service"]; 34 - wantedBy = ["multi-user.target"]; 35 - 36 - serviceConfig = { 37 - User = "jetstream"; 38 - Group = "jetstream"; 39 - StateDirectory = "jetstream"; 40 - StateDirectoryMode = "0755"; 41 - # preStart = '' 42 - # mkdir -p "${cfg.dataDir}" 43 - # chown -R jetstream:jetstream "${cfg.dataDir}" 44 - # ''; 45 - # WorkingDirectory = cfg.dataDir; 46 - Environment = [ 47 - "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 - "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 - "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 - ]; 51 - ExecStart = getExe cfg.package; 52 - Restart = "always"; 53 - RestartSec = 5; 54 - }; 55 - }; 56 - users = { 57 - users.jetstream = { 58 - group = "jetstream"; 59 - isSystemUser = true; 60 - }; 61 - groups.jetstream = {}; 62 - }; 63 - }; 64 - }
-48
nix/modules/bluesky-relay.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.bluesky-relay; 8 - in 9 - with lib; { 10 - options.services.bluesky-relay = { 11 - enable = mkEnableOption "relay server"; 12 - package = mkPackageOption pkgs "bluesky-relay" {}; 13 - }; 14 - config = mkIf cfg.enable { 15 - systemd.services.bluesky-relay = { 16 - description = "bluesky relay"; 17 - after = ["network.target" "pds.service"]; 18 - wantedBy = ["multi-user.target"]; 19 - 20 - serviceConfig = { 21 - User = "relay"; 22 - Group = "relay"; 23 - StateDirectory = "relay"; 24 - StateDirectoryMode = "0755"; 25 - Environment = [ 26 - "RELAY_ADMIN_PASSWORD=password" 27 - "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 - "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 - "RELAY_IP_BIND=:2470" 30 - "RELAY_PERSIST_DIR=/var/lib/relay" 31 - "RELAY_DISABLE_REQUEST_CRAWL=0" 32 - "RELAY_INITIAL_SEQ_NUMBER=1" 33 - "RELAY_ALLOW_INSECURE_HOSTS=1" 34 - ]; 35 - ExecStart = "${getExe cfg.package} serve"; 36 - Restart = "always"; 37 - RestartSec = 5; 38 - }; 39 - }; 40 - users = { 41 - users.relay = { 42 - group = "relay"; 43 - isSystemUser = true; 44 - }; 45 - groups.relay = {}; 46 - }; 47 - }; 48 - }
-76
nix/modules/did-method-plc.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.did-method-plc; 8 - in 9 - with lib; { 10 - options.services.did-method-plc = { 11 - enable = mkEnableOption "did-method-plc server"; 12 - package = mkPackageOption pkgs "did-method-plc" {}; 13 - }; 14 - config = mkIf cfg.enable { 15 - services.postgresql = { 16 - enable = true; 17 - package = pkgs.postgresql_14; 18 - ensureDatabases = ["plc"]; 19 - ensureUsers = [ 20 - { 21 - name = "pg"; 22 - # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 - } 24 - ]; 25 - authentication = '' 26 - local all all trust 27 - host all all 127.0.0.1/32 trust 28 - ''; 29 - }; 30 - systemd.services.did-method-plc = { 31 - description = "did-method-plc"; 32 - 33 - after = ["postgresql.service"]; 34 - wants = ["postgresql.service"]; 35 - wantedBy = ["multi-user.target"]; 36 - 37 - environment = let 38 - db_creds_json = builtins.toJSON { 39 - username = "pg"; 40 - password = ""; 41 - host = "127.0.0.1"; 42 - port = 5432; 43 - }; 44 - in { 45 - # TODO: inherit from config 46 - DEBUG_MODE = "1"; 47 - LOG_ENABLED = "true"; 48 - LOG_LEVEL = "debug"; 49 - LOG_DESTINATION = "1"; 50 - ENABLE_MIGRATIONS = "true"; 51 - DB_CREDS_JSON = db_creds_json; 52 - DB_MIGRATE_CREDS_JSON = db_creds_json; 53 - PLC_VERSION = "0.0.1"; 54 - PORT = "8080"; 55 - }; 56 - 57 - serviceConfig = { 58 - ExecStart = getExe cfg.package; 59 - User = "plc"; 60 - Group = "plc"; 61 - StateDirectory = "plc"; 62 - StateDirectoryMode = "0755"; 63 - Restart = "always"; 64 - 65 - # Hardening 66 - }; 67 - }; 68 - users = { 69 - users.plc = { 70 - group = "plc"; 71 - isSystemUser = true; 72 - }; 73 - groups.plc = {}; 74 - }; 75 - }; 76 - }
+12 -46
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 5 }: let ··· 18 17 type = types.package; 19 18 description = "Package to use for the spindle"; 20 19 }; 21 - tap-package = mkOption { 22 - type = types.package; 23 - description = "Package to use for the spindle"; 24 - }; 25 - 26 - atpRelayUrl = mkOption { 27 - type = types.str; 28 - default = "https://relay1.us-east.bsky.network"; 29 - description = "atproto relay"; 30 - }; 31 20 32 21 server = { 33 22 listenAddr = mkOption { ··· 36 25 description = "Address to listen on"; 37 26 }; 38 27 39 - stateDir = mkOption { 28 + dbPath = mkOption { 40 29 type = types.path; 41 - default = "/var/lib/spindle"; 42 - description = "Tangled spindle data directory"; 30 + default = "/var/lib/spindle/spindle.db"; 31 + description = "Path to the database file"; 43 32 }; 44 33 45 34 hostname = mkOption { ··· 52 41 type = types.str; 53 42 default = "https://plc.directory"; 54 43 description = "atproto PLC directory"; 44 + }; 45 + 46 + jetstreamEndpoint = mkOption { 47 + type = types.str; 48 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 49 + description = "Jetstream endpoint to subscribe to"; 55 50 }; 56 51 57 52 dev = mkOption { ··· 119 114 config = mkIf cfg.enable { 120 115 virtualisation.docker.enable = true; 121 116 122 - systemd.services.spindle-tap = { 123 - description = "spindle tap service"; 124 - after = ["network.target" "docker.service"]; 125 - wantedBy = ["multi-user.target"]; 126 - serviceConfig = { 127 - LogsDirectory = "spindle-tap"; 128 - StateDirectory = "spindle-tap"; 129 - Environment = [ 130 - "TAP_BIND=:2480" 131 - "TAP_PLC_URL=${cfg.server.plcUrl}" 132 - "TAP_RELAY_URL=${cfg.atpRelayUrl}" 133 - "TAP_DATABASE_URL=sqlite:///var/lib/spindle-tap/tap.db" 134 - "TAP_RETRY_TIMEOUT=3s" 135 - "TAP_COLLECTION_FILTERS=${concatStringsSep "," [ 136 - "sh.tangled.repo" 137 - "sh.tangled.repo.collaborator" 138 - "sh.tangled.spindle.member" 139 - "sh.tangled.repo.pull" 140 - ]}" 141 - # temporary hack to listen for repo.pull from non-tangled users 142 - "TAP_SIGNAL_COLLECTION=sh.tangled.repo.pull" 143 - ]; 144 - ExecStart = "${getExe cfg.tap-package} run"; 145 - }; 146 - }; 147 - 148 117 systemd.services.spindle = { 149 118 description = "spindle service"; 150 - after = ["network.target" "docker.service" "spindle-tap.service"]; 119 + after = ["network.target" "docker.service"]; 151 120 wantedBy = ["multi-user.target"]; 152 - path = [ 153 - pkgs.git 154 - ]; 155 121 serviceConfig = { 156 122 LogsDirectory = "spindle"; 157 123 StateDirectory = "spindle"; 158 124 Environment = [ 159 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 160 - "SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}" 126 + "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 161 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 162 128 "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 163 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 164 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 165 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" ··· 167 134 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 168 135 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 169 136 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 170 - "SPINDLE_SERVER_TAP_URL=http://localhost:2480" 171 137 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 172 138 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 173 139 ];
-20
nix/pkgs/bluesky-jetstream.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "bluesky-jetstream"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "bluesky-social"; 10 - repo = "jetstream"; 11 - rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 - sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 - }; 14 - subPackages = ["cmd/jetstream"]; 15 - vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "jetstream"; 19 - }; 20 - }
-20
nix/pkgs/bluesky-relay.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "bluesky-relay"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "boltlessengineer"; 10 - repo = "indigo"; 11 - rev = "7fe70a304d795b998f354d2b7b2050b909709c99"; 12 - sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok="; 13 - }; 14 - subPackages = ["cmd/relay"]; 15 - vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "relay"; 19 - }; 20 - }
-65
nix/pkgs/did-method-plc.nix
··· 1 - # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 - { 3 - lib, 4 - stdenv, 5 - fetchFromGitHub, 6 - fetchYarnDeps, 7 - yarnConfigHook, 8 - yarnBuildHook, 9 - nodejs, 10 - makeBinaryWrapper, 11 - }: 12 - stdenv.mkDerivation (finalAttrs: { 13 - pname = "did-method-plc"; 14 - version = "0.0.1"; 15 - 16 - src = fetchFromGitHub { 17 - owner = "did-method-plc"; 18 - repo = "did-method-plc"; 19 - rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 - sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 - }; 22 - postPatch = '' 23 - # remove dd-trace dependency 24 - sed -i '3d' packages/server/service/index.js 25 - ''; 26 - 27 - yarnOfflineCache = fetchYarnDeps { 28 - yarnLock = finalAttrs.src + "/yarn.lock"; 29 - hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 - }; 31 - 32 - nativeBuildInputs = [ 33 - yarnConfigHook 34 - yarnBuildHook 35 - nodejs 36 - makeBinaryWrapper 37 - ]; 38 - yarnBuildScript = "lerna"; 39 - yarnBuildFlags = [ 40 - "run" 41 - "build" 42 - "--scope" 43 - "@did-plc/server" 44 - "--include-dependencies" 45 - ]; 46 - 47 - installPhase = '' 48 - runHook preInstall 49 - 50 - mkdir -p $out/lib/node_modules/ 51 - mv packages/ $out/lib/packages/ 52 - mv node_modules/* $out/lib/node_modules/ 53 - 54 - makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 - --add-flags $out/lib/packages/server/service/index.js \ 56 - --add-flags --enable-source-maps \ 57 - --set NODE_PATH $out/lib/node_modules 58 - 59 - runHook postInstall 60 - ''; 61 - 62 - meta = { 63 - mainProgram = "plc"; 64 - }; 65 - })
+13 -1
nix/pkgs/docs.nix
··· 18 18 # icons 19 19 cp -rf ${lucide-src}/*.svg working/ 20 20 21 - # content 21 + # content - chunked 22 22 ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 23 -o $out/ \ 24 24 -t chunkedhtml \ 25 25 --variable toc \ 26 + --variable-json single-page=false \ 26 27 --toc-depth=2 \ 27 28 --css=stylesheet.css \ 28 29 --chunk-template="%i.html" \ 30 + --highlight-style=working/highlight.theme \ 31 + --template=working/template.html 32 + 33 + # content - single page 34 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 35 + -o $out/single-page.html \ 36 + --toc \ 37 + --variable toc \ 38 + --variable single-page \ 39 + --toc-depth=2 \ 40 + --css=stylesheet.css \ 29 41 --highlight-style=working/highlight.theme \ 30 42 --template=working/template.html 31 43
-20
nix/pkgs/tap.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "tap"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "bluesky-social"; 10 - repo = "indigo"; 11 - rev = "498ecb9693e8ae050f73234c86f340f51ad896a9"; 12 - sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k="; 13 - }; 14 - subPackages = ["cmd/tap"]; 15 - vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "tap"; 19 - }; 20 - }
+2 -8
nix/vm.nix
··· 19 19 20 20 plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 21 jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 - relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network"; 23 22 in 24 23 nixpkgs.lib.nixosSystem { 25 24 inherit system; ··· 58 57 host.port = 6555; 59 58 guest.port = 6555; 60 59 } 61 - { 62 - from = "host"; 63 - host.port = 6556; 64 - guest.port = 2480; 65 - } 66 60 ]; 67 61 sharedDirectories = { 68 62 # We can't use the 9p mounts directly for most of these ··· 101 95 }; 102 96 services.tangled.spindle = { 103 97 enable = true; 104 - atpRelayUrl = relayUrl; 105 98 server = { 106 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 107 100 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 108 101 plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 109 103 listenAddr = "0.0.0.0:6555"; 110 104 dev = true; 111 105 queueSize = 100; ··· 140 134 }; 141 135 in { 142 136 knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 143 - spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 144 138 }; 145 139 }) 146 140 ];
-10
orm/orm.go
··· 20 20 } 21 21 defer tx.Rollback() 22 22 23 - _, err = tx.Exec(` 24 - create table if not exists migrations ( 25 - id integer primary key autoincrement, 26 - name text unique 27 - ); 28 - `) 29 - if err != nil { 30 - return fmt.Errorf("creating migrations table: %w", err) 31 - } 32 - 33 23 var exists bool 34 24 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 35 25 if err != nil {
-52
rbac2/bytesadapter/adapter.go
··· 1 - package bytesadapter 2 - 3 - import ( 4 - "bufio" 5 - "bytes" 6 - "errors" 7 - "strings" 8 - 9 - "github.com/casbin/casbin/v2/model" 10 - "github.com/casbin/casbin/v2/persist" 11 - ) 12 - 13 - var ( 14 - errNotImplemented = errors.New("not implemented") 15 - ) 16 - 17 - type Adapter struct { 18 - b []byte 19 - } 20 - 21 - var _ persist.Adapter = &Adapter{} 22 - 23 - func NewAdapter(b []byte) *Adapter { 24 - return &Adapter{b} 25 - } 26 - 27 - func (a *Adapter) LoadPolicy(model model.Model) error { 28 - scanner := bufio.NewScanner(bytes.NewReader(a.b)) 29 - for scanner.Scan() { 30 - line := strings.TrimSpace(scanner.Text()) 31 - if err := persist.LoadPolicyLine(line, model); err != nil { 32 - return err 33 - } 34 - } 35 - return scanner.Err() 36 - } 37 - 38 - func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { 39 - return errNotImplemented 40 - } 41 - 42 - func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { 43 - return errNotImplemented 44 - } 45 - 46 - func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { 47 - return errNotImplemented 48 - } 49 - 50 - func (a *Adapter) SavePolicy(model model.Model) error { 51 - return errNotImplemented 52 - }
-139
rbac2/rbac2.go
··· 1 - package rbac2 2 - 3 - import ( 4 - "database/sql" 5 - _ "embed" 6 - "fmt" 7 - 8 - adapter "github.com/Blank-Xu/sql-adapter" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/casbin/casbin/v2" 11 - "github.com/casbin/casbin/v2/model" 12 - "github.com/casbin/casbin/v2/util" 13 - "tangled.org/core/rbac2/bytesadapter" 14 - ) 15 - 16 - const ( 17 - Model = ` 18 - [request_definition] 19 - r = sub, dom, obj, act 20 - 21 - [policy_definition] 22 - p = sub, dom, obj, act 23 - 24 - [role_definition] 25 - g = _, _, _ 26 - 27 - [policy_effect] 28 - e = some(where (p.eft == allow)) 29 - 30 - [matchers] 31 - m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 32 - ` 33 - ) 34 - 35 - type Enforcer struct { 36 - e *casbin.Enforcer 37 - } 38 - 39 - //go:embed tangled_policy.csv 40 - var tangledPolicy []byte 41 - 42 - func NewEnforcer(path string) (*Enforcer, error) { 43 - db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 44 - if err != nil { 45 - return nil, err 46 - } 47 - return NewEnforcerWithDB(db) 48 - } 49 - 50 - func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) { 51 - m, err := model.NewModelFromString(Model) 52 - if err != nil { 53 - return nil, err 54 - } 55 - 56 - a, err := adapter.NewAdapter(db, "sqlite3", "acl") 57 - if err != nil { 58 - return nil, err 59 - } 60 - 61 - // // PATCH: create unique index to make `AddPoliciesEx` work 62 - // _, err = db.Exec(fmt.Sprintf( 63 - // `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`, 64 - // tableName, 65 - // )) 66 - // if err != nil { 67 - // return nil, err 68 - // } 69 - 70 - e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error 71 - // e.EnableLog(true) 72 - 73 - // NOTE: casbin clears the model on init, so we should intialize with temporary adapter first 74 - // and then override the adapter to sql-adapter. 75 - // `e.SetModel(m)` after init doesn't work for some reason 76 - if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil { 77 - return nil, err 78 - } 79 - 80 - // load dynamic policy from db 81 - e.EnableAutoSave(false) 82 - if err := a.LoadPolicy(e.GetModel()); err != nil { 83 - return nil, err 84 - } 85 - e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 86 - e.BuildRoleLinks() 87 - e.SetAdapter(a) 88 - e.EnableAutoSave(true) 89 - 90 - return &Enforcer{e}, nil 91 - } 92 - 93 - // CaptureModel returns copy of current model. Used for testing 94 - func (e *Enforcer) CaptureModel() model.Model { 95 - return e.e.GetModel().Copy() 96 - } 97 - 98 - func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 99 - roles, err := e.e.GetImplicitRolesForUser(name, domain...) 100 - if err != nil { 101 - return false, err 102 - } 103 - for _, r := range roles { 104 - if r == role { 105 - return true, nil 106 - } 107 - } 108 - return false, nil 109 - } 110 - 111 - // setRoleForUser sets single user role for specified domain. 112 - // All existing users with that role will be removed. 113 - func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 114 - currentUsers, err := e.e.GetUsersForRole(role, domain...) 115 - if err != nil { 116 - return err 117 - } 118 - 119 - for _, oldUser := range currentUsers { 120 - _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 121 - if err != nil { 122 - return err 123 - } 124 - } 125 - 126 - _, err = e.e.AddRoleForUser(name, role, domain...) 127 - return err 128 - } 129 - 130 - // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 131 - func validateAtUri(uri syntax.ATURI, expected string) error { 132 - if !uri.Authority().IsDID() { 133 - return fmt.Errorf("expected at-uri with did") 134 - } 135 - if expected != "" && uri.Collection().String() != expected { 136 - return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 137 - } 138 - return nil 139 - }
-150
rbac2/rbac2_test.go
··· 1 - package rbac2_test 2 - 3 - import ( 4 - "database/sql" 5 - "testing" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - _ "github.com/mattn/go-sqlite3" 9 - "github.com/stretchr/testify/assert" 10 - "tangled.org/core/rbac2" 11 - ) 12 - 13 - func setup(t *testing.T) *rbac2.Enforcer { 14 - enforcer, err := rbac2.NewEnforcer(":memory:") 15 - assert.NoError(t, err) 16 - 17 - return enforcer 18 - } 19 - 20 - func TestNewEnforcer(t *testing.T) { 21 - db, err := sql.Open("sqlite3", "/tmp/test/test.db?_foreign_keys=1") 22 - assert.NoError(t, err) 23 - 24 - enforcer1, err := rbac2.NewEnforcerWithDB(db) 25 - assert.NoError(t, err) 26 - enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")) 27 - model1 := enforcer1.CaptureModel() 28 - 29 - enforcer2, err := rbac2.NewEnforcerWithDB(db) 30 - assert.NoError(t, err) 31 - model2 := enforcer2.CaptureModel() 32 - 33 - // model1.GetLogger().EnableLog(true) 34 - // model1.PrintModel() 35 - // model1.PrintPolicy() 36 - // model1.GetLogger().EnableLog(false) 37 - 38 - model2.GetLogger().EnableLog(true) 39 - model2.PrintModel() 40 - model2.PrintPolicy() 41 - model2.GetLogger().EnableLog(false) 42 - 43 - assert.Equal(t, model1, model2) 44 - } 45 - 46 - func TestRepoOwnerPermissions(t *testing.T) { 47 - var ( 48 - e = setup(t) 49 - ok bool 50 - err error 51 - fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 52 - fooUser = syntax.DID("did:plc:foo") 53 - ) 54 - 55 - assert.NoError(t, e.AddRepo(fooRepo)) 56 - 57 - ok, err = e.IsRepoOwner(fooUser, fooRepo) 58 - assert.NoError(t, err) 59 - assert.True(t, ok, "repo author should be repo owner") 60 - 61 - ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 62 - assert.NoError(t, err) 63 - assert.True(t, ok, "repo owner should be able to modify the repo itself") 64 - 65 - ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 66 - assert.NoError(t, err) 67 - assert.True(t, ok, "repo owner should inherit role role:collaborator") 68 - 69 - ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 70 - assert.NoError(t, err) 71 - assert.True(t, ok, "repo owner should inherit collaborator permissions") 72 - } 73 - 74 - func TestRepoCollaboratorPermissions(t *testing.T) { 75 - var ( 76 - e = setup(t) 77 - ok bool 78 - err error 79 - fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 80 - barUser = syntax.DID("did:plc:bar") 81 - ) 82 - 83 - assert.NoError(t, e.AddRepo(fooRepo)) 84 - assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 85 - 86 - ok, err = e.IsRepoCollaborator(barUser, fooRepo) 87 - assert.NoError(t, err) 88 - assert.True(t, ok, "should set repo collaborator") 89 - 90 - ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 91 - assert.NoError(t, err) 92 - assert.True(t, ok, "repo collaborator should be able to edit repo settings") 93 - 94 - ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 95 - assert.NoError(t, err) 96 - assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 97 - } 98 - 99 - func TestGetByRole(t *testing.T) { 100 - var ( 101 - e = setup(t) 102 - err error 103 - fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 104 - owner = syntax.DID("did:plc:foo") 105 - collaborator1 = syntax.DID("did:plc:bar") 106 - collaborator2 = syntax.DID("did:plc:baz") 107 - ) 108 - 109 - assert.NoError(t, e.AddRepo(fooRepo)) 110 - assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 111 - assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 112 - 113 - collaborators, err := e.GetRepoCollaborators(fooRepo) 114 - assert.NoError(t, err) 115 - assert.ElementsMatch(t, []syntax.DID{ 116 - owner, 117 - collaborator1, 118 - collaborator2, 119 - }, collaborators) 120 - } 121 - 122 - func TestSpindleOwnerPermissions(t *testing.T) { 123 - var ( 124 - e = setup(t) 125 - ok bool 126 - err error 127 - spindle = syntax.DID("did:web:spindle.example.com") 128 - owner = syntax.DID("did:plc:foo") 129 - member = syntax.DID("did:plc:bar") 130 - ) 131 - 132 - assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 133 - assert.NoError(t, e.AddSpindleMember(member, spindle)) 134 - 135 - ok, err = e.IsSpindleMember(owner, spindle) 136 - assert.NoError(t, err) 137 - assert.True(t, ok, "spindle owner is spindle member") 138 - 139 - ok, err = e.IsSpindleMember(member, spindle) 140 - assert.NoError(t, err) 141 - assert.True(t, ok, "spindle member is spindle member") 142 - 143 - ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 144 - assert.NoError(t, err) 145 - assert.True(t, ok, "spindle owner can invite members") 146 - 147 - ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 148 - assert.NoError(t, err) 149 - assert.False(t, ok, "spindle member cannot invite members") 150 - }
-91
rbac2/repo.go
··· 1 - package rbac2 2 - 3 - import ( 4 - "slices" 5 - "strings" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - "tangled.org/core/api/tangled" 9 - ) 10 - 11 - // AddRepo adds new repo with its owner to rbac enforcer 12 - func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 - return err 15 - } 16 - user := repo.Authority() 17 - 18 - return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 - } 20 - 21 - // DeleteRepo deletes all policies related to the repo 22 - func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 - return err 25 - } 26 - 27 - _, err := e.e.DeleteDomains(repo.String()) 28 - return err 29 - } 30 - 31 - // AddRepoCollaborator adds new collaborator to the repo 32 - func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 - return err 35 - } 36 - 37 - _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 - return err 39 - } 40 - 41 - // RemoveRepoCollaborator removes the collaborator from the repo. 42 - // This won't remove inherited roles like repository owner. 43 - func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 - return err 46 - } 47 - 48 - _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 - return err 50 - } 51 - 52 - func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 - var collaborators []syntax.DID 54 - members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 - if err != nil { 56 - return nil, err 57 - } 58 - for _, m := range members { 59 - if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 - continue 61 - } 62 - collaborators = append(collaborators, syntax.DID(m)) 63 - } 64 - 65 - slices.Sort(collaborators) 66 - return slices.Compact(collaborators), nil 67 - } 68 - 69 - func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 - return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 - } 72 - 73 - func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 - return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 - } 76 - 77 - func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 - return e.e.Enforce(user.String(), repo.String(), "/", "write") 79 - } 80 - 81 - func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 - return e.e.Enforce(user.String(), repo.String(), "/settings", "write") 83 - } 84 - 85 - func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 - return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write") 87 - } 88 - 89 - func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 - return e.e.Enforce(user.String(), repo.String(), "/git", "write") 91 - }
-29
rbac2/spindle.go
··· 1 - package rbac2 2 - 3 - import "github.com/bluesky-social/indigo/atproto/syntax" 4 - 5 - func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 - return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 - } 8 - 9 - func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 - return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 11 - } 12 - 13 - func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 - _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 - return err 16 - } 17 - 18 - func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 - _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 - return err 21 - } 22 - 23 - func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 - return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write") 25 - } 26 - 27 - func intoSpindle(did syntax.DID) string { 28 - return "/spindle/" + did.String() 29 - }
-19
rbac2/tangled_policy.csv
··· 1 - #, policies 2 - #, sub, dom, obj, act 3 - p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write 4 - p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write 5 - p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write 6 - p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write 7 - 8 - p, server:owner, /knot/{did}, /member, write 9 - p, server:member, /knot/{did}, /git, write 10 - 11 - p, server:owner, /spindle/{did}, /member, write 12 - 13 - 14 - #, group policies 15 - #, sub, role, dom 16 - g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey} 17 - 18 - g, server:owner, server:member, /knot/{did} 19 - g, server:owner, server:member, /spindle/{did}
+11 -20
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "path/filepath" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/sethvargo/go-envconfig" 10 9 ) 11 10 12 11 type Server struct { 13 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - TapUrl string `env:"TAP_URL, required"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner syntax.DID `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 22 - QueueSize int `env:"QUEUE_SIZE, default=100"` 23 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 + DBPath string `env:"DB_PATH, default=spindle.db"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 + Dev bool `env:"DEV, default=false"` 18 + Owner string `env:"OWNER, required"` 19 + Secrets Secrets `env:",prefix=SECRETS_"` 20 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 + QueueSize int `env:"QUEUE_SIZE, default=100"` 22 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 24 23 } 25 24 26 25 func (s Server) Did() syntax.DID { 27 26 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 28 - } 29 - 30 - func (s Server) RepoDir() string { 31 - return filepath.Join(s.DataDir, "repos") 32 - } 33 - 34 - func (s Server) DBPath() string { 35 - return filepath.Join(s.DataDir, "spindle.db") 36 27 } 37 28 38 29 type Secrets struct {
+18 -73
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 - "context" 5 4 "database/sql" 6 5 "strings" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 7 _ "github.com/mattn/go-sqlite3" 10 - "tangled.org/core/log" 11 - "tangled.org/core/orm" 12 8 ) 13 9 14 10 type DB struct { 15 11 *sql.DB 16 12 } 17 13 18 - func Make(ctx context.Context, dbPath string) (*DB, error) { 14 + func Make(dbPath string) (*DB, error) { 19 15 // https://github.com/mattn/go-sqlite3#connection-string 20 16 opts := []string{ 21 17 "_foreign_keys=1", ··· 24 20 "_auto_vacuum=incremental", 25 21 } 26 22 27 - logger := log.FromContext(ctx) 28 - logger = log.SubLogger(logger, "db") 29 - 30 23 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 24 if err != nil { 32 25 return nil, err 33 26 } 34 27 35 - conn, err := db.Conn(ctx) 36 - if err != nil { 37 - return nil, err 38 - } 39 - defer conn.Close() 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 40 31 41 32 _, err = db.Exec(` 42 33 create table if not exists _jetstream ( ··· 58 49 unique(owner, name) 59 50 ); 60 51 61 - create table if not exists repo_collaborators ( 62 - -- identifiers 63 - id integer primary key autoincrement, 64 - did text not null, 65 - rkey text not null, 66 - at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.collaborator' || '/' || rkey) stored, 67 - 68 - repo text not null, 69 - subject text not null, 70 - 71 - addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 72 - unique(did, rkey) 73 - ); 74 - 75 52 create table if not exists spindle_members ( 76 53 -- identifiers for the record 77 54 id integer primary key autoincrement, ··· 99 76 return nil, err 100 77 } 101 78 102 - // run migrations 79 + return &DB{db}, nil 80 + } 103 81 104 - // NOTE: this won't migrate existing records 105 - // they will be fetched again with tap instead 106 - orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error { 107 - // archive legacy repos (just in case) 108 - _, err = tx.Exec(`alter table repos rename to repos_old`) 109 - if err != nil { 110 - return err 111 - } 112 - 113 - _, err := tx.Exec(` 114 - create table repos ( 115 - -- identifiers 116 - id integer primary key autoincrement, 117 - did text not null, 118 - rkey text not null, 119 - at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 120 - 121 - name text not null, 122 - knot text not null, 123 - 124 - addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 125 - unique(did, rkey) 126 - ); 127 - `) 128 - if err != nil { 129 - return err 130 - } 131 - 132 - return nil 133 - }) 134 - 135 - return &DB{db}, nil 82 + func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 83 + _, err := d.Exec(` 84 + insert into _jetstream (id, last_time_us) 85 + values (1, ?) 86 + on conflict(id) do update set last_time_us = excluded.last_time_us 87 + `, lastTimeUs) 88 + return err 136 89 } 137 90 138 - func (d *DB) IsKnownDid(did syntax.DID) (bool, error) { 139 - // is spindle member / repo collaborator 140 - var exists bool 141 - err := d.QueryRow( 142 - `select exists ( 143 - select 1 from repo_collaborators where subject = ? 144 - union all 145 - select 1 from spindle_members where did = ? 146 - )`, 147 - did, 148 - did, 149 - ).Scan(&exists) 150 - return exists, err 91 + func (d *DB) GetLastTimeUs() (int64, error) { 92 + var lastTimeUs int64 93 + row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 94 + err := row.Scan(&lastTimeUs) 95 + return lastTimeUs, err 151 96 }
+8 -10
spindle/db/events.go
··· 18 18 EventJson string `json:"event"` 19 19 } 20 20 21 - func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error { 21 + func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { 22 22 _, err := d.Exec( 23 23 `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 24 event.Rkey, ··· 70 70 return evts, nil 71 71 } 72 72 73 - func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error { 74 - eventJson, err := json.Marshal(pipeline) 73 + func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error { 74 + eventJson, err := json.Marshal(s) 75 75 if err != nil { 76 76 return err 77 77 } 78 + 78 79 event := Event{ 79 80 Rkey: rkey, 80 - Nsid: tangled.PipelineNSID, 81 + Nsid: tangled.PipelineStatusNSID, 81 82 Created: time.Now().UnixNano(), 82 83 EventJson: string(eventJson), 83 84 } 84 - return d.insertEvent(event, n) 85 + 86 + return d.InsertEvent(event, n) 85 87 } 86 88 87 89 func (d *DB) createStatusEvent( ··· 114 116 EventJson: string(eventJson), 115 117 } 116 118 117 - return d.insertEvent(event, n) 119 + return d.InsertEvent(event, n) 118 120 119 121 } 120 122 ··· 162 164 163 165 func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 164 166 return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n) 165 - } 166 - 167 - func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 168 - return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n) 169 167 } 170 168 171 169 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
+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 + }
+11 -119
spindle/db/repos.go
··· 1 1 package db 2 2 3 - import "github.com/bluesky-social/indigo/atproto/syntax" 4 - 5 3 type Repo struct { 6 - Did syntax.DID 7 - Rkey syntax.RecordKey 8 - Name string 9 - Knot string 4 + Knot string 5 + Owner string 6 + Name string 10 7 } 11 8 12 - type RepoCollaborator struct { 13 - Did syntax.DID 14 - Rkey syntax.RecordKey 15 - Repo syntax.ATURI 16 - Subject syntax.DID 17 - } 18 - 19 - func (d *DB) PutRepo(repo *Repo) error { 20 - _, err := d.Exec( 21 - `insert or ignore into repos (did, rkey, name, knot) 22 - values (?, ?, ?, ?) 23 - on conflict(did, rkey) do update set 24 - name = excluded.name, 25 - knot = excluded.knot`, 26 - repo.Did, 27 - repo.Rkey, 28 - repo.Name, 29 - repo.Knot, 30 - ) 31 - return err 32 - } 33 - 34 - func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error { 35 - _, err := d.Exec( 36 - `delete from repos where did = ? and rkey = ?`, 37 - did, 38 - rkey, 39 - ) 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) 40 11 return err 41 12 } 42 13 ··· 63 34 return knots, nil 64 35 } 65 36 66 - func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) { 37 + func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 67 38 var repo Repo 68 - err := d.DB.QueryRow( 69 - `select 70 - did, 71 - rkey, 72 - name, 73 - knot 74 - from repos where at_uri = ?`, 75 - repoAt, 76 - ).Scan( 77 - &repo.Did, 78 - &repo.Rkey, 79 - &repo.Name, 80 - &repo.Knot, 81 - ) 82 - if err != nil { 83 - return nil, err 84 - } 85 - return &repo, nil 86 - } 87 39 88 - func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) { 89 - var repo Repo 90 - err := d.DB.QueryRow( 91 - `select 92 - did, 93 - rkey, 94 - name, 95 - knot 96 - from repos where did = ? and name = ?`, 97 - did, 98 - name, 99 - ).Scan( 100 - &repo.Did, 101 - &repo.Rkey, 102 - &repo.Name, 103 - &repo.Knot, 104 - ) 40 + query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?" 41 + err := d.DB.QueryRow(query, knot, owner, name). 42 + Scan(&repo.Knot, &repo.Owner, &repo.Name) 43 + 105 44 if err != nil { 106 45 return nil, err 107 46 } 47 + 108 48 return &repo, nil 109 49 } 110 - 111 - func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error { 112 - _, err := d.Exec( 113 - `insert into repo_collaborators (did, rkey, repo, subject) 114 - values (?, ?, ?, ?) 115 - on conflict(did, rkey) do update set 116 - repo = excluded.repo, 117 - subject = excluded.subject`, 118 - collaborator.Did, 119 - collaborator.Rkey, 120 - collaborator.Repo, 121 - collaborator.Subject, 122 - ) 123 - return err 124 - } 125 - 126 - func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error { 127 - _, err := d.Exec( 128 - `delete from repo_collaborators where did = ? and rkey = ?`, 129 - did, 130 - rkey, 131 - ) 132 - return err 133 - } 134 - 135 - func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 136 - var collaborator RepoCollaborator 137 - err := d.DB.QueryRow( 138 - `select 139 - did, 140 - rkey, 141 - repo, 142 - subject 143 - from repo_collaborators 144 - where did = ? and rkey = ?`, 145 - did, 146 - rkey, 147 - ).Scan( 148 - &collaborator.Did, 149 - &collaborator.Rkey, 150 - &collaborator.Repo, 151 - &collaborator.Subject, 152 - ) 153 - if err != nil { 154 - return nil, err 155 - } 156 - return &collaborator, nil 157 - }
+13 -24
spindle/engines/nixery/engine.go
··· 179 179 return err 180 180 } 181 181 e.registerCleanup(wid, func(ctx context.Context) error { 182 - if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil { 183 - return fmt.Errorf("removing network: %w", err) 184 - } 185 - return nil 182 + return e.docker.NetworkRemove(ctx, networkName(wid)) 186 183 }) 187 184 188 185 addl := wf.Data.(addlFields) ··· 232 229 return fmt.Errorf("creating container: %w", err) 233 230 } 234 231 e.registerCleanup(wid, func(ctx context.Context) error { 235 - if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil { 236 - return fmt.Errorf("stopping container: %w", err) 232 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 233 + if err != nil { 234 + return err 237 235 } 238 236 239 - err := e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 237 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 240 238 RemoveVolumes: true, 241 239 RemoveLinks: false, 242 240 Force: false, 243 241 }) 244 - if err != nil { 245 - return fmt.Errorf("removing container: %w", err) 246 - } 247 - return nil 248 242 }) 249 243 250 - if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { 244 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 + if err != nil { 251 246 return fmt.Errorf("starting container: %w", err) 252 247 } 253 248 ··· 399 394 } 400 395 401 396 func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 402 - fns := e.drainCleanups(wid) 397 + e.cleanupMu.Lock() 398 + key := wid.String() 399 + 400 + fns := e.cleanup[key] 401 + delete(e.cleanup, key) 402 + e.cleanupMu.Unlock() 403 403 404 404 for _, fn := range fns { 405 405 if err := fn(ctx); err != nil { ··· 415 415 416 416 key := wid.String() 417 417 e.cleanup[key] = append(e.cleanup[key], fn) 418 - } 419 - 420 - func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 421 - e.cleanupMu.Lock() 422 - key := wid.String() 423 - 424 - fns := e.cleanup[key] 425 - delete(e.cleanup, key) 426 - e.cleanupMu.Unlock() 427 - 428 - return fns 429 418 } 430 419 431 420 func networkName(wid models.WorkflowId) string {
-73
spindle/git/git.go
··· 1 - package git 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "os" 8 - "os/exec" 9 - "strings" 10 - 11 - "github.com/hashicorp/go-version" 12 - ) 13 - 14 - func Version() (*version.Version, error) { 15 - var buf bytes.Buffer 16 - cmd := exec.Command("git", "version") 17 - cmd.Stdout = &buf 18 - cmd.Stderr = os.Stderr 19 - err := cmd.Run() 20 - if err != nil { 21 - return nil, err 22 - } 23 - fields := strings.Fields(buf.String()) 24 - if len(fields) < 3 { 25 - return nil, fmt.Errorf("invalid git version: %s", buf.String()) 26 - } 27 - 28 - // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" 29 - versionString := fields[2] 30 - if pos := strings.Index(versionString, "windows"); pos >= 1 { 31 - versionString = versionString[:pos-1] 32 - } 33 - return version.NewVersion(versionString) 34 - } 35 - 36 - const WorkflowDir = `/.tangled/workflows` 37 - 38 - func SparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error { 39 - exist, err := isDir(path) 40 - if err != nil { 41 - return err 42 - } 43 - if rev == "" { 44 - rev = "HEAD" 45 - } 46 - if !exist { 47 - if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil { 48 - return fmt.Errorf("git clone: %w", err) 49 - } 50 - if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir).Run(); err != nil { 51 - return fmt.Errorf("git sparse-checkout set: %w", err) 52 - } 53 - } else { 54 - if err := exec.Command("git", "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev).Run(); err != nil { 55 - return fmt.Errorf("git pull: %w", err) 56 - } 57 - } 58 - if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil { 59 - return fmt.Errorf("git checkout: %w", err) 60 - } 61 - return nil 62 - } 63 - 64 - func isDir(path string) (bool, error) { 65 - info, err := os.Stat(path) 66 - if err == nil && info.IsDir() { 67 - return true, nil 68 - } 69 - if os.IsNotExist(err) { 70 - return false, nil 71 - } 72 - return false, err 73 - }
+300
spindle/ingester.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/rbac" 13 + "tangled.org/core/spindle/db" 14 + 15 + comatproto "github.com/bluesky-social/indigo/api/atproto" 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/bluesky-social/jetstream/pkg/models" 20 + securejoin "github.com/cyphar/filepath-securejoin" 21 + ) 22 + 23 + type Ingester func(ctx context.Context, e *models.Event) error 24 + 25 + func (s *Spindle) ingest() Ingester { 26 + return func(ctx context.Context, e *models.Event) error { 27 + var err error 28 + defer func() { 29 + eventTime := e.TimeUS 30 + lastTimeUs := eventTime + 1 31 + if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil { 32 + err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 + } 34 + }() 35 + 36 + if e.Kind != models.EventKindCommit { 37 + return nil 38 + } 39 + 40 + switch e.Commit.Collection { 41 + case tangled.SpindleMemberNSID: 42 + err = s.ingestMember(ctx, e) 43 + case tangled.RepoNSID: 44 + err = s.ingestRepo(ctx, e) 45 + case tangled.RepoCollaboratorNSID: 46 + err = s.ingestCollaborator(ctx, e) 47 + } 48 + 49 + if err != nil { 50 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 51 + } 52 + 53 + return nil 54 + } 55 + } 56 + 57 + func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 58 + var err error 59 + did := e.Did 60 + rkey := e.Commit.RKey 61 + 62 + l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 63 + 64 + switch e.Commit.Operation { 65 + case models.CommitOperationCreate, models.CommitOperationUpdate: 66 + raw := e.Commit.Record 67 + record := tangled.SpindleMember{} 68 + err = json.Unmarshal(raw, &record) 69 + if err != nil { 70 + l.Error("invalid record", "error", err) 71 + return err 72 + } 73 + 74 + domain := s.cfg.Server.Hostname 75 + recordInstance := record.Instance 76 + 77 + if recordInstance != domain { 78 + l.Error("domain mismatch", "domain", recordInstance, "expected", domain) 79 + return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 80 + } 81 + 82 + ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 83 + if err != nil || !ok { 84 + l.Error("failed to add member", "did", did, "error", err) 85 + return fmt.Errorf("failed to enforce permissions: %w", err) 86 + } 87 + 88 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 89 + Did: syntax.DID(did), 90 + Rkey: rkey, 91 + Instance: recordInstance, 92 + Subject: syntax.DID(record.Subject), 93 + Created: time.Now(), 94 + }); err != nil { 95 + l.Error("failed to add member", "error", err) 96 + return fmt.Errorf("failed to add member: %w", err) 97 + } 98 + 99 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 100 + l.Error("failed to add member", "error", err) 101 + return fmt.Errorf("failed to add member: %w", err) 102 + } 103 + l.Info("added member from firehose", "member", record.Subject) 104 + 105 + if err := s.db.AddDid(record.Subject); err != nil { 106 + l.Error("failed to add did", "error", err) 107 + return fmt.Errorf("failed to add did: %w", err) 108 + } 109 + s.jc.AddDid(record.Subject) 110 + 111 + return nil 112 + 113 + case models.CommitOperationDelete: 114 + record, err := db.GetSpindleMember(s.db, did, rkey) 115 + if err != nil { 116 + l.Error("failed to find member", "error", err) 117 + return fmt.Errorf("failed to find member: %w", err) 118 + } 119 + 120 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 121 + l.Error("failed to remove member", "error", err) 122 + return fmt.Errorf("failed to remove member: %w", err) 123 + } 124 + 125 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 126 + l.Error("failed to add member", "error", err) 127 + return fmt.Errorf("failed to add member: %w", err) 128 + } 129 + l.Info("added member from firehose", "member", record.Subject) 130 + 131 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 132 + l.Error("failed to add did", "error", err) 133 + return fmt.Errorf("failed to add did: %w", err) 134 + } 135 + s.jc.RemoveDid(record.Subject.String()) 136 + 137 + } 138 + return nil 139 + } 140 + 141 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 + var err error 143 + did := e.Did 144 + 145 + l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 + 147 + l.Info("ingesting repo record", "did", did) 148 + 149 + switch e.Commit.Operation { 150 + case models.CommitOperationCreate, models.CommitOperationUpdate: 151 + raw := e.Commit.Record 152 + record := tangled.Repo{} 153 + err = json.Unmarshal(raw, &record) 154 + if err != nil { 155 + l.Error("invalid record", "error", err) 156 + return err 157 + } 158 + 159 + domain := s.cfg.Server.Hostname 160 + 161 + // no spindle configured for this repo 162 + if record.Spindle == nil { 163 + l.Info("no spindle configured", "name", record.Name) 164 + return nil 165 + } 166 + 167 + // this repo did not want this spindle 168 + if *record.Spindle != domain { 169 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 170 + return nil 171 + } 172 + 173 + // add this repo to the watch list 174 + if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 175 + l.Error("failed to add repo", "error", err) 176 + return fmt.Errorf("failed to add repo: %w", err) 177 + } 178 + 179 + didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 180 + if err != nil { 181 + return err 182 + } 183 + 184 + // add repo to rbac 185 + if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 186 + l.Error("failed to add repo to enforcer", "error", err) 187 + return fmt.Errorf("failed to add repo: %w", err) 188 + } 189 + 190 + // add collaborators to rbac 191 + owner, err := s.res.ResolveIdent(ctx, did) 192 + if err != nil || owner.Handle.IsInvalidHandle() { 193 + return err 194 + } 195 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 196 + return err 197 + } 198 + 199 + // add this knot to the event consumer 200 + src := eventconsumer.NewKnotSource(record.Knot) 201 + s.ks.AddSource(context.Background(), src) 202 + 203 + return nil 204 + 205 + } 206 + return nil 207 + } 208 + 209 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 210 + var err error 211 + 212 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 213 + 214 + l.Info("ingesting collaborator record") 215 + 216 + switch e.Commit.Operation { 217 + case models.CommitOperationCreate, models.CommitOperationUpdate: 218 + raw := e.Commit.Record 219 + record := tangled.RepoCollaborator{} 220 + err = json.Unmarshal(raw, &record) 221 + if err != nil { 222 + l.Error("invalid record", "error", err) 223 + return err 224 + } 225 + 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 + if err != nil || subjectId.Handle.IsInvalidHandle() { 228 + return err 229 + } 230 + 231 + repoAt, err := syntax.ParseATURI(record.Repo) 232 + if err != nil { 233 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 + return nil 235 + } 236 + 237 + // TODO: get rid of this entirely 238 + // resolve this aturi to extract the repo record 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 + if err != nil || owner.Handle.IsInvalidHandle() { 241 + return fmt.Errorf("failed to resolve handle: %w", err) 242 + } 243 + 244 + xrpcc := xrpc.Client{ 245 + Host: owner.PDSEndpoint(), 246 + } 247 + 248 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + repo := resp.Value.Val.(*tangled.Repo) 254 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 + 256 + // check perms for this user 257 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 + return fmt.Errorf("insufficient permissions: %w", err) 259 + } 260 + 261 + // add collaborator to rbac 262 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 + l.Error("failed to add repo to enforcer", "error", err) 264 + return fmt.Errorf("failed to add repo: %w", err) 265 + } 266 + 267 + return nil 268 + } 269 + return nil 270 + } 271 + 272 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 + 275 + l.Info("fetching and adding existing collaborators") 276 + 277 + xrpcc := xrpc.Client{ 278 + Host: owner.PDSEndpoint(), 279 + } 280 + 281 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 + if err != nil { 283 + return err 284 + } 285 + 286 + var errs error 287 + for _, r := range resp.Records { 288 + if r == nil { 289 + continue 290 + } 291 + record := r.Value.Val.(*tangled.RepoCollaborator) 292 + 293 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 + l.Error("failed to add repo to enforcer", "error", err) 295 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 + } 297 + } 298 + 299 + return errs 300 + }
+1 -1
spindle/models/pipeline_env.go
··· 20 20 // Standard CI environment variable 21 21 env["CI"] = "true" 22 22 23 - env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 24 24 25 25 // Repo info 26 26 if tr.Repo != nil {
+179 -234
spindle/server.go
··· 4 4 "context" 5 5 _ "embed" 6 6 "encoding/json" 7 - "errors" 8 7 "fmt" 9 8 "log/slog" 10 9 "maps" 11 10 "net/http" 12 - "path/filepath" 11 + "sync" 13 12 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 13 "github.com/go-chi/chi/v5" 16 - "github.com/go-git/go-git/v5/plumbing/object" 17 - "github.com/hashicorp/go-version" 18 14 "tangled.org/core/api/tangled" 19 15 "tangled.org/core/eventconsumer" 20 16 "tangled.org/core/eventconsumer/cursor" 21 17 "tangled.org/core/idresolver" 22 - kgit "tangled.org/core/knotserver/git" 18 + "tangled.org/core/jetstream" 23 19 "tangled.org/core/log" 24 20 "tangled.org/core/notifier" 25 - "tangled.org/core/rbac2" 21 + "tangled.org/core/rbac" 26 22 "tangled.org/core/spindle/config" 27 23 "tangled.org/core/spindle/db" 28 24 "tangled.org/core/spindle/engine" 29 25 "tangled.org/core/spindle/engines/nixery" 30 - "tangled.org/core/spindle/git" 31 26 "tangled.org/core/spindle/models" 32 27 "tangled.org/core/spindle/queue" 33 28 "tangled.org/core/spindle/secrets" 34 29 "tangled.org/core/spindle/xrpc" 35 - "tangled.org/core/tap" 36 - "tangled.org/core/tid" 37 - "tangled.org/core/workflow" 38 30 "tangled.org/core/xrpc/serviceauth" 39 31 ) 40 32 41 33 //go:embed motd 42 - var motd []byte 34 + var defaultMotd []byte 35 + 36 + const ( 37 + rbacDomain = "thisserver" 38 + ) 43 39 44 40 type Spindle struct { 45 - tap *tap.Client 46 - db *db.DB 47 - e *rbac2.Enforcer 48 - l *slog.Logger 49 - n *notifier.Notifier 50 - engs map[string]models.Engine 51 - jq *queue.Queue 52 - cfg *config.Config 53 - ks *eventconsumer.Consumer 54 - res *idresolver.Resolver 55 - vault secrets.Manager 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 56 54 } 57 55 58 56 // New creates a new Spindle server with the provided configuration and engines. 59 57 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 60 58 logger := log.FromContext(ctx) 61 59 62 - if err := ensureGitVersion(); err != nil { 63 - return nil, fmt.Errorf("ensuring git version: %w", err) 64 - } 65 - 66 - d, err := db.Make(ctx, cfg.Server.DBPath()) 60 + d, err := db.Make(cfg.Server.DBPath) 67 61 if err != nil { 68 62 return nil, fmt.Errorf("failed to setup db: %w", err) 69 63 } 70 64 71 - e, err := rbac2.NewEnforcer(cfg.Server.DBPath()) 65 + e, err := rbac.NewEnforcer(cfg.Server.DBPath) 72 66 if err != nil { 73 67 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 74 68 } 69 + e.E.EnableAutoSave(true) 75 70 76 71 n := notifier.New() 77 72 ··· 91 86 } 92 87 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 93 88 case "sqlite", "": 94 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets")) 89 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 95 90 if err != nil { 96 91 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 97 92 } 98 - logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath()) 93 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 99 94 default: 100 95 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 96 } ··· 103 98 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 99 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 105 100 106 - tap := tap.NewClient(cfg.Server.TapUrl, "") 101 + collections := []string{ 102 + tangled.SpindleMemberNSID, 103 + tangled.RepoNSID, 104 + tangled.RepoCollaboratorNSID, 105 + } 106 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 107 + if err != nil { 108 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 109 + } 110 + jc.AddDid(cfg.Server.Owner) 111 + 112 + // Check if the spindle knows about any Dids; 113 + dids, err := d.GetAllDids() 114 + if err != nil { 115 + return nil, fmt.Errorf("failed to get all dids: %w", err) 116 + } 117 + for _, d := range dids { 118 + jc.AddDid(d) 119 + } 107 120 108 121 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 109 122 110 123 spindle := &Spindle{ 111 - tap: &tap, 124 + jc: jc, 112 125 e: e, 113 126 db: d, 114 127 l: logger, ··· 118 131 cfg: cfg, 119 132 res: resolver, 120 133 vault: vault, 134 + motd: defaultMotd, 121 135 } 122 136 123 - err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 137 + err = e.AddSpindle(rbacDomain) 138 + if err != nil { 139 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 140 + } 141 + err = spindle.configureOwner() 124 142 if err != nil { 125 143 return nil, err 126 144 } 127 145 logger.Info("owner set", "did", cfg.Server.Owner) 128 146 129 - cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath()) 147 + cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 130 148 if err != nil { 131 149 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 132 150 } 133 151 134 - // spindle listen to knot stream for sh.tangled.git.refUpdate 135 - // which will sync the local workflow files in spindle and enqueues the 136 - // pipeline job for on-push workflows 152 + err = jc.StartJetstream(ctx, spindle.ingest()) 153 + if err != nil { 154 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 155 + } 156 + 157 + // for each incoming sh.tangled.pipeline, we execute 158 + // spindle.processPipeline, which in turn enqueues the pipeline 159 + // job in the above registered queue. 137 160 ccfg := eventconsumer.NewConsumerConfig() 138 161 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 139 162 ccfg.Dev = cfg.Server.Dev 140 - ccfg.ProcessFunc = spindle.processKnotStream 163 + ccfg.ProcessFunc = spindle.processPipeline 141 164 ccfg.CursorStore = cursorStore 142 165 knownKnots, err := d.Knots() 143 166 if err != nil { ··· 178 201 } 179 202 180 203 // Enforcer returns the RBAC enforcer instance. 181 - func (s *Spindle) Enforcer() *rbac2.Enforcer { 204 + func (s *Spindle) Enforcer() *rbac.Enforcer { 182 205 return s.e 183 206 } 184 207 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 185 222 // Start starts the Spindle server (blocking). 186 223 func (s *Spindle) Start(ctx context.Context) error { 187 224 // starts a job queue runner in the background ··· 196 233 go func() { 197 234 s.l.Info("starting knot event consumer") 198 235 s.ks.Start(ctx) 199 - }() 200 - 201 - // ensure server owner is tracked 202 - if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil { 203 - return err 204 - } 205 - 206 - go func() { 207 - s.l.Info("starting tap stream consumer") 208 - s.tap.Connect(ctx, &tap.SimpleIndexer{ 209 - EventHandler: s.processEvent, 210 - }) 211 236 }() 212 237 213 238 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) ··· 239 264 mux := chi.NewRouter() 240 265 241 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 242 - w.Write(motd) 267 + w.Write(s.GetMotdContent()) 243 268 }) 244 269 mux.HandleFunc("/events", s.Events) 245 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 261 286 Config: s.cfg, 262 287 Resolver: s.res, 263 288 Vault: s.vault, 264 - Notifier: s.Notifier(), 265 289 ServiceAuth: serviceAuth, 266 290 } 267 291 268 292 return x.Router() 269 293 } 270 294 271 - func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 272 - l := log.FromContext(ctx).With("handler", "processKnotStream") 273 - l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 274 - if msg.Nsid == tangled.GitRefUpdateNSID { 275 - event := tangled.GitRefUpdate{} 276 - if err := json.Unmarshal(msg.EventJson, &event); err != nil { 277 - l.Error("error unmarshalling", "err", err) 295 + func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 296 + if msg.Nsid == tangled.PipelineNSID { 297 + tpl := tangled.Pipeline{} 298 + err := json.Unmarshal(msg.EventJson, &tpl) 299 + if err != nil { 300 + fmt.Println("error unmarshalling", err) 278 301 return err 279 302 } 280 - l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 281 303 282 - // resolve repo name to rkey 283 - // TODO: git.refUpdate should respond with rkey instead of repo name 284 - repo, err := s.db.GetRepoWithName(syntax.DID(event.RepoDid), event.RepoName) 285 - if err != nil { 286 - return fmt.Errorf("get repo with did and name (%s/%s): %w", event.RepoDid, event.RepoName, err) 304 + if tpl.TriggerMetadata == nil { 305 + return fmt.Errorf("no trigger metadata found") 287 306 } 288 307 289 - // NOTE: we are blindly trusting the knot that it will return only repos it own 290 - repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 291 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 292 - if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 293 - return fmt.Errorf("sync git repo: %w", err) 308 + if tpl.TriggerMetadata.Repo == nil { 309 + return fmt.Errorf("no repo data found") 294 310 } 295 - l.Info("synced git repo") 296 311 297 - compiler := workflow.Compiler{ 298 - Trigger: tangled.Pipeline_TriggerMetadata{ 299 - Kind: string(workflow.TriggerKindPush), 300 - Push: &tangled.Pipeline_PushTriggerData{ 301 - Ref: event.Ref, 302 - OldSha: event.OldSha, 303 - NewSha: event.NewSha, 304 - }, 305 - Repo: &tangled.Pipeline_TriggerRepo{ 306 - Did: repo.Did.String(), 307 - Knot: repo.Knot, 308 - Repo: repo.Name, 309 - }, 310 - }, 312 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 313 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 311 314 } 312 315 313 - // load workflow definitions from rev (without spindle context) 314 - rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 316 + // filter by repos 317 + _, err = s.db.GetRepo( 318 + tpl.TriggerMetadata.Repo.Knot, 319 + tpl.TriggerMetadata.Repo.Did, 320 + tpl.TriggerMetadata.Repo.Repo, 321 + ) 315 322 if err != nil { 316 - return fmt.Errorf("loading pipeline: %w", err) 317 - } 318 - if len(rawPipeline) == 0 { 319 - l.Info("no workflow definition find for the repo. skipping the event") 320 - return nil 321 - } 322 - tpl := compiler.Compile(compiler.Parse(rawPipeline)) 323 - // TODO: pass compile error to workflow log 324 - for _, w := range compiler.Diagnostics.Errors { 325 - l.Error(w.String()) 326 - } 327 - for _, w := range compiler.Diagnostics.Warnings { 328 - l.Warn(w.String()) 323 + return err 329 324 } 330 325 331 326 pipelineId := models.PipelineId{ 332 - Knot: tpl.TriggerMetadata.Repo.Knot, 333 - Rkey: tid.TID(), 327 + Knot: src.Key(), 328 + Rkey: msg.Rkey, 334 329 } 335 - if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 336 - l.Error("failed to create pipeline event", "err", err) 337 - return nil 338 - } 339 - err = s.processPipeline(ctx, tpl, pipelineId) 340 - if err != nil { 341 - return err 342 - } 343 - } 330 + 331 + workflows := make(map[models.Engine][]models.Workflow) 344 332 345 - return nil 346 - } 333 + // Build pipeline environment variables once for all workflows 334 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 347 335 348 - func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 349 - if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 350 - return nil, fmt.Errorf("syncing git repo: %w", err) 351 - } 352 - gr, err := kgit.Open(repoPath, rev) 353 - if err != nil { 354 - return nil, fmt.Errorf("opening git repo: %w", err) 355 - } 336 + for _, w := range tpl.Workflows { 337 + if w != nil { 338 + if _, ok := s.engs[w.Engine]; !ok { 339 + err = s.db.StatusFailed(models.WorkflowId{ 340 + PipelineId: pipelineId, 341 + Name: w.Name, 342 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 343 + if err != nil { 344 + return err 345 + } 356 346 357 - workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 358 - if errors.Is(err, object.ErrDirectoryNotFound) { 359 - // return empty RawPipeline when directory doesn't exist 360 - return nil, nil 361 - } else if err != nil { 362 - return nil, fmt.Errorf("loading file tree: %w", err) 363 - } 347 + continue 348 + } 364 349 365 - var rawPipeline workflow.RawPipeline 366 - for _, e := range workflowDir { 367 - if !e.IsFile() { 368 - continue 369 - } 350 + eng := s.engs[w.Engine] 370 351 371 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 372 - contents, err := gr.RawContent(fpath) 373 - if err != nil { 374 - return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 375 - } 352 + if _, ok := workflows[eng]; !ok { 353 + workflows[eng] = []models.Workflow{} 354 + } 376 355 377 - rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 378 - Name: e.Name, 379 - Contents: contents, 380 - }) 381 - } 356 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 357 + if err != nil { 358 + return err 359 + } 382 360 383 - return rawPipeline, nil 384 - } 361 + // inject TANGLED_* env vars after InitWorkflow 362 + // This prevents user-defined env vars from overriding them 363 + if ewf.Environment == nil { 364 + ewf.Environment = make(map[string]string) 365 + } 366 + maps.Copy(ewf.Environment, pipelineEnv) 385 367 386 - func (s *Spindle) processPipeline(ctx context.Context, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 387 - // Build pipeline environment variables once for all workflows 388 - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 368 + workflows[eng] = append(workflows[eng], *ewf) 389 369 390 - // filter & init workflows 391 - workflows := make(map[models.Engine][]models.Workflow) 392 - for _, w := range tpl.Workflows { 393 - if w == nil { 394 - continue 395 - } 396 - if _, ok := s.engs[w.Engine]; !ok { 397 - err := s.db.StatusFailed(models.WorkflowId{ 398 - PipelineId: pipelineId, 399 - Name: w.Name, 400 - }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 401 - if err != nil { 402 - return fmt.Errorf("db.StatusFailed: %w", err) 370 + err = s.db.StatusPending(models.WorkflowId{ 371 + PipelineId: pipelineId, 372 + Name: w.Name, 373 + }, s.n) 374 + if err != nil { 375 + return err 376 + } 403 377 } 404 - 405 - continue 406 378 } 407 379 408 - eng := s.engs[w.Engine] 409 - 410 - if _, ok := workflows[eng]; !ok { 411 - workflows[eng] = []models.Workflow{} 412 - } 413 - 414 - ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 415 - if err != nil { 416 - return fmt.Errorf("init workflow: %w", err) 417 - } 418 - 419 - // inject TANGLED_* env vars after InitWorkflow 420 - // This prevents user-defined env vars from overriding them 421 - if ewf.Environment == nil { 422 - ewf.Environment = make(map[string]string) 380 + ok := s.jq.Enqueue(queue.Job{ 381 + Run: func() error { 382 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 383 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 384 + RepoName: tpl.TriggerMetadata.Repo.Repo, 385 + Workflows: workflows, 386 + }, pipelineId) 387 + return nil 388 + }, 389 + OnFail: func(jobError error) { 390 + s.l.Error("pipeline run failed", "error", jobError) 391 + }, 392 + }) 393 + if ok { 394 + s.l.Info("pipeline enqueued successfully", "id", msg.Rkey) 395 + } else { 396 + s.l.Error("failed to enqueue pipeline: queue is full") 423 397 } 424 - maps.Copy(ewf.Environment, pipelineEnv) 425 - 426 - workflows[eng] = append(workflows[eng], *ewf) 427 398 } 428 399 429 - // enqueue pipeline 430 - ok := s.jq.Enqueue(queue.Job{ 431 - Run: func() error { 432 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 433 - RepoOwner: tpl.TriggerMetadata.Repo.Did, 434 - RepoName: tpl.TriggerMetadata.Repo.Repo, 435 - Workflows: workflows, 436 - }, pipelineId) 437 - return nil 438 - }, 439 - OnFail: func(jobError error) { 440 - s.l.Error("pipeline run failed", "error", jobError) 441 - }, 442 - }) 443 - if !ok { 444 - return fmt.Errorf("failed to enqueue pipeline: queue is full") 445 - } 446 - s.l.Info("pipeline enqueued successfully", "id", pipelineId) 447 - 448 - // emit StatusPending for all workflows here (after successful enqueue) 449 - for _, ewfs := range workflows { 450 - for _, ewf := range ewfs { 451 - err := s.db.StatusPending(models.WorkflowId{ 452 - PipelineId: pipelineId, 453 - Name: ewf.Name, 454 - }, s.n) 455 - if err != nil { 456 - return fmt.Errorf("db.StatusPending: %w", err) 457 - } 458 - } 459 - } 460 400 return nil 461 401 } 462 402 463 - // newRepoPath creates a path to store repository by its did and rkey. 464 - // The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey 465 - func (s *Spindle) newRepoPath(did syntax.DID, rkey syntax.RecordKey) string { 466 - return filepath.Join(s.cfg.Server.RepoDir(), did.String(), tangled.RepoNSID, rkey.String()) 467 - } 403 + func (s *Spindle) configureOwner() error { 404 + cfgOwner := s.cfg.Server.Owner 468 405 469 - func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 470 - scheme := "https://" 471 - if s.cfg.Server.Dev { 472 - scheme = "http://" 406 + existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 407 + if err != nil { 408 + return err 473 409 } 474 - return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 475 - } 410 + 411 + switch len(existing) { 412 + case 0: 413 + // no owner configured, continue 414 + case 1: 415 + // find existing owner 416 + existingOwner := existing[0] 476 417 477 - const RequiredVersion = "2.49.0" 418 + // no ownership change, this is okay 419 + if existingOwner == s.cfg.Server.Owner { 420 + break 421 + } 478 422 479 - func ensureGitVersion() error { 480 - v, err := git.Version() 481 - if err != nil { 482 - return fmt.Errorf("fetching git version: %w", err) 483 - } 484 - if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 485 - return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 423 + // remove existing owner 424 + err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 425 + if err != nil { 426 + return nil 427 + } 428 + default: 429 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 486 430 } 487 - return nil 431 + 432 + return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 488 433 }
-391
spindle/tap.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "time" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/eventconsumer" 12 - "tangled.org/core/spindle/db" 13 - "tangled.org/core/spindle/git" 14 - "tangled.org/core/spindle/models" 15 - "tangled.org/core/tap" 16 - "tangled.org/core/tid" 17 - "tangled.org/core/workflow" 18 - ) 19 - 20 - func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error { 21 - l := s.l.With("component", "tapIndexer") 22 - 23 - var err error 24 - switch evt.Type { 25 - case tap.EvtRecord: 26 - switch evt.Record.Collection.String() { 27 - case tangled.SpindleMemberNSID: 28 - err = s.processMember(ctx, evt) 29 - case tangled.RepoNSID: 30 - err = s.processRepo(ctx, evt) 31 - case tangled.RepoCollaboratorNSID: 32 - err = s.processCollaborator(ctx, evt) 33 - case tangled.RepoPullNSID: 34 - err = s.processPull(ctx, evt) 35 - } 36 - case tap.EvtIdentity: 37 - // no-op 38 - } 39 - 40 - if err != nil { 41 - l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 42 - return err 43 - } 44 - return nil 45 - } 46 - 47 - // NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated) 48 - 49 - func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error { 50 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 51 - 52 - l.Info("processing spindle.member record") 53 - 54 - // only listen to members 55 - if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 56 - l.Warn("forbidden request: member invite not allowed", "did", evt.Record.Did, "error", err) 57 - return nil 58 - } 59 - 60 - switch evt.Record.Action { 61 - case tap.RecordCreateAction, tap.RecordUpdateAction: 62 - record := tangled.SpindleMember{} 63 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 64 - return fmt.Errorf("parsing record: %w", err) 65 - } 66 - 67 - domain := s.cfg.Server.Hostname 68 - if record.Instance != domain { 69 - l.Info("domain mismatch", "domain", record.Instance, "expected", domain) 70 - return nil 71 - } 72 - 73 - created, err := time.Parse(record.CreatedAt, time.RFC3339) 74 - if err != nil { 75 - created = time.Now() 76 - } 77 - if err := db.AddSpindleMember(s.db, db.SpindleMember{ 78 - Did: evt.Record.Did, 79 - Rkey: evt.Record.Rkey.String(), 80 - Instance: record.Instance, 81 - Subject: syntax.DID(record.Subject), 82 - Created: created, 83 - }); err != nil { 84 - l.Error("failed to add member", "error", err) 85 - return fmt.Errorf("adding member to db: %w", err) 86 - } 87 - if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil { 88 - return fmt.Errorf("adding member to rbac: %w", err) 89 - } 90 - if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 91 - return fmt.Errorf("adding did to tap", err) 92 - } 93 - 94 - l.Info("added member", "member", record.Subject) 95 - return nil 96 - 97 - case tap.RecordDeleteAction: 98 - var ( 99 - did = evt.Record.Did.String() 100 - rkey = evt.Record.Rkey.String() 101 - ) 102 - member, err := db.GetSpindleMember(s.db, did, rkey) 103 - if err != nil { 104 - return fmt.Errorf("finding member: %w", err) 105 - } 106 - 107 - if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 108 - return fmt.Errorf("removing member from db: %w", err) 109 - } 110 - if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil { 111 - return fmt.Errorf("removing member from rbac: %w", err) 112 - } 113 - if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil { 114 - return fmt.Errorf("removing did from tap: %w", err) 115 - } 116 - 117 - l.Info("removed member", "member", member.Subject) 118 - return nil 119 - } 120 - return nil 121 - } 122 - 123 - func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error { 124 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 125 - 126 - l.Info("processing repo.collaborator record") 127 - 128 - // only listen to members 129 - if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 130 - l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 131 - return nil 132 - } 133 - 134 - switch evt.Record.Action { 135 - case tap.RecordCreateAction, tap.RecordUpdateAction: 136 - record := tangled.RepoCollaborator{} 137 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 138 - l.Error("invalid record", "err", err) 139 - return fmt.Errorf("parsing record: %w", err) 140 - } 141 - 142 - // retry later if target repo is not ingested yet 143 - if _, err := s.db.GetRepo(syntax.ATURI(record.Repo)); err != nil { 144 - l.Warn("target repo is not ingested yet", "repo", record.Repo, "err", err) 145 - return fmt.Errorf("target repo is unknown") 146 - } 147 - 148 - // check perms for this user 149 - if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil { 150 - l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 151 - return nil 152 - } 153 - 154 - if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{ 155 - Did: evt.Record.Did, 156 - Rkey: evt.Record.Rkey, 157 - Repo: syntax.ATURI(record.Repo), 158 - Subject: syntax.DID(record.Subject), 159 - }); err != nil { 160 - return fmt.Errorf("adding collaborator to db: %w", err) 161 - } 162 - if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil { 163 - return fmt.Errorf("adding collaborator to rbac: %w", err) 164 - } 165 - if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 166 - return fmt.Errorf("adding did to tap: %w", err) 167 - } 168 - 169 - l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 170 - return nil 171 - 172 - case tap.RecordDeleteAction: 173 - // get existing collaborator 174 - collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey) 175 - if err != nil { 176 - return fmt.Errorf("failed to get existing collaborator info: %w", err) 177 - } 178 - 179 - // check perms for this user 180 - if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil { 181 - l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 182 - return nil 183 - } 184 - 185 - if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil { 186 - return fmt.Errorf("removing collaborator from db: %w", err) 187 - } 188 - if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil { 189 - return fmt.Errorf("removing collaborator from rbac: %w", err) 190 - } 191 - if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil { 192 - return fmt.Errorf("removing did from tap: %w", err) 193 - } 194 - 195 - l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo) 196 - return nil 197 - } 198 - return nil 199 - } 200 - 201 - func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error { 202 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 203 - 204 - l.Info("processing repo record") 205 - 206 - // only listen to members 207 - if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 208 - l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 209 - return nil 210 - } 211 - 212 - switch evt.Record.Action { 213 - case tap.RecordCreateAction, tap.RecordUpdateAction: 214 - record := tangled.Repo{} 215 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 216 - return fmt.Errorf("parsing record: %w", err) 217 - } 218 - 219 - domain := s.cfg.Server.Hostname 220 - if record.Spindle == nil || *record.Spindle != domain { 221 - if record.Spindle == nil { 222 - l.Info("spindle isn't configured", "name", record.Name) 223 - } else { 224 - l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 225 - } 226 - if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 227 - return fmt.Errorf("deleting repo from db: %w", err) 228 - } 229 - return nil 230 - } 231 - 232 - repo := &db.Repo{ 233 - Did: evt.Record.Did, 234 - Rkey: evt.Record.Rkey, 235 - Name: record.Name, 236 - Knot: record.Knot, 237 - } 238 - 239 - if err := s.db.PutRepo(repo); err != nil { 240 - return fmt.Errorf("adding repo to db: %w", err) 241 - } 242 - 243 - if err := s.e.AddRepo(evt.Record.AtUri()); err != nil { 244 - return fmt.Errorf("adding repo to rbac") 245 - } 246 - 247 - // add this knot to the event consumer 248 - src := eventconsumer.NewKnotSource(record.Knot) 249 - s.ks.AddSource(context.Background(), src) 250 - 251 - // setup sparse sync 252 - repoCloneUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 253 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 254 - if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, ""); err != nil { 255 - return fmt.Errorf("setting up sparse-clone git repo: %w", err) 256 - } 257 - 258 - l.Info("added repo", "repo", evt.Record.AtUri()) 259 - return nil 260 - 261 - case tap.RecordDeleteAction: 262 - // check perms for this user 263 - if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil { 264 - l.Warn("forbidden request: not repo owner", "did", evt.Record.Did, "err", err) 265 - return nil 266 - } 267 - 268 - if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 269 - return fmt.Errorf("deleting repo from db: %w", err) 270 - } 271 - 272 - if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil { 273 - return fmt.Errorf("deleting repo from rbac: %w", err) 274 - } 275 - 276 - l.Info("deleted repo", "repo", evt.Record.AtUri()) 277 - return nil 278 - } 279 - return nil 280 - } 281 - 282 - func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error { 283 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 284 - 285 - l.Info("processing pull record") 286 - 287 - // only listen to live events 288 - if !evt.Record.Live { 289 - l.Info("skipping backfill event", "event", evt.Record.AtUri()) 290 - return nil 291 - } 292 - 293 - switch evt.Record.Action { 294 - case tap.RecordCreateAction, tap.RecordUpdateAction: 295 - record := tangled.RepoPull{} 296 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 297 - l.Error("invalid record", "err", err) 298 - return fmt.Errorf("parsing record: %w", err) 299 - } 300 - 301 - // ignore legacy records 302 - if record.Target == nil { 303 - l.Info("ignoring pull record: target repo is nil") 304 - return nil 305 - } 306 - 307 - // ignore patch-based and fork-based PRs 308 - if record.Source == nil || record.Source.Repo != nil { 309 - l.Info("ignoring pull record: not a branch-based pull request") 310 - return nil 311 - } 312 - 313 - // skip if target repo is unknown 314 - repo, err := s.db.GetRepo(syntax.ATURI(record.Target.Repo)) 315 - if err != nil { 316 - l.Warn("target repo is not ingested yet", "repo", record.Target.Repo, "err", err) 317 - return fmt.Errorf("target repo is unknown") 318 - } 319 - 320 - compiler := workflow.Compiler{ 321 - Trigger: tangled.Pipeline_TriggerMetadata{ 322 - Kind: string(workflow.TriggerKindPullRequest), 323 - PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 324 - Action: "create", 325 - SourceBranch: record.Source.Branch, 326 - SourceSha: record.Source.Sha, 327 - TargetBranch: record.Target.Branch, 328 - }, 329 - Repo: &tangled.Pipeline_TriggerRepo{ 330 - Did: repo.Did.String(), 331 - Knot: repo.Knot, 332 - Repo: repo.Name, 333 - }, 334 - }, 335 - } 336 - 337 - repoUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 338 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 339 - 340 - // load workflow definitions from rev (without spindle context) 341 - rawPipeline, err := s.loadPipeline(ctx, repoUri, repoPath, record.Source.Sha) 342 - if err != nil { 343 - // don't retry 344 - l.Error("failed loading pipeline", "err", err) 345 - return nil 346 - } 347 - if len(rawPipeline) == 0 { 348 - l.Info("no workflow definition find for the repo. skipping the event") 349 - return nil 350 - } 351 - tpl := compiler.Compile(compiler.Parse(rawPipeline)) 352 - // TODO: pass compile error to workflow log 353 - for _, w := range compiler.Diagnostics.Errors { 354 - l.Error(w.String()) 355 - } 356 - for _, w := range compiler.Diagnostics.Warnings { 357 - l.Warn(w.String()) 358 - } 359 - 360 - pipelineId := models.PipelineId{ 361 - Knot: tpl.TriggerMetadata.Repo.Knot, 362 - Rkey: tid.TID(), 363 - } 364 - if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 365 - l.Error("failed to create pipeline event", "err", err) 366 - return nil 367 - } 368 - err = s.processPipeline(ctx, tpl, pipelineId) 369 - if err != nil { 370 - // don't retry 371 - l.Error("failed processing pipeline", "err", err) 372 - return nil 373 - } 374 - case tap.RecordDeleteAction: 375 - // no-op 376 - } 377 - return nil 378 - } 379 - 380 - func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error { 381 - known, err := s.db.IsKnownDid(syntax.DID(did)) 382 - if err != nil { 383 - return fmt.Errorf("ensuring did known state: %w", err) 384 - } 385 - if !known { 386 - if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 387 - return fmt.Errorf("removing did from tap: %w", err) 388 - } 389 - } 390 - return nil 391 - }
+2 -1
spindle/xrpc/add_secret.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 14 15 "tangled.org/core/spindle/secrets" 15 16 xrpcerr "tangled.org/core/xrpc/errors" 16 17 ) ··· 67 68 return 68 69 } 69 70 70 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 71 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 72 l.Error("insufficent permissions", "did", actorDid.String()) 72 73 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 74 return
+2 -1
spindle/xrpc/list_secrets.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 14 15 "tangled.org/core/spindle/secrets" 15 16 xrpcerr "tangled.org/core/xrpc/errors" 16 17 ) ··· 62 63 return 63 64 } 64 65 65 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 67 l.Error("insufficent permissions", "did", actorDid.String()) 67 68 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 69 return
+1 -1
spindle/xrpc/owner.go
··· 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 - owner := x.Config.Server.Owner.String() 12 + owner := x.Config.Server.Owner 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
-72
spindle/xrpc/pipeline_cancelPipeline.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "strings" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/spindle/models" 12 - xrpcerr "tangled.org/core/xrpc/errors" 13 - ) 14 - 15 - func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { 16 - l := x.Logger 17 - fail := func(e xrpcerr.XrpcError) { 18 - l.Error("failed", "kind", e.Tag, "error", e.Message) 19 - writeError(w, e, http.StatusBadRequest) 20 - } 21 - l.Debug("cancel pipeline") 22 - 23 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 24 - if !ok { 25 - fail(xrpcerr.MissingActorDidError) 26 - return 27 - } 28 - 29 - var input tangled.PipelineCancelPipeline_Input 30 - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 31 - fail(xrpcerr.GenericError(err)) 32 - return 33 - } 34 - 35 - aturi := syntax.ATURI(input.Pipeline) 36 - wid := models.WorkflowId{ 37 - PipelineId: models.PipelineId{ 38 - Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), 39 - Rkey: aturi.RecordKey().String(), 40 - }, 41 - Name: input.Workflow, 42 - } 43 - l.Debug("cancel pipeline", "wid", wid) 44 - 45 - // unfortunately we have to resolve repo-at here 46 - repoAt, err := syntax.ParseATURI(input.Repo) 47 - if err != nil { 48 - fail(xrpcerr.InvalidRepoError(input.Repo)) 49 - return 50 - } 51 - 52 - isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 53 - if err != nil || !isRepoOwner { 54 - fail(xrpcerr.AccessControlError(actorDid.String())) 55 - return 56 - } 57 - for _, engine := range x.Engines { 58 - l.Debug("destorying workflow", "wid", wid) 59 - err = engine.DestroyWorkflow(r.Context(), wid) 60 - if err != nil { 61 - fail(xrpcerr.GenericError(fmt.Errorf("dailed to destroy workflow: %w", err))) 62 - return 63 - } 64 - err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier) 65 - if err != nil { 66 - fail(xrpcerr.GenericError(fmt.Errorf("dailed to emit status failed: %w", err))) 67 - return 68 - } 69 - } 70 - 71 - w.WriteHeader(http.StatusOK) 72 - }
+2 -1
spindle/xrpc/remove_secret.go
··· 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "tangled.org/core/api/tangled" 13 + "tangled.org/core/rbac" 13 14 "tangled.org/core/spindle/secrets" 14 15 xrpcerr "tangled.org/core/xrpc/errors" 15 16 ) ··· 61 62 return 62 63 } 63 64 64 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 66 l.Error("insufficent permissions", "did", actorDid.String()) 66 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 68 return
+2 -5
spindle/xrpc/xrpc.go
··· 10 10 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/idresolver" 13 - "tangled.org/core/notifier" 14 - "tangled.org/core/rbac2" 13 + "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/config" 16 15 "tangled.org/core/spindle/db" 17 16 "tangled.org/core/spindle/models" ··· 25 24 type Xrpc struct { 26 25 Logger *slog.Logger 27 26 Db *db.DB 28 - Enforcer *rbac2.Enforcer 27 + Enforcer *rbac.Enforcer 29 28 Engines map[string]models.Engine 30 29 Config *config.Config 31 30 Resolver *idresolver.Resolver 32 31 Vault secrets.Manager 33 - Notifier *notifier.Notifier 34 32 ServiceAuth *serviceauth.ServiceAuth 35 33 } 36 34 ··· 43 41 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 44 42 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 45 43 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 46 - r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline) 47 44 }) 48 45 49 46 // service query endpoints (no auth required)
-24
tap/simpleIndexer.go
··· 1 - package tap 2 - 3 - import "context" 4 - 5 - type SimpleIndexer struct { 6 - EventHandler func(ctx context.Context, evt Event) error 7 - ErrorHandler func(ctx context.Context, err error) 8 - } 9 - 10 - var _ Handler = (*SimpleIndexer)(nil) 11 - 12 - func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 - if i.EventHandler == nil { 14 - return nil 15 - } 16 - return i.EventHandler(ctx, evt) 17 - } 18 - 19 - func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 20 - if i.ErrorHandler == nil { 21 - return 22 - } 23 - i.ErrorHandler(ctx, err) 24 - }
-169
tap/tap.go
··· 1 - /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 - 3 - package tap 4 - 5 - import ( 6 - "bytes" 7 - "context" 8 - "encoding/json" 9 - "fmt" 10 - "net/http" 11 - "net/url" 12 - 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/gorilla/websocket" 15 - "tangled.org/core/log" 16 - ) 17 - 18 - // type WebsocketOptions struct { 19 - // maxReconnectSeconds int 20 - // heartbeatIntervalMs int 21 - // // onReconnectError 22 - // } 23 - 24 - type Handler interface { 25 - OnEvent(ctx context.Context, evt Event) error 26 - OnError(ctx context.Context, err error) 27 - } 28 - 29 - type Client struct { 30 - Url string 31 - AdminPassword string 32 - HTTPClient *http.Client 33 - } 34 - 35 - func NewClient(url, adminPassword string) Client { 36 - return Client{ 37 - Url: url, 38 - AdminPassword: adminPassword, 39 - HTTPClient: &http.Client{}, 40 - } 41 - } 42 - 43 - func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 44 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 45 - if err != nil { 46 - return err 47 - } 48 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 49 - if err != nil { 50 - return err 51 - } 52 - req.SetBasicAuth("admin", c.AdminPassword) 53 - req.Header.Set("Content-Type", "application/json") 54 - 55 - resp, err := c.HTTPClient.Do(req) 56 - if err != nil { 57 - return err 58 - } 59 - defer resp.Body.Close() 60 - if resp.StatusCode != http.StatusOK { 61 - return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 62 - } 63 - return nil 64 - } 65 - 66 - func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 67 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 68 - if err != nil { 69 - return err 70 - } 71 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 72 - if err != nil { 73 - return err 74 - } 75 - req.SetBasicAuth("admin", c.AdminPassword) 76 - req.Header.Set("Content-Type", "application/json") 77 - 78 - resp, err := c.HTTPClient.Do(req) 79 - if err != nil { 80 - return err 81 - } 82 - defer resp.Body.Close() 83 - if resp.StatusCode != http.StatusOK { 84 - return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 85 - } 86 - return nil 87 - } 88 - 89 - func (c *Client) Connect(ctx context.Context, handler Handler) error { 90 - l := log.FromContext(ctx) 91 - 92 - u, err := url.Parse(c.Url) 93 - if err != nil { 94 - return err 95 - } 96 - if u.Scheme == "https" { 97 - u.Scheme = "wss" 98 - } else { 99 - u.Scheme = "ws" 100 - } 101 - u.Path = "/channel" 102 - 103 - // TODO: set auth on dial 104 - 105 - url := u.String() 106 - 107 - // var backoff int 108 - // for { 109 - // select { 110 - // case <-ctx.Done(): 111 - // return ctx.Err() 112 - // default: 113 - // } 114 - // 115 - // header := http.Header{ 116 - // "Authorization": []string{""}, 117 - // } 118 - // conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 119 - // if err != nil { 120 - // l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 121 - // time.Sleep(time.Duration(5+backoff) * time.Second) 122 - // backoff++ 123 - // 124 - // continue 125 - // } else { 126 - // backoff = 0 127 - // } 128 - // 129 - // l.Info("event subscription response", "code", res.StatusCode) 130 - // } 131 - 132 - // TODO: keep websocket connection alive 133 - conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 134 - if err != nil { 135 - return err 136 - } 137 - defer conn.Close() 138 - 139 - for { 140 - select { 141 - case <-ctx.Done(): 142 - return ctx.Err() 143 - default: 144 - } 145 - _, message, err := conn.ReadMessage() 146 - if err != nil { 147 - return err 148 - } 149 - 150 - var ev Event 151 - if err := json.Unmarshal(message, &ev); err != nil { 152 - handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 153 - continue 154 - } 155 - if err := handler.OnEvent(ctx, ev); err != nil { 156 - handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 157 - continue 158 - } 159 - 160 - ack := map[string]any{ 161 - "type": "ack", 162 - "id": ev.ID, 163 - } 164 - if err := conn.WriteJSON(ack); err != nil { 165 - l.Warn("failed to send ack", "err", err) 166 - continue 167 - } 168 - } 169 - }
-62
tap/types.go
··· 1 - package tap 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type EventType string 11 - 12 - const ( 13 - EvtRecord EventType = "record" 14 - EvtIdentity EventType = "identity" 15 - ) 16 - 17 - type Event struct { 18 - ID int64 `json:"id"` 19 - Type EventType `json:"type"` 20 - Record *RecordEventData `json:"record,omitempty"` 21 - Identity *IdentityEventData `json:"identity,omitempty"` 22 - } 23 - 24 - type RecordEventData struct { 25 - Live bool `json:"live"` 26 - Did syntax.DID `json:"did"` 27 - Rev string `json:"rev"` 28 - Collection syntax.NSID `json:"collection"` 29 - Rkey syntax.RecordKey `json:"rkey"` 30 - Action RecordAction `json:"action"` 31 - Record json.RawMessage `json:"record,omitempty"` 32 - CID *syntax.CID `json:"cid,omitempty"` 33 - } 34 - 35 - func (r *RecordEventData) AtUri() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 - } 38 - 39 - type RecordAction string 40 - 41 - const ( 42 - RecordCreateAction RecordAction = "create" 43 - RecordUpdateAction RecordAction = "update" 44 - RecordDeleteAction RecordAction = "delete" 45 - ) 46 - 47 - type IdentityEventData struct { 48 - DID syntax.DID `json:"did"` 49 - Handle string `json:"handle"` 50 - IsActive bool `json:"is_active"` 51 - Status RepoStatus `json:"status"` 52 - } 53 - 54 - type RepoStatus string 55 - 56 - const ( 57 - RepoStatusActive RepoStatus = "active" 58 - RepoStatusTakendown RepoStatus = "takendown" 59 - RepoStatusSuspended RepoStatus = "suspended" 60 - RepoStatusDeactivated RepoStatus = "deactivated" 61 - RepoStatusDeleted RepoStatus = "deleted" 62 - )
+3
types/diff.go
··· 74 74 75 75 // used by html elements as a unique ID for hrefs 76 76 func (d *Diff) Id() string { 77 + if d.IsDelete { 78 + return d.Name.Old 79 + } 77 80 return d.Name.New 78 81 } 79 82
+112
types/diff_test.go
··· 1 + package types 2 + 3 + import "testing" 4 + 5 + func TestDiffId(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + diff Diff 9 + expected string 10 + }{ 11 + { 12 + name: "regular file uses new name", 13 + diff: Diff{ 14 + Name: struct { 15 + Old string `json:"old"` 16 + New string `json:"new"` 17 + }{Old: "", New: "src/main.go"}, 18 + }, 19 + expected: "src/main.go", 20 + }, 21 + { 22 + name: "new file uses new name", 23 + diff: Diff{ 24 + Name: struct { 25 + Old string `json:"old"` 26 + New string `json:"new"` 27 + }{Old: "", New: "src/new.go"}, 28 + IsNew: true, 29 + }, 30 + expected: "src/new.go", 31 + }, 32 + { 33 + name: "deleted file uses old name", 34 + diff: Diff{ 35 + Name: struct { 36 + Old string `json:"old"` 37 + New string `json:"new"` 38 + }{Old: "src/deleted.go", New: ""}, 39 + IsDelete: true, 40 + }, 41 + expected: "src/deleted.go", 42 + }, 43 + { 44 + name: "renamed file uses new name", 45 + diff: Diff{ 46 + Name: struct { 47 + Old string `json:"old"` 48 + New string `json:"new"` 49 + }{Old: "src/old.go", New: "src/renamed.go"}, 50 + IsRename: true, 51 + }, 52 + expected: "src/renamed.go", 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + if got := tt.diff.Id(); got != tt.expected { 59 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 60 + } 61 + }) 62 + } 63 + } 64 + 65 + func TestChangedFilesMatchesDiffId(t *testing.T) { 66 + // ChangedFiles() must return values matching each Diff's Id() 67 + // so that sidebar links point to the correct anchors. 68 + // Tests existing, deleted, new, and renamed files. 69 + nd := NiceDiff{ 70 + Diff: []Diff{ 71 + { 72 + Name: struct { 73 + Old string `json:"old"` 74 + New string `json:"new"` 75 + }{Old: "", New: "src/modified.go"}, 76 + }, 77 + { 78 + Name: struct { 79 + Old string `json:"old"` 80 + New string `json:"new"` 81 + }{Old: "src/deleted.go", New: ""}, 82 + IsDelete: true, 83 + }, 84 + { 85 + Name: struct { 86 + Old string `json:"old"` 87 + New string `json:"new"` 88 + }{Old: "", New: "src/new.go"}, 89 + IsNew: true, 90 + }, 91 + { 92 + Name: struct { 93 + Old string `json:"old"` 94 + New string `json:"new"` 95 + }{Old: "src/old.go", New: "src/renamed.go"}, 96 + IsRename: true, 97 + }, 98 + }, 99 + } 100 + 101 + changedFiles := nd.ChangedFiles() 102 + 103 + if len(changedFiles) != len(nd.Diff) { 104 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 105 + } 106 + 107 + for i, diff := range nd.Diff { 108 + if changedFiles[i] != diff.Id() { 109 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 110 + } 111 + } 112 + }