A vibe coded tangled fork which supports pijul.

Add pijul support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+9053 -111
+1459
api/tangled/cbor_gen.go
··· 3625 3625 3626 3626 return nil 3627 3627 } 3628 + func (t *PijulRefUpdate) MarshalCBOR(w io.Writer) error { 3629 + if t == nil { 3630 + _, err := w.Write(cbg.CborNull) 3631 + return err 3632 + } 3633 + 3634 + cw := cbg.NewCborWriter(w) 3635 + fieldCount := 7 3636 + 3637 + if t.Languages == nil { 3638 + fieldCount-- 3639 + } 3640 + 3641 + if t.OldState == nil { 3642 + fieldCount-- 3643 + } 3644 + 3645 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3646 + return err 3647 + } 3648 + 3649 + // t.Repo (string) (string) 3650 + if len("repo") > 1000000 { 3651 + return xerrors.Errorf("Value in field \"repo\" was too long") 3652 + } 3653 + 3654 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 3655 + return err 3656 + } 3657 + if _, err := cw.WriteString(string("repo")); err != nil { 3658 + return err 3659 + } 3660 + 3661 + if len(t.Repo) > 1000000 { 3662 + return xerrors.Errorf("Value in field t.Repo was too long") 3663 + } 3664 + 3665 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 3666 + return err 3667 + } 3668 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 3669 + return err 3670 + } 3671 + 3672 + // t.LexiconTypeID (string) (string) 3673 + if len("$type") > 1000000 { 3674 + return xerrors.Errorf("Value in field \"$type\" was too long") 3675 + } 3676 + 3677 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3678 + return err 3679 + } 3680 + if _, err := cw.WriteString(string("$type")); err != nil { 3681 + return err 3682 + } 3683 + 3684 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.pijul.refUpdate"))); err != nil { 3685 + return err 3686 + } 3687 + if _, err := cw.WriteString(string("sh.tangled.pijul.refUpdate")); err != nil { 3688 + return err 3689 + } 3690 + 3691 + // t.Changes ([]string) (slice) 3692 + if len("changes") > 1000000 { 3693 + return xerrors.Errorf("Value in field \"changes\" was too long") 3694 + } 3695 + 3696 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("changes"))); err != nil { 3697 + return err 3698 + } 3699 + if _, err := cw.WriteString(string("changes")); err != nil { 3700 + return err 3701 + } 3702 + 3703 + if len(t.Changes) > 8192 { 3704 + return xerrors.Errorf("Slice value in field t.Changes was too long") 3705 + } 3706 + 3707 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Changes))); err != nil { 3708 + return err 3709 + } 3710 + for _, v := range t.Changes { 3711 + if len(v) > 1000000 { 3712 + return xerrors.Errorf("Value in field v was too long") 3713 + } 3714 + 3715 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3716 + return err 3717 + } 3718 + if _, err := cw.WriteString(string(v)); err != nil { 3719 + return err 3720 + } 3721 + 3722 + } 3723 + 3724 + // t.Channel (string) (string) 3725 + if len("channel") > 1000000 { 3726 + return xerrors.Errorf("Value in field \"channel\" was too long") 3727 + } 3728 + 3729 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("channel"))); err != nil { 3730 + return err 3731 + } 3732 + if _, err := cw.WriteString(string("channel")); err != nil { 3733 + return err 3734 + } 3735 + 3736 + if len(t.Channel) > 1000000 { 3737 + return xerrors.Errorf("Value in field t.Channel was too long") 3738 + } 3739 + 3740 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Channel))); err != nil { 3741 + return err 3742 + } 3743 + if _, err := cw.WriteString(string(t.Channel)); err != nil { 3744 + return err 3745 + } 3746 + 3747 + // t.NewState (string) (string) 3748 + if len("newState") > 1000000 { 3749 + return xerrors.Errorf("Value in field \"newState\" was too long") 3750 + } 3751 + 3752 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("newState"))); err != nil { 3753 + return err 3754 + } 3755 + if _, err := cw.WriteString(string("newState")); err != nil { 3756 + return err 3757 + } 3758 + 3759 + if len(t.NewState) > 1000000 { 3760 + return xerrors.Errorf("Value in field t.NewState was too long") 3761 + } 3762 + 3763 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.NewState))); err != nil { 3764 + return err 3765 + } 3766 + if _, err := cw.WriteString(string(t.NewState)); err != nil { 3767 + return err 3768 + } 3769 + 3770 + // t.OldState (string) (string) 3771 + if t.OldState != nil { 3772 + 3773 + if len("oldState") > 1000000 { 3774 + return xerrors.Errorf("Value in field \"oldState\" was too long") 3775 + } 3776 + 3777 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oldState"))); err != nil { 3778 + return err 3779 + } 3780 + if _, err := cw.WriteString(string("oldState")); err != nil { 3781 + return err 3782 + } 3783 + 3784 + if t.OldState == nil { 3785 + if _, err := cw.Write(cbg.CborNull); err != nil { 3786 + return err 3787 + } 3788 + } else { 3789 + if len(*t.OldState) > 1000000 { 3790 + return xerrors.Errorf("Value in field t.OldState was too long") 3791 + } 3792 + 3793 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.OldState))); err != nil { 3794 + return err 3795 + } 3796 + if _, err := cw.WriteString(string(*t.OldState)); err != nil { 3797 + return err 3798 + } 3799 + } 3800 + } 3801 + 3802 + // t.Languages (tangled.PijulRefUpdate_Languages) (struct) 3803 + if t.Languages != nil { 3804 + 3805 + if len("languages") > 1000000 { 3806 + return xerrors.Errorf("Value in field \"languages\" was too long") 3807 + } 3808 + 3809 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("languages"))); err != nil { 3810 + return err 3811 + } 3812 + if _, err := cw.WriteString(string("languages")); err != nil { 3813 + return err 3814 + } 3815 + 3816 + if err := t.Languages.MarshalCBOR(cw); err != nil { 3817 + return err 3818 + } 3819 + } 3820 + return nil 3821 + } 3822 + 3823 + func (t *PijulRefUpdate) UnmarshalCBOR(r io.Reader) (err error) { 3824 + *t = PijulRefUpdate{} 3825 + 3826 + cr := cbg.NewCborReader(r) 3827 + 3828 + maj, extra, err := cr.ReadHeader() 3829 + if err != nil { 3830 + return err 3831 + } 3832 + defer func() { 3833 + if err == io.EOF { 3834 + err = io.ErrUnexpectedEOF 3835 + } 3836 + }() 3837 + 3838 + if maj != cbg.MajMap { 3839 + return fmt.Errorf("cbor input should be of type map") 3840 + } 3841 + 3842 + if extra > cbg.MaxLength { 3843 + return fmt.Errorf("PijulRefUpdate: map struct too large (%d)", extra) 3844 + } 3845 + 3846 + n := extra 3847 + 3848 + nameBuf := make([]byte, 9) 3849 + for i := uint64(0); i < n; i++ { 3850 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3851 + if err != nil { 3852 + return err 3853 + } 3854 + 3855 + if !ok { 3856 + // Field doesn't exist on this type, so ignore it 3857 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3858 + return err 3859 + } 3860 + continue 3861 + } 3862 + 3863 + switch string(nameBuf[:nameLen]) { 3864 + // t.Repo (string) (string) 3865 + case "repo": 3866 + 3867 + { 3868 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3869 + if err != nil { 3870 + return err 3871 + } 3872 + 3873 + t.Repo = string(sval) 3874 + } 3875 + // t.LexiconTypeID (string) (string) 3876 + case "$type": 3877 + 3878 + { 3879 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3880 + if err != nil { 3881 + return err 3882 + } 3883 + 3884 + t.LexiconTypeID = string(sval) 3885 + } 3886 + // t.Changes ([]string) (slice) 3887 + case "changes": 3888 + 3889 + maj, extra, err = cr.ReadHeader() 3890 + if err != nil { 3891 + return err 3892 + } 3893 + 3894 + if extra > 8192 { 3895 + return fmt.Errorf("t.Changes: array too large (%d)", extra) 3896 + } 3897 + 3898 + if maj != cbg.MajArray { 3899 + return fmt.Errorf("expected cbor array") 3900 + } 3901 + 3902 + if extra > 0 { 3903 + t.Changes = make([]string, extra) 3904 + } 3905 + 3906 + for i := 0; i < int(extra); i++ { 3907 + { 3908 + var maj byte 3909 + var extra uint64 3910 + var err error 3911 + _ = maj 3912 + _ = extra 3913 + _ = err 3914 + 3915 + { 3916 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3917 + if err != nil { 3918 + return err 3919 + } 3920 + 3921 + t.Changes[i] = string(sval) 3922 + } 3923 + 3924 + } 3925 + } 3926 + // t.Channel (string) (string) 3927 + case "channel": 3928 + 3929 + { 3930 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3931 + if err != nil { 3932 + return err 3933 + } 3934 + 3935 + t.Channel = string(sval) 3936 + } 3937 + // t.NewState (string) (string) 3938 + case "newState": 3939 + 3940 + { 3941 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3942 + if err != nil { 3943 + return err 3944 + } 3945 + 3946 + t.NewState = string(sval) 3947 + } 3948 + // t.OldState (string) (string) 3949 + case "oldState": 3950 + 3951 + { 3952 + b, err := cr.ReadByte() 3953 + if err != nil { 3954 + return err 3955 + } 3956 + if b != cbg.CborNull[0] { 3957 + if err := cr.UnreadByte(); err != nil { 3958 + return err 3959 + } 3960 + 3961 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3962 + if err != nil { 3963 + return err 3964 + } 3965 + 3966 + t.OldState = (*string)(&sval) 3967 + } 3968 + } 3969 + // t.Languages (tangled.PijulRefUpdate_Languages) (struct) 3970 + case "languages": 3971 + 3972 + { 3973 + 3974 + b, err := cr.ReadByte() 3975 + if err != nil { 3976 + return err 3977 + } 3978 + if b != cbg.CborNull[0] { 3979 + if err := cr.UnreadByte(); err != nil { 3980 + return err 3981 + } 3982 + t.Languages = new(PijulRefUpdate_Languages) 3983 + if err := t.Languages.UnmarshalCBOR(cr); err != nil { 3984 + return xerrors.Errorf("unmarshaling t.Languages pointer: %w", err) 3985 + } 3986 + } 3987 + 3988 + } 3989 + 3990 + default: 3991 + // Field doesn't exist on this type, so ignore it 3992 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3993 + return err 3994 + } 3995 + } 3996 + } 3997 + 3998 + return nil 3999 + } 3628 4000 func (t *Pipeline) MarshalCBOR(w io.Writer) error { 3629 4001 if t == nil { 3630 4002 _, err := w.Write(cbg.CborNull) ··· 6962 7334 } 6963 7335 6964 7336 t.CreatedAt = string(sval) 7337 + } 7338 + 7339 + default: 7340 + // Field doesn't exist on this type, so ignore it 7341 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7342 + return err 7343 + } 7344 + } 7345 + } 7346 + 7347 + return nil 7348 + } 7349 + func (t *RepoDiscussion) MarshalCBOR(w io.Writer) error { 7350 + if t == nil { 7351 + _, err := w.Write(cbg.CborNull) 7352 + return err 7353 + } 7354 + 7355 + cw := cbg.NewCborWriter(w) 7356 + fieldCount := 8 7357 + 7358 + if t.Body == nil { 7359 + fieldCount-- 7360 + } 7361 + 7362 + if t.Mentions == nil { 7363 + fieldCount-- 7364 + } 7365 + 7366 + if t.References == nil { 7367 + fieldCount-- 7368 + } 7369 + 7370 + if t.TargetChannel == nil { 7371 + fieldCount-- 7372 + } 7373 + 7374 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7375 + return err 7376 + } 7377 + 7378 + // t.Body (string) (string) 7379 + if t.Body != nil { 7380 + 7381 + if len("body") > 1000000 { 7382 + return xerrors.Errorf("Value in field \"body\" was too long") 7383 + } 7384 + 7385 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 7386 + return err 7387 + } 7388 + if _, err := cw.WriteString(string("body")); err != nil { 7389 + return err 7390 + } 7391 + 7392 + if t.Body == nil { 7393 + if _, err := cw.Write(cbg.CborNull); err != nil { 7394 + return err 7395 + } 7396 + } else { 7397 + if len(*t.Body) > 1000000 { 7398 + return xerrors.Errorf("Value in field t.Body was too long") 7399 + } 7400 + 7401 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Body))); err != nil { 7402 + return err 7403 + } 7404 + if _, err := cw.WriteString(string(*t.Body)); err != nil { 7405 + return err 7406 + } 7407 + } 7408 + } 7409 + 7410 + // t.Repo (string) (string) 7411 + if len("repo") > 1000000 { 7412 + return xerrors.Errorf("Value in field \"repo\" was too long") 7413 + } 7414 + 7415 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7416 + return err 7417 + } 7418 + if _, err := cw.WriteString(string("repo")); err != nil { 7419 + return err 7420 + } 7421 + 7422 + if len(t.Repo) > 1000000 { 7423 + return xerrors.Errorf("Value in field t.Repo was too long") 7424 + } 7425 + 7426 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 7427 + return err 7428 + } 7429 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 7430 + return err 7431 + } 7432 + 7433 + // t.LexiconTypeID (string) (string) 7434 + if len("$type") > 1000000 { 7435 + return xerrors.Errorf("Value in field \"$type\" was too long") 7436 + } 7437 + 7438 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 7439 + return err 7440 + } 7441 + if _, err := cw.WriteString(string("$type")); err != nil { 7442 + return err 7443 + } 7444 + 7445 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.discussion"))); err != nil { 7446 + return err 7447 + } 7448 + if _, err := cw.WriteString(string("sh.tangled.repo.discussion")); err != nil { 7449 + return err 7450 + } 7451 + 7452 + // t.Title (string) (string) 7453 + if len("title") > 1000000 { 7454 + return xerrors.Errorf("Value in field \"title\" was too long") 7455 + } 7456 + 7457 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("title"))); err != nil { 7458 + return err 7459 + } 7460 + if _, err := cw.WriteString(string("title")); err != nil { 7461 + return err 7462 + } 7463 + 7464 + if len(t.Title) > 1000000 { 7465 + return xerrors.Errorf("Value in field t.Title was too long") 7466 + } 7467 + 7468 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Title))); err != nil { 7469 + return err 7470 + } 7471 + if _, err := cw.WriteString(string(t.Title)); err != nil { 7472 + return err 7473 + } 7474 + 7475 + // t.Mentions ([]string) (slice) 7476 + if t.Mentions != nil { 7477 + 7478 + if len("mentions") > 1000000 { 7479 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7480 + } 7481 + 7482 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7483 + return err 7484 + } 7485 + if _, err := cw.WriteString(string("mentions")); err != nil { 7486 + return err 7487 + } 7488 + 7489 + if len(t.Mentions) > 8192 { 7490 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7491 + } 7492 + 7493 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7494 + return err 7495 + } 7496 + for _, v := range t.Mentions { 7497 + if len(v) > 1000000 { 7498 + return xerrors.Errorf("Value in field v was too long") 7499 + } 7500 + 7501 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7502 + return err 7503 + } 7504 + if _, err := cw.WriteString(string(v)); err != nil { 7505 + return err 7506 + } 7507 + 7508 + } 7509 + } 7510 + 7511 + // t.CreatedAt (string) (string) 7512 + if len("createdAt") > 1000000 { 7513 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7514 + } 7515 + 7516 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7517 + return err 7518 + } 7519 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7520 + return err 7521 + } 7522 + 7523 + if len(t.CreatedAt) > 1000000 { 7524 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7525 + } 7526 + 7527 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7528 + return err 7529 + } 7530 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7531 + return err 7532 + } 7533 + 7534 + // t.References ([]string) (slice) 7535 + if t.References != nil { 7536 + 7537 + if len("references") > 1000000 { 7538 + return xerrors.Errorf("Value in field \"references\" was too long") 7539 + } 7540 + 7541 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7542 + return err 7543 + } 7544 + if _, err := cw.WriteString(string("references")); err != nil { 7545 + return err 7546 + } 7547 + 7548 + if len(t.References) > 8192 { 7549 + return xerrors.Errorf("Slice value in field t.References was too long") 7550 + } 7551 + 7552 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7553 + return err 7554 + } 7555 + for _, v := range t.References { 7556 + if len(v) > 1000000 { 7557 + return xerrors.Errorf("Value in field v was too long") 7558 + } 7559 + 7560 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7561 + return err 7562 + } 7563 + if _, err := cw.WriteString(string(v)); err != nil { 7564 + return err 7565 + } 7566 + 7567 + } 7568 + } 7569 + 7570 + // t.TargetChannel (string) (string) 7571 + if t.TargetChannel != nil { 7572 + 7573 + if len("targetChannel") > 1000000 { 7574 + return xerrors.Errorf("Value in field \"targetChannel\" was too long") 7575 + } 7576 + 7577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetChannel"))); err != nil { 7578 + return err 7579 + } 7580 + if _, err := cw.WriteString(string("targetChannel")); err != nil { 7581 + return err 7582 + } 7583 + 7584 + if t.TargetChannel == nil { 7585 + if _, err := cw.Write(cbg.CborNull); err != nil { 7586 + return err 7587 + } 7588 + } else { 7589 + if len(*t.TargetChannel) > 1000000 { 7590 + return xerrors.Errorf("Value in field t.TargetChannel was too long") 7591 + } 7592 + 7593 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.TargetChannel))); err != nil { 7594 + return err 7595 + } 7596 + if _, err := cw.WriteString(string(*t.TargetChannel)); err != nil { 7597 + return err 7598 + } 7599 + } 7600 + } 7601 + return nil 7602 + } 7603 + 7604 + func (t *RepoDiscussion) UnmarshalCBOR(r io.Reader) (err error) { 7605 + *t = RepoDiscussion{} 7606 + 7607 + cr := cbg.NewCborReader(r) 7608 + 7609 + maj, extra, err := cr.ReadHeader() 7610 + if err != nil { 7611 + return err 7612 + } 7613 + defer func() { 7614 + if err == io.EOF { 7615 + err = io.ErrUnexpectedEOF 7616 + } 7617 + }() 7618 + 7619 + if maj != cbg.MajMap { 7620 + return fmt.Errorf("cbor input should be of type map") 7621 + } 7622 + 7623 + if extra > cbg.MaxLength { 7624 + return fmt.Errorf("RepoDiscussion: map struct too large (%d)", extra) 7625 + } 7626 + 7627 + n := extra 7628 + 7629 + nameBuf := make([]byte, 13) 7630 + for i := uint64(0); i < n; i++ { 7631 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7632 + if err != nil { 7633 + return err 7634 + } 7635 + 7636 + if !ok { 7637 + // Field doesn't exist on this type, so ignore it 7638 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7639 + return err 7640 + } 7641 + continue 7642 + } 7643 + 7644 + switch string(nameBuf[:nameLen]) { 7645 + // t.Body (string) (string) 7646 + case "body": 7647 + 7648 + { 7649 + b, err := cr.ReadByte() 7650 + if err != nil { 7651 + return err 7652 + } 7653 + if b != cbg.CborNull[0] { 7654 + if err := cr.UnreadByte(); err != nil { 7655 + return err 7656 + } 7657 + 7658 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7659 + if err != nil { 7660 + return err 7661 + } 7662 + 7663 + t.Body = (*string)(&sval) 7664 + } 7665 + } 7666 + // t.Repo (string) (string) 7667 + case "repo": 7668 + 7669 + { 7670 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7671 + if err != nil { 7672 + return err 7673 + } 7674 + 7675 + t.Repo = string(sval) 7676 + } 7677 + // t.LexiconTypeID (string) (string) 7678 + case "$type": 7679 + 7680 + { 7681 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7682 + if err != nil { 7683 + return err 7684 + } 7685 + 7686 + t.LexiconTypeID = string(sval) 7687 + } 7688 + // t.Title (string) (string) 7689 + case "title": 7690 + 7691 + { 7692 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7693 + if err != nil { 7694 + return err 7695 + } 7696 + 7697 + t.Title = string(sval) 7698 + } 7699 + // t.Mentions ([]string) (slice) 7700 + case "mentions": 7701 + 7702 + maj, extra, err = cr.ReadHeader() 7703 + if err != nil { 7704 + return err 7705 + } 7706 + 7707 + if extra > 8192 { 7708 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7709 + } 7710 + 7711 + if maj != cbg.MajArray { 7712 + return fmt.Errorf("expected cbor array") 7713 + } 7714 + 7715 + if extra > 0 { 7716 + t.Mentions = make([]string, extra) 7717 + } 7718 + 7719 + for i := 0; i < int(extra); i++ { 7720 + { 7721 + var maj byte 7722 + var extra uint64 7723 + var err error 7724 + _ = maj 7725 + _ = extra 7726 + _ = err 7727 + 7728 + { 7729 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7730 + if err != nil { 7731 + return err 7732 + } 7733 + 7734 + t.Mentions[i] = string(sval) 7735 + } 7736 + 7737 + } 7738 + } 7739 + // t.CreatedAt (string) (string) 7740 + case "createdAt": 7741 + 7742 + { 7743 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7744 + if err != nil { 7745 + return err 7746 + } 7747 + 7748 + t.CreatedAt = string(sval) 7749 + } 7750 + // t.References ([]string) (slice) 7751 + case "references": 7752 + 7753 + maj, extra, err = cr.ReadHeader() 7754 + if err != nil { 7755 + return err 7756 + } 7757 + 7758 + if extra > 8192 { 7759 + return fmt.Errorf("t.References: array too large (%d)", extra) 7760 + } 7761 + 7762 + if maj != cbg.MajArray { 7763 + return fmt.Errorf("expected cbor array") 7764 + } 7765 + 7766 + if extra > 0 { 7767 + t.References = make([]string, extra) 7768 + } 7769 + 7770 + for i := 0; i < int(extra); i++ { 7771 + { 7772 + var maj byte 7773 + var extra uint64 7774 + var err error 7775 + _ = maj 7776 + _ = extra 7777 + _ = err 7778 + 7779 + { 7780 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7781 + if err != nil { 7782 + return err 7783 + } 7784 + 7785 + t.References[i] = string(sval) 7786 + } 7787 + 7788 + } 7789 + } 7790 + // t.TargetChannel (string) (string) 7791 + case "targetChannel": 7792 + 7793 + { 7794 + b, err := cr.ReadByte() 7795 + if err != nil { 7796 + return err 7797 + } 7798 + if b != cbg.CborNull[0] { 7799 + if err := cr.UnreadByte(); err != nil { 7800 + return err 7801 + } 7802 + 7803 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7804 + if err != nil { 7805 + return err 7806 + } 7807 + 7808 + t.TargetChannel = (*string)(&sval) 7809 + } 7810 + } 7811 + 7812 + default: 7813 + // Field doesn't exist on this type, so ignore it 7814 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7815 + return err 7816 + } 7817 + } 7818 + } 7819 + 7820 + return nil 7821 + } 7822 + func (t *RepoDiscussionComment) MarshalCBOR(w io.Writer) error { 7823 + if t == nil { 7824 + _, err := w.Write(cbg.CborNull) 7825 + return err 7826 + } 7827 + 7828 + cw := cbg.NewCborWriter(w) 7829 + fieldCount := 7 7830 + 7831 + if t.Mentions == nil { 7832 + fieldCount-- 7833 + } 7834 + 7835 + if t.References == nil { 7836 + fieldCount-- 7837 + } 7838 + 7839 + if t.ReplyTo == nil { 7840 + fieldCount-- 7841 + } 7842 + 7843 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7844 + return err 7845 + } 7846 + 7847 + // t.Body (string) (string) 7848 + if len("body") > 1000000 { 7849 + return xerrors.Errorf("Value in field \"body\" was too long") 7850 + } 7851 + 7852 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 7853 + return err 7854 + } 7855 + if _, err := cw.WriteString(string("body")); err != nil { 7856 + return err 7857 + } 7858 + 7859 + if len(t.Body) > 1000000 { 7860 + return xerrors.Errorf("Value in field t.Body was too long") 7861 + } 7862 + 7863 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 7864 + return err 7865 + } 7866 + if _, err := cw.WriteString(string(t.Body)); err != nil { 7867 + return err 7868 + } 7869 + 7870 + // t.LexiconTypeID (string) (string) 7871 + if len("$type") > 1000000 { 7872 + return xerrors.Errorf("Value in field \"$type\" was too long") 7873 + } 7874 + 7875 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 7876 + return err 7877 + } 7878 + if _, err := cw.WriteString(string("$type")); err != nil { 7879 + return err 7880 + } 7881 + 7882 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.discussion.comment"))); err != nil { 7883 + return err 7884 + } 7885 + if _, err := cw.WriteString(string("sh.tangled.repo.discussion.comment")); err != nil { 7886 + return err 7887 + } 7888 + 7889 + // t.ReplyTo (string) (string) 7890 + if t.ReplyTo != nil { 7891 + 7892 + if len("replyTo") > 1000000 { 7893 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 7894 + } 7895 + 7896 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 7897 + return err 7898 + } 7899 + if _, err := cw.WriteString(string("replyTo")); err != nil { 7900 + return err 7901 + } 7902 + 7903 + if t.ReplyTo == nil { 7904 + if _, err := cw.Write(cbg.CborNull); err != nil { 7905 + return err 7906 + } 7907 + } else { 7908 + if len(*t.ReplyTo) > 1000000 { 7909 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 7910 + } 7911 + 7912 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 7913 + return err 7914 + } 7915 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 7916 + return err 7917 + } 7918 + } 7919 + } 7920 + 7921 + // t.Mentions ([]string) (slice) 7922 + if t.Mentions != nil { 7923 + 7924 + if len("mentions") > 1000000 { 7925 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7926 + } 7927 + 7928 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7929 + return err 7930 + } 7931 + if _, err := cw.WriteString(string("mentions")); err != nil { 7932 + return err 7933 + } 7934 + 7935 + if len(t.Mentions) > 8192 { 7936 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7937 + } 7938 + 7939 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7940 + return err 7941 + } 7942 + for _, v := range t.Mentions { 7943 + if len(v) > 1000000 { 7944 + return xerrors.Errorf("Value in field v was too long") 7945 + } 7946 + 7947 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7948 + return err 7949 + } 7950 + if _, err := cw.WriteString(string(v)); err != nil { 7951 + return err 7952 + } 7953 + 7954 + } 7955 + } 7956 + 7957 + // t.CreatedAt (string) (string) 7958 + if len("createdAt") > 1000000 { 7959 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7960 + } 7961 + 7962 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7963 + return err 7964 + } 7965 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7966 + return err 7967 + } 7968 + 7969 + if len(t.CreatedAt) > 1000000 { 7970 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7971 + } 7972 + 7973 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7974 + return err 7975 + } 7976 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7977 + return err 7978 + } 7979 + 7980 + // t.Discussion (string) (string) 7981 + if len("discussion") > 1000000 { 7982 + return xerrors.Errorf("Value in field \"discussion\" was too long") 7983 + } 7984 + 7985 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("discussion"))); err != nil { 7986 + return err 7987 + } 7988 + if _, err := cw.WriteString(string("discussion")); err != nil { 7989 + return err 7990 + } 7991 + 7992 + if len(t.Discussion) > 1000000 { 7993 + return xerrors.Errorf("Value in field t.Discussion was too long") 7994 + } 7995 + 7996 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Discussion))); err != nil { 7997 + return err 7998 + } 7999 + if _, err := cw.WriteString(string(t.Discussion)); err != nil { 8000 + return err 8001 + } 8002 + 8003 + // t.References ([]string) (slice) 8004 + if t.References != nil { 8005 + 8006 + if len("references") > 1000000 { 8007 + return xerrors.Errorf("Value in field \"references\" was too long") 8008 + } 8009 + 8010 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8011 + return err 8012 + } 8013 + if _, err := cw.WriteString(string("references")); err != nil { 8014 + return err 8015 + } 8016 + 8017 + if len(t.References) > 8192 { 8018 + return xerrors.Errorf("Slice value in field t.References was too long") 8019 + } 8020 + 8021 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8022 + return err 8023 + } 8024 + for _, v := range t.References { 8025 + if len(v) > 1000000 { 8026 + return xerrors.Errorf("Value in field v was too long") 8027 + } 8028 + 8029 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8030 + return err 8031 + } 8032 + if _, err := cw.WriteString(string(v)); err != nil { 8033 + return err 8034 + } 8035 + 8036 + } 8037 + } 8038 + return nil 8039 + } 8040 + 8041 + func (t *RepoDiscussionComment) UnmarshalCBOR(r io.Reader) (err error) { 8042 + *t = RepoDiscussionComment{} 8043 + 8044 + cr := cbg.NewCborReader(r) 8045 + 8046 + maj, extra, err := cr.ReadHeader() 8047 + if err != nil { 8048 + return err 8049 + } 8050 + defer func() { 8051 + if err == io.EOF { 8052 + err = io.ErrUnexpectedEOF 8053 + } 8054 + }() 8055 + 8056 + if maj != cbg.MajMap { 8057 + return fmt.Errorf("cbor input should be of type map") 8058 + } 8059 + 8060 + if extra > cbg.MaxLength { 8061 + return fmt.Errorf("RepoDiscussionComment: map struct too large (%d)", extra) 8062 + } 8063 + 8064 + n := extra 8065 + 8066 + nameBuf := make([]byte, 10) 8067 + for i := uint64(0); i < n; i++ { 8068 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8069 + if err != nil { 8070 + return err 8071 + } 8072 + 8073 + if !ok { 8074 + // Field doesn't exist on this type, so ignore it 8075 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8076 + return err 8077 + } 8078 + continue 8079 + } 8080 + 8081 + switch string(nameBuf[:nameLen]) { 8082 + // t.Body (string) (string) 8083 + case "body": 8084 + 8085 + { 8086 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8087 + if err != nil { 8088 + return err 8089 + } 8090 + 8091 + t.Body = string(sval) 8092 + } 8093 + // t.LexiconTypeID (string) (string) 8094 + case "$type": 8095 + 8096 + { 8097 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8098 + if err != nil { 8099 + return err 8100 + } 8101 + 8102 + t.LexiconTypeID = string(sval) 8103 + } 8104 + // t.ReplyTo (string) (string) 8105 + case "replyTo": 8106 + 8107 + { 8108 + b, err := cr.ReadByte() 8109 + if err != nil { 8110 + return err 8111 + } 8112 + if b != cbg.CborNull[0] { 8113 + if err := cr.UnreadByte(); err != nil { 8114 + return err 8115 + } 8116 + 8117 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8118 + if err != nil { 8119 + return err 8120 + } 8121 + 8122 + t.ReplyTo = (*string)(&sval) 8123 + } 8124 + } 8125 + // t.Mentions ([]string) (slice) 8126 + case "mentions": 8127 + 8128 + maj, extra, err = cr.ReadHeader() 8129 + if err != nil { 8130 + return err 8131 + } 8132 + 8133 + if extra > 8192 { 8134 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8135 + } 8136 + 8137 + if maj != cbg.MajArray { 8138 + return fmt.Errorf("expected cbor array") 8139 + } 8140 + 8141 + if extra > 0 { 8142 + t.Mentions = make([]string, extra) 8143 + } 8144 + 8145 + for i := 0; i < int(extra); i++ { 8146 + { 8147 + var maj byte 8148 + var extra uint64 8149 + var err error 8150 + _ = maj 8151 + _ = extra 8152 + _ = err 8153 + 8154 + { 8155 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8156 + if err != nil { 8157 + return err 8158 + } 8159 + 8160 + t.Mentions[i] = string(sval) 8161 + } 8162 + 8163 + } 8164 + } 8165 + // t.CreatedAt (string) (string) 8166 + case "createdAt": 8167 + 8168 + { 8169 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8170 + if err != nil { 8171 + return err 8172 + } 8173 + 8174 + t.CreatedAt = string(sval) 8175 + } 8176 + // t.Discussion (string) (string) 8177 + case "discussion": 8178 + 8179 + { 8180 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8181 + if err != nil { 8182 + return err 8183 + } 8184 + 8185 + t.Discussion = string(sval) 8186 + } 8187 + // t.References ([]string) (slice) 8188 + case "references": 8189 + 8190 + maj, extra, err = cr.ReadHeader() 8191 + if err != nil { 8192 + return err 8193 + } 8194 + 8195 + if extra > 8192 { 8196 + return fmt.Errorf("t.References: array too large (%d)", extra) 8197 + } 8198 + 8199 + if maj != cbg.MajArray { 8200 + return fmt.Errorf("expected cbor array") 8201 + } 8202 + 8203 + if extra > 0 { 8204 + t.References = make([]string, extra) 8205 + } 8206 + 8207 + for i := 0; i < int(extra); i++ { 8208 + { 8209 + var maj byte 8210 + var extra uint64 8211 + var err error 8212 + _ = maj 8213 + _ = extra 8214 + _ = err 8215 + 8216 + { 8217 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8218 + if err != nil { 8219 + return err 8220 + } 8221 + 8222 + t.References[i] = string(sval) 8223 + } 8224 + 8225 + } 8226 + } 8227 + 8228 + default: 8229 + // Field doesn't exist on this type, so ignore it 8230 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8231 + return err 8232 + } 8233 + } 8234 + } 8235 + 8236 + return nil 8237 + } 8238 + func (t *RepoDiscussionState) MarshalCBOR(w io.Writer) error { 8239 + if t == nil { 8240 + _, err := w.Write(cbg.CborNull) 8241 + return err 8242 + } 8243 + 8244 + cw := cbg.NewCborWriter(w) 8245 + 8246 + if _, err := cw.Write([]byte{164}); err != nil { 8247 + return err 8248 + } 8249 + 8250 + // t.LexiconTypeID (string) (string) 8251 + if len("$type") > 1000000 { 8252 + return xerrors.Errorf("Value in field \"$type\" was too long") 8253 + } 8254 + 8255 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8256 + return err 8257 + } 8258 + if _, err := cw.WriteString(string("$type")); err != nil { 8259 + return err 8260 + } 8261 + 8262 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.discussion.state"))); err != nil { 8263 + return err 8264 + } 8265 + if _, err := cw.WriteString(string("sh.tangled.repo.discussion.state")); err != nil { 8266 + return err 8267 + } 8268 + 8269 + // t.State (string) (string) 8270 + if len("state") > 1000000 { 8271 + return xerrors.Errorf("Value in field \"state\" was too long") 8272 + } 8273 + 8274 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("state"))); err != nil { 8275 + return err 8276 + } 8277 + if _, err := cw.WriteString(string("state")); err != nil { 8278 + return err 8279 + } 8280 + 8281 + if len(t.State) > 1000000 { 8282 + return xerrors.Errorf("Value in field t.State was too long") 8283 + } 8284 + 8285 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.State))); err != nil { 8286 + return err 8287 + } 8288 + if _, err := cw.WriteString(string(t.State)); err != nil { 8289 + return err 8290 + } 8291 + 8292 + // t.CreatedAt (string) (string) 8293 + if len("createdAt") > 1000000 { 8294 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8295 + } 8296 + 8297 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8298 + return err 8299 + } 8300 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8301 + return err 8302 + } 8303 + 8304 + if len(t.CreatedAt) > 1000000 { 8305 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8306 + } 8307 + 8308 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8309 + return err 8310 + } 8311 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8312 + return err 8313 + } 8314 + 8315 + // t.Discussion (string) (string) 8316 + if len("discussion") > 1000000 { 8317 + return xerrors.Errorf("Value in field \"discussion\" was too long") 8318 + } 8319 + 8320 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("discussion"))); err != nil { 8321 + return err 8322 + } 8323 + if _, err := cw.WriteString(string("discussion")); err != nil { 8324 + return err 8325 + } 8326 + 8327 + if len(t.Discussion) > 1000000 { 8328 + return xerrors.Errorf("Value in field t.Discussion was too long") 8329 + } 8330 + 8331 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Discussion))); err != nil { 8332 + return err 8333 + } 8334 + if _, err := cw.WriteString(string(t.Discussion)); err != nil { 8335 + return err 8336 + } 8337 + return nil 8338 + } 8339 + 8340 + func (t *RepoDiscussionState) UnmarshalCBOR(r io.Reader) (err error) { 8341 + *t = RepoDiscussionState{} 8342 + 8343 + cr := cbg.NewCborReader(r) 8344 + 8345 + maj, extra, err := cr.ReadHeader() 8346 + if err != nil { 8347 + return err 8348 + } 8349 + defer func() { 8350 + if err == io.EOF { 8351 + err = io.ErrUnexpectedEOF 8352 + } 8353 + }() 8354 + 8355 + if maj != cbg.MajMap { 8356 + return fmt.Errorf("cbor input should be of type map") 8357 + } 8358 + 8359 + if extra > cbg.MaxLength { 8360 + return fmt.Errorf("RepoDiscussionState: map struct too large (%d)", extra) 8361 + } 8362 + 8363 + n := extra 8364 + 8365 + nameBuf := make([]byte, 10) 8366 + for i := uint64(0); i < n; i++ { 8367 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8368 + if err != nil { 8369 + return err 8370 + } 8371 + 8372 + if !ok { 8373 + // Field doesn't exist on this type, so ignore it 8374 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8375 + return err 8376 + } 8377 + continue 8378 + } 8379 + 8380 + switch string(nameBuf[:nameLen]) { 8381 + // t.LexiconTypeID (string) (string) 8382 + case "$type": 8383 + 8384 + { 8385 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8386 + if err != nil { 8387 + return err 8388 + } 8389 + 8390 + t.LexiconTypeID = string(sval) 8391 + } 8392 + // t.State (string) (string) 8393 + case "state": 8394 + 8395 + { 8396 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8397 + if err != nil { 8398 + return err 8399 + } 8400 + 8401 + t.State = string(sval) 8402 + } 8403 + // t.CreatedAt (string) (string) 8404 + case "createdAt": 8405 + 8406 + { 8407 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8408 + if err != nil { 8409 + return err 8410 + } 8411 + 8412 + t.CreatedAt = string(sval) 8413 + } 8414 + // t.Discussion (string) (string) 8415 + case "discussion": 8416 + 8417 + { 8418 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8419 + if err != nil { 8420 + return err 8421 + } 8422 + 8423 + t.Discussion = string(sval) 6965 8424 } 6966 8425 6967 8426 default:
+30
api/tangled/discussioncomment.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.discussion.comment 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoDiscussionCommentNSID = "sh.tangled.repo.discussion.comment" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.discussion.comment", &RepoDiscussionComment{}) 17 + } // 18 + // RECORDTYPE: RepoDiscussionComment 19 + type RepoDiscussionComment struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.discussion.comment" cborgen:"$type,const=sh.tangled.repo.discussion.comment"` 21 + // body: Comment text 22 + Body string `json:"body" cborgen:"body"` 23 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 + // discussion: The discussion this comment belongs to 25 + Discussion string `json:"discussion" cborgen:"discussion"` 26 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 27 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 28 + // replyTo: If this is a reply, the parent comment's at-uri 29 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 30 + }
+26
api/tangled/discussionstate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.discussion.state 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoDiscussionStateNSID = "sh.tangled.repo.discussion.state" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.discussion.state", &RepoDiscussionState{}) 17 + } // 18 + // RECORDTYPE: RepoDiscussionState 19 + type RepoDiscussionState struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.discussion.state" cborgen:"$type,const=sh.tangled.repo.discussion.state"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // discussion: The discussion this state change applies to 23 + Discussion string `json:"discussion" cborgen:"discussion"` 24 + // state: The new state of the discussion 25 + State string `json:"state" cborgen:"state"` 26 + }
+79
api/tangled/pijulrefUpdate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.pijul.refUpdate 6 + 7 + import ( 8 + "fmt" 9 + "io" 10 + 11 + cid "github.com/ipfs/go-cid" 12 + cbg "github.com/whyrusleeping/cbor-gen" 13 + 14 + "github.com/bluesky-social/indigo/lex/util" 15 + ) 16 + 17 + const ( 18 + PijulRefUpdateNSID = "sh.tangled.pijul.refUpdate" 19 + ) 20 + 21 + func init() { 22 + util.RegisterType("sh.tangled.pijul.refUpdate", &PijulRefUpdate{}) 23 + } // 24 + // RECORDTYPE: PijulRefUpdate 25 + type PijulRefUpdate struct { 26 + LexiconTypeID string `json:"$type,const=sh.tangled.pijul.refUpdate" cborgen:"$type,const=sh.tangled.pijul.refUpdate"` 27 + // changes: List of change hashes that were applied 28 + Changes []string `json:"changes" cborgen:"changes"` 29 + // channel: Channel that was updated 30 + Channel string `json:"channel" cborgen:"channel"` 31 + // languages: Map of language name to lines of code 32 + Languages *PijulRefUpdate_Languages `json:"languages,omitempty" cborgen:"languages,omitempty"` 33 + // newState: New Merkle hash after update 34 + NewState string `json:"newState" cborgen:"newState"` 35 + // oldState: Previous Merkle hash (empty for new channels) 36 + OldState *string `json:"oldState,omitempty" cborgen:"oldState,omitempty"` 37 + // repo: Repository identifier 38 + Repo string `json:"repo" cborgen:"repo"` 39 + } 40 + 41 + // Map of language name to lines of code 42 + type PijulRefUpdate_Languages struct { 43 + } 44 + 45 + func (t *PijulRefUpdate_Languages) MarshalCBOR(w io.Writer) error { 46 + if t == nil { 47 + _, err := w.Write(cbg.CborNull) 48 + return err 49 + } 50 + cw := cbg.NewCborWriter(w) 51 + if err := cw.WriteMajorTypeHeader(cbg.MajMap, 0); err != nil { 52 + return err 53 + } 54 + return nil 55 + } 56 + 57 + func (t *PijulRefUpdate_Languages) UnmarshalCBOR(r io.Reader) error { 58 + *t = PijulRefUpdate_Languages{} 59 + cr := cbg.NewCborReader(r) 60 + maj, extra, err := cr.ReadHeader() 61 + if err != nil { 62 + return err 63 + } 64 + if maj != cbg.MajMap { 65 + return fmt.Errorf("expected cbor map for PijulRefUpdate_Languages") 66 + } 67 + // skip all entries 68 + for i := uint64(0); i < extra; i++ { 69 + // skip key 70 + if err := cbg.ScanForLinks(cr, func(_ cid.Cid) {}); err != nil { 71 + return err 72 + } 73 + // skip value 74 + if err := cbg.ScanForLinks(cr, func(_ cid.Cid) {}); err != nil { 75 + return err 76 + } 77 + } 78 + return nil 79 + }
+48
api/tangled/repoapplyChanges.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.applyChanges 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoApplyChangesNSID = "sh.tangled.repo.applyChanges" 15 + ) 16 + 17 + // RepoApplyChanges_Input is the input argument to a sh.tangled.repo.applyChanges call. 18 + type RepoApplyChanges_Input struct { 19 + // changes: List of change hashes to apply (in order) 20 + Changes []string `json:"changes" cborgen:"changes"` 21 + // channel: Target channel to apply changes to 22 + Channel string `json:"channel" cborgen:"channel"` 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoApplyChanges_Output is the output of a sh.tangled.repo.applyChanges call. 28 + type RepoApplyChanges_Output struct { 29 + // applied: List of successfully applied change hashes 30 + Applied []string `json:"applied" cborgen:"applied"` 31 + // failed: List of changes that failed to apply 32 + Failed []*RepoApplyChanges_Output_Failed_Elem `json:"failed,omitempty" cborgen:"failed,omitempty"` 33 + } 34 + 35 + type RepoApplyChanges_Output_Failed_Elem struct { 36 + Error string `json:"error" cborgen:"error"` 37 + Hash string `json:"hash" cborgen:"hash"` 38 + } 39 + 40 + // RepoApplyChanges calls the XRPC method "sh.tangled.repo.applyChanges". 41 + func RepoApplyChanges(ctx context.Context, c util.LexClient, input *RepoApplyChanges_Input) (*RepoApplyChanges_Output, error) { 42 + var out RepoApplyChanges_Output 43 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.applyChanges", nil, input, &out); err != nil { 44 + return nil, err 45 + } 46 + 47 + return &out, nil 48 + }
+53
api/tangled/repochangeGet.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.changeGet 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoChangeGetNSID = "sh.tangled.repo.changeGet" 15 + ) 16 + 17 + // RepoChangeGet_Author is a "author" in the sh.tangled.repo.changeGet schema. 18 + type RepoChangeGet_Author struct { 19 + Email *string `json:"email,omitempty" cborgen:"email,omitempty"` 20 + Name string `json:"name" cborgen:"name"` 21 + } 22 + 23 + // RepoChangeGet_Output is the output of a sh.tangled.repo.changeGet call. 24 + type RepoChangeGet_Output struct { 25 + Authors []*RepoChangeGet_Author `json:"authors" cborgen:"authors"` 26 + // dependencies: Hashes of changes this change depends on 27 + Dependencies []string `json:"dependencies,omitempty" cborgen:"dependencies,omitempty"` 28 + // diff: Raw diff content of the change 29 + Diff *string `json:"diff,omitempty" cborgen:"diff,omitempty"` 30 + // hash: Change hash (base32 encoded) 31 + Hash string `json:"hash" cborgen:"hash"` 32 + // message: Change description 33 + Message string `json:"message" cborgen:"message"` 34 + // timestamp: When the change was recorded 35 + Timestamp *string `json:"timestamp,omitempty" cborgen:"timestamp,omitempty"` 36 + } 37 + 38 + // RepoChangeGet calls the XRPC method "sh.tangled.repo.changeGet". 39 + // 40 + // hash: Change hash to retrieve 41 + // repo: Repository identifier in format 'did:plc:.../repoName' 42 + func RepoChangeGet(ctx context.Context, c util.LexClient, hash string, repo string) (*RepoChangeGet_Output, error) { 43 + var out RepoChangeGet_Output 44 + 45 + params := map[string]interface{}{} 46 + params["hash"] = hash 47 + params["repo"] = repo 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.changeGet", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+70
api/tangled/repochangeList.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.changeList 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoChangeListNSID = "sh.tangled.repo.changeList" 15 + ) 16 + 17 + // RepoChangeList_Author is a "author" in the sh.tangled.repo.changeList schema. 18 + type RepoChangeList_Author struct { 19 + Email *string `json:"email,omitempty" cborgen:"email,omitempty"` 20 + Name string `json:"name" cborgen:"name"` 21 + } 22 + 23 + // RepoChangeList_ChangeEntry is a "changeEntry" in the sh.tangled.repo.changeList schema. 24 + type RepoChangeList_ChangeEntry struct { 25 + Authors []*RepoChangeList_Author `json:"authors" cborgen:"authors"` 26 + // dependencies: Hashes of changes this change depends on 27 + Dependencies []string `json:"dependencies,omitempty" cborgen:"dependencies,omitempty"` 28 + // hash: Change hash (base32 encoded) 29 + Hash string `json:"hash" cborgen:"hash"` 30 + // message: Change description 31 + Message string `json:"message" cborgen:"message"` 32 + // timestamp: When the change was recorded 33 + Timestamp *string `json:"timestamp,omitempty" cborgen:"timestamp,omitempty"` 34 + } 35 + 36 + // RepoChangeList_Output is the output of a sh.tangled.repo.changeList call. 37 + type RepoChangeList_Output struct { 38 + Changes []*RepoChangeList_ChangeEntry `json:"changes" cborgen:"changes"` 39 + Channel *string `json:"channel,omitempty" cborgen:"channel,omitempty"` 40 + Page int64 `json:"page" cborgen:"page"` 41 + Per_page int64 `json:"per_page" cborgen:"per_page"` 42 + Total int64 `json:"total" cborgen:"total"` 43 + } 44 + 45 + // RepoChangeList calls the XRPC method "sh.tangled.repo.changeList". 46 + // 47 + // channel: Pijul channel name (defaults to main channel) 48 + // cursor: Pagination cursor (offset) 49 + // limit: Maximum number of changes to return 50 + // repo: Repository identifier in format 'did:plc:.../repoName' 51 + func RepoChangeList(ctx context.Context, c util.LexClient, channel string, cursor string, limit int64, repo string) (*RepoChangeList_Output, error) { 52 + var out RepoChangeList_Output 53 + 54 + params := map[string]interface{}{} 55 + if channel != "" { 56 + params["channel"] = channel 57 + } 58 + if cursor != "" { 59 + params["cursor"] = cursor 60 + } 61 + if limit != 0 { 62 + params["limit"] = limit 63 + } 64 + params["repo"] = repo 65 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.changeList", params, nil, &out); err != nil { 66 + return nil, err 67 + } 68 + 69 + return &out, nil 70 + }
+51
api/tangled/repochannelList.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.channelList 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoChannelListNSID = "sh.tangled.repo.channelList" 15 + ) 16 + 17 + // RepoChannelList_Channel is a "channel" in the sh.tangled.repo.channelList schema. 18 + type RepoChannelList_Channel struct { 19 + // is_current: Whether this is the currently active channel 20 + Is_current *bool `json:"is_current,omitempty" cborgen:"is_current,omitempty"` 21 + // name: Channel name 22 + Name string `json:"name" cborgen:"name"` 23 + } 24 + 25 + // RepoChannelList_Output is the output of a sh.tangled.repo.channelList call. 26 + type RepoChannelList_Output struct { 27 + Channels []*RepoChannelList_Channel `json:"channels" cborgen:"channels"` 28 + } 29 + 30 + // RepoChannelList calls the XRPC method "sh.tangled.repo.channelList". 31 + // 32 + // cursor: Pagination cursor (offset) 33 + // limit: Maximum number of channels to return 34 + // repo: Repository identifier in format 'did:plc:.../repoName' 35 + func RepoChannelList(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) (*RepoChannelList_Output, error) { 36 + var out RepoChannelList_Output 37 + 38 + params := map[string]interface{}{} 39 + if cursor != "" { 40 + params["cursor"] = cursor 41 + } 42 + if limit != 0 { 43 + params["limit"] = limit 44 + } 45 + params["repo"] = repo 46 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.channelList", params, nil, &out); err != nil { 47 + return nil, err 48 + } 49 + 50 + return &out, nil 51 + }
+2
api/tangled/repocreate.go
··· 22 22 Rkey string `json:"rkey" cborgen:"rkey"` 23 23 // source: A source URL to clone from, populate this when forking or importing a repository. 24 24 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + // vcs: Version control system to use for the repository (git or pijul). 26 + Vcs *string `json:"vcs,omitempty" cborgen:"vcs,omitempty"` 25 27 } 26 28 27 29 // RepoCreate calls the XRPC method "sh.tangled.repo.create".
+32
api/tangled/repodiscussion.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.discussion 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoDiscussionNSID = "sh.tangled.repo.discussion" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.discussion", &RepoDiscussion{}) 17 + } // 18 + // RECORDTYPE: RepoDiscussion 19 + type RepoDiscussion struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.discussion" cborgen:"$type,const=sh.tangled.repo.discussion"` 21 + // body: Discussion body/description 22 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 23 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 + // repo: The repository this discussion belongs to 27 + Repo string `json:"repo" cborgen:"repo"` 28 + // targetChannel: Target Pijul channel for merging patches 29 + TargetChannel *string `json:"targetChannel,omitempty" cborgen:"targetChannel,omitempty"` 30 + // title: Discussion title 31 + Title string `json:"title" cborgen:"title"` 32 + }
+36
api/tangled/repogetDefaultChannel.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultChannel 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultChannelNSID = "sh.tangled.repo.getDefaultChannel" 15 + ) 16 + 17 + // RepoGetDefaultChannel_Output is the output of a sh.tangled.repo.getDefaultChannel call. 18 + type RepoGetDefaultChannel_Output struct { 19 + // channel: Default channel name 20 + Channel string `json:"channel" cborgen:"channel"` 21 + } 22 + 23 + // RepoGetDefaultChannel calls the XRPC method "sh.tangled.repo.getDefaultChannel". 24 + // 25 + // repo: Repository identifier in format 'did:plc:.../repoName' 26 + func RepoGetDefaultChannel(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultChannel_Output, error) { 27 + var out RepoGetDefaultChannel_Output 28 + 29 + params := map[string]interface{}{} 30 + params["repo"] = repo 31 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultChannel", params, nil, &out); err != nil { 32 + return nil, err 33 + } 34 + 35 + return &out, nil 36 + }
+36
api/tangled/repopermissions.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.permissions 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoPermissionsNSID = "sh.tangled.repo.permissions" 15 + ) 16 + 17 + // RepoPermissions_Output is the output of a sh.tangled.repo.permissions call. 18 + type RepoPermissions_Output struct { 19 + Mask int64 `json:"mask" cborgen:"mask"` 20 + Permissions []string `json:"permissions" cborgen:"permissions"` 21 + } 22 + 23 + // RepoPermissions calls the XRPC method "sh.tangled.repo.permissions". 24 + // 25 + // repo: Repository identifier in format 'did:plc:.../repoName' 26 + func RepoPermissions(ctx context.Context, c util.LexClient, repo string) (*RepoPermissions_Output, error) { 27 + var out RepoPermissions_Output 28 + 29 + params := map[string]interface{}{} 30 + params["repo"] = repo 31 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.permissions", params, nil, &out); err != nil { 32 + return nil, err 33 + } 34 + 35 + return &out, nil 36 + }
+48
api/tangled/repopijulBlob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.pijulBlob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoPijulBlobNSID = "sh.tangled.repo.pijulBlob" 15 + ) 16 + 17 + // RepoPijulBlob_Output is the output of a sh.tangled.repo.pijulBlob call. 18 + type RepoPijulBlob_Output struct { 19 + // contents: File contents (empty for binary files) 20 + Contents *string `json:"contents,omitempty" cborgen:"contents,omitempty"` 21 + // is_binary: Whether the file is binary 22 + Is_binary bool `json:"is_binary" cborgen:"is_binary"` 23 + // path: File path 24 + Path string `json:"path" cborgen:"path"` 25 + // ref: Channel name 26 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 27 + } 28 + 29 + // RepoPijulBlob calls the XRPC method "sh.tangled.repo.pijulBlob". 30 + // 31 + // channel: Pijul channel name (defaults to main channel) 32 + // path: Path to the file within the repository 33 + // repo: Repository identifier in format 'did:plc:.../repoName' 34 + func RepoPijulBlob(ctx context.Context, c util.LexClient, channel string, path string, repo string) (*RepoPijulBlob_Output, error) { 35 + var out RepoPijulBlob_Output 36 + 37 + params := map[string]interface{}{} 38 + if channel != "" { 39 + params["channel"] = channel 40 + } 41 + params["path"] = path 42 + params["repo"] = repo 43 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.pijulBlob", params, nil, &out); err != nil { 44 + return nil, err 45 + } 46 + 47 + return &out, nil 48 + }
+63
api/tangled/repopijulTree.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.pijulTree 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoPijulTreeNSID = "sh.tangled.repo.pijulTree" 15 + ) 16 + 17 + // RepoPijulTree_Output is the output of a sh.tangled.repo.pijulTree call. 18 + type RepoPijulTree_Output struct { 19 + // dotdot: Path to navigate up 20 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 21 + Files []*RepoPijulTree_TreeEntry `json:"files" cborgen:"files"` 22 + // parent: Parent path 23 + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 24 + Readme *RepoPijulTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 25 + // ref: Channel name 26 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 27 + } 28 + 29 + // RepoPijulTree_Readme is a "readme" in the sh.tangled.repo.pijulTree schema. 30 + type RepoPijulTree_Readme struct { 31 + Contents *string `json:"contents,omitempty" cborgen:"contents,omitempty"` 32 + Filename *string `json:"filename,omitempty" cborgen:"filename,omitempty"` 33 + } 34 + 35 + // RepoPijulTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.pijulTree schema. 36 + type RepoPijulTree_TreeEntry struct { 37 + Mode string `json:"mode" cborgen:"mode"` 38 + Name string `json:"name" cborgen:"name"` 39 + Size int64 `json:"size" cborgen:"size"` 40 + } 41 + 42 + // RepoPijulTree calls the XRPC method "sh.tangled.repo.pijulTree". 43 + // 44 + // channel: Pijul channel name (defaults to main channel) 45 + // path: Path within the repository (defaults to root) 46 + // repo: Repository identifier in format 'did:plc:.../repoName' 47 + func RepoPijulTree(ctx context.Context, c util.LexClient, channel string, path string, repo string) (*RepoPijulTree_Output, error) { 48 + var out RepoPijulTree_Output 49 + 50 + params := map[string]interface{}{} 51 + if channel != "" { 52 + params["channel"] = channel 53 + } 54 + if path != "" { 55 + params["path"] = path 56 + } 57 + params["repo"] = repo 58 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.pijulTree", params, nil, &out); err != nil { 59 + return nil, err 60 + } 61 + 62 + return &out, nil 63 + }
+146
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "fmt" 6 7 "log/slog" 7 8 "strings" 8 9 ··· 25 26 ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) 26 27 Prepare(query string) (*sql.Stmt, error) 27 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 29 + } 30 + 31 + func columnExists(tx *sql.Tx, table, column string) (bool, error) { 32 + query := fmt.Sprintf("select 1 from pragma_table_info('%s') where name = ? limit 1", table) 33 + var one int 34 + err := tx.QueryRow(query, column).Scan(&one) 35 + if err == sql.ErrNoRows { 36 + return false, nil 37 + } 38 + if err != nil { 39 + return false, err 40 + } 41 + return true, nil 28 42 } 29 43 30 44 func Make(ctx context.Context, dbPath string) (*DB, error) { ··· 75 89 rkey text not null, 76 90 at_uri text not null unique, 77 91 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 92 + vcs text not null default 'git' check (vcs in ('git', 'pijul')), 78 93 unique(did, name, knot, rkey) 79 94 ); 80 95 create table if not exists collaborators ( ··· 1257 1272 }) 1258 1273 1259 1274 orm.RunMigration(conn, logger, "add-avatar-to-profile", func(tx *sql.Tx) error { 1275 + colExists, colErr := columnExists(tx, "profile", "avatar") 1276 + if colErr != nil { 1277 + return colErr 1278 + } 1279 + if colExists { 1280 + return nil 1281 + } 1260 1282 _, err := tx.Exec(` 1261 1283 alter table profile add column avatar text; 1262 1284 `) ··· 1283 1305 1284 1306 -- rename new table 1285 1307 alter table profile_stats_new rename to profile_stats; 1308 + `) 1309 + return err 1310 + }) 1311 + 1312 + // Add vcs column to repos table for pijul support 1313 + orm.RunMigration(conn, logger, "add-vcs-to-repos", func(tx *sql.Tx) error { 1314 + colExists, colErr := columnExists(tx, "repos", "vcs") 1315 + if colErr != nil { 1316 + return colErr 1317 + } 1318 + if colExists { 1319 + return nil 1320 + } 1321 + _, err := tx.Exec(` 1322 + alter table repos add column vcs text not null default 'git' check (vcs in ('git', 'pijul')); 1323 + `) 1324 + return err 1325 + }) 1326 + 1327 + // Create discussions tables for Pijul repos 1328 + orm.RunMigration(conn, logger, "create-discussions-tables", func(tx *sql.Tx) error { 1329 + _, err := tx.Exec(` 1330 + -- Discussions: unified discussion model for Pijul repos 1331 + create table if not exists discussions ( 1332 + -- identifiers 1333 + id integer primary key autoincrement, 1334 + did text not null, 1335 + rkey text not null, 1336 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.discussion' || '/' || rkey) stored, 1337 + 1338 + -- repo reference 1339 + repo_at text not null, 1340 + 1341 + -- sequential ID per repo 1342 + discussion_id integer not null, 1343 + 1344 + -- content 1345 + title text not null, 1346 + body text not null, 1347 + target_channel text not null default 'main', 1348 + 1349 + -- state: 0=closed, 1=open, 2=merged 1350 + state integer not null default 1 check (state in (0, 1, 2)), 1351 + 1352 + -- meta 1353 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1354 + edited text, 1355 + 1356 + -- constraints 1357 + unique(did, rkey), 1358 + unique(repo_at, discussion_id), 1359 + unique(at_uri), 1360 + foreign key (repo_at) references repos(at_uri) on delete cascade 1361 + ); 1362 + 1363 + -- Discussion patches: anyone can add patches to a discussion 1364 + create table if not exists discussion_patches ( 1365 + -- identifiers 1366 + id integer primary key autoincrement, 1367 + discussion_at text not null, 1368 + 1369 + -- who pushed this patch 1370 + pushed_by_did text not null, 1371 + 1372 + -- patch content 1373 + patch_hash text not null, 1374 + patch text not null, 1375 + 1376 + -- meta 1377 + added text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1378 + removed text, -- NULL means active, timestamp means removed 1379 + 1380 + -- constraints 1381 + unique(discussion_at, patch_hash), 1382 + foreign key (discussion_at) references discussions(at_uri) on delete cascade 1383 + ); 1384 + 1385 + -- Discussion comments 1386 + create table if not exists discussion_comments ( 1387 + -- identifiers 1388 + id integer primary key autoincrement, 1389 + did text not null, 1390 + rkey text, 1391 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.discussion.comment' || '/' || rkey) stored, 1392 + 1393 + -- parent references 1394 + discussion_at text not null, 1395 + reply_to text, -- at_uri of parent comment for threading 1396 + 1397 + -- content 1398 + body text not null, 1399 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1400 + edited text, 1401 + deleted text, 1402 + 1403 + -- constraints 1404 + unique(did, rkey), 1405 + unique(at_uri), 1406 + foreign key (discussion_at) references discussions(at_uri) on delete cascade 1407 + ); 1408 + 1409 + -- Discussion subscriptions: who is watching a discussion 1410 + create table if not exists discussion_subscriptions ( 1411 + id integer primary key autoincrement, 1412 + discussion_at text not null, 1413 + subscriber_did text not null, 1414 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1415 + 1416 + unique(discussion_at, subscriber_did), 1417 + foreign key (discussion_at) references discussions(at_uri) on delete cascade 1418 + ); 1419 + 1420 + -- Sequential ID generator for discussions per repo 1421 + create table if not exists repo_discussion_seqs ( 1422 + repo_at text primary key, 1423 + next_discussion_id integer not null default 1 1424 + ); 1425 + 1426 + -- indexes for discussions 1427 + create index if not exists idx_discussions_repo_at on discussions(repo_at); 1428 + create index if not exists idx_discussions_state on discussions(state); 1429 + create index if not exists idx_discussion_patches_discussion_at on discussion_patches(discussion_at); 1430 + create index if not exists idx_discussion_patches_pushed_by on discussion_patches(pushed_by_did); 1431 + create index if not exists idx_discussion_comments_discussion_at on discussion_comments(discussion_at); 1286 1432 `) 1287 1433 return err 1288 1434 })
+632
appview/db/discussions.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/appview/pagination" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + // NewDiscussion creates a new discussion in a Pijul repository 19 + func NewDiscussion(tx *sql.Tx, discussion *models.Discussion) error { 20 + // ensure sequence exists 21 + _, err := tx.Exec(` 22 + insert or ignore into repo_discussion_seqs (repo_at, next_discussion_id) 23 + values (?, 1) 24 + `, discussion.RepoAt) 25 + if err != nil { 26 + return err 27 + } 28 + 29 + // get next discussion_id 30 + var newDiscussionId int 31 + err = tx.QueryRow(` 32 + update repo_discussion_seqs 33 + set next_discussion_id = next_discussion_id + 1 34 + where repo_at = ? 35 + returning next_discussion_id - 1 36 + `, discussion.RepoAt).Scan(&newDiscussionId) 37 + if err != nil { 38 + return err 39 + } 40 + 41 + // insert new discussion 42 + row := tx.QueryRow(` 43 + insert into discussions (repo_at, did, rkey, discussion_id, title, body, target_channel, state) 44 + values (?, ?, ?, ?, ?, ?, ?, ?) 45 + returning id, discussion_id 46 + `, discussion.RepoAt, discussion.Did, discussion.Rkey, newDiscussionId, discussion.Title, discussion.Body, discussion.TargetChannel, discussion.State) 47 + 48 + err = row.Scan(&discussion.Id, &discussion.DiscussionId) 49 + if err != nil { 50 + return fmt.Errorf("scan row: %w", err) 51 + } 52 + 53 + return nil 54 + } 55 + 56 + // GetDiscussionsPaginated returns discussions with pagination 57 + func GetDiscussionsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Discussion, error) { 58 + discussionMap := make(map[string]*models.Discussion) // at-uri -> discussion 59 + 60 + var conditions []string 61 + var args []any 62 + 63 + for _, filter := range filters { 64 + conditions = append(conditions, filter.Condition()) 65 + args = append(args, filter.Arg()...) 66 + } 67 + 68 + whereClause := "" 69 + if conditions != nil { 70 + whereClause = " where " + strings.Join(conditions, " and ") 71 + } 72 + 73 + pLower := orm.FilterGte("row_num", page.Offset+1) 74 + pUpper := orm.FilterLte("row_num", page.Offset+page.Limit) 75 + 76 + pageClause := "" 77 + if page.Limit > 0 { 78 + args = append(args, pLower.Arg()...) 79 + args = append(args, pUpper.Arg()...) 80 + pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 81 + } 82 + 83 + query := fmt.Sprintf( 84 + ` 85 + select * from ( 86 + select 87 + id, 88 + did, 89 + rkey, 90 + repo_at, 91 + discussion_id, 92 + title, 93 + body, 94 + target_channel, 95 + state, 96 + created, 97 + edited, 98 + row_number() over (order by created desc) as row_num 99 + from 100 + discussions 101 + %s 102 + ) ranked_discussions 103 + %s 104 + `, 105 + whereClause, 106 + pageClause, 107 + ) 108 + 109 + rows, err := e.Query(query, args...) 110 + if err != nil { 111 + return nil, fmt.Errorf("failed to query discussions table: %w", err) 112 + } 113 + defer rows.Close() 114 + 115 + for rows.Next() { 116 + var discussion models.Discussion 117 + var createdAt string 118 + var editedAt sql.Null[string] 119 + var rowNum int64 120 + err := rows.Scan( 121 + &discussion.Id, 122 + &discussion.Did, 123 + &discussion.Rkey, 124 + &discussion.RepoAt, 125 + &discussion.DiscussionId, 126 + &discussion.Title, 127 + &discussion.Body, 128 + &discussion.TargetChannel, 129 + &discussion.State, 130 + &createdAt, 131 + &editedAt, 132 + &rowNum, 133 + ) 134 + if err != nil { 135 + return nil, fmt.Errorf("failed to scan discussion: %w", err) 136 + } 137 + 138 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 139 + discussion.Created = t 140 + } 141 + 142 + if editedAt.Valid { 143 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 144 + discussion.Edited = &t 145 + } 146 + } 147 + 148 + atUri := discussion.AtUri().String() 149 + discussionMap[atUri] = &discussion 150 + } 151 + 152 + // collect reverse repos 153 + repoAts := make([]string, 0, len(discussionMap)) 154 + for _, discussion := range discussionMap { 155 + repoAts = append(repoAts, string(discussion.RepoAt)) 156 + } 157 + 158 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 159 + if err != nil { 160 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 161 + } 162 + 163 + repoMap := make(map[string]*models.Repo) 164 + for i := range repos { 165 + repoMap[string(repos[i].RepoAt())] = &repos[i] 166 + } 167 + 168 + for discussionAt, d := range discussionMap { 169 + if r, ok := repoMap[string(d.RepoAt)]; ok { 170 + d.Repo = r 171 + } else { 172 + // do not show up the discussion if the repo is deleted 173 + delete(discussionMap, discussionAt) 174 + } 175 + } 176 + 177 + // collect patches 178 + discussionAts := slices.Collect(maps.Keys(discussionMap)) 179 + 180 + patches, err := GetDiscussionPatches(e, orm.FilterIn("discussion_at", discussionAts)) 181 + if err != nil { 182 + return nil, fmt.Errorf("failed to query patches: %w", err) 183 + } 184 + for _, p := range patches { 185 + discussionAt := p.DiscussionAt.String() 186 + if discussion, ok := discussionMap[discussionAt]; ok { 187 + discussion.Patches = append(discussion.Patches, p) 188 + } 189 + } 190 + 191 + // collect comments 192 + comments, err := GetDiscussionComments(e, orm.FilterIn("discussion_at", discussionAts)) 193 + if err != nil { 194 + return nil, fmt.Errorf("failed to query comments: %w", err) 195 + } 196 + for i := range comments { 197 + discussionAt := comments[i].DiscussionAt 198 + if discussion, ok := discussionMap[discussionAt]; ok { 199 + discussion.Comments = append(discussion.Comments, comments[i]) 200 + } 201 + } 202 + 203 + // collect labels for each discussion 204 + allLabels, err := GetLabels(e, orm.FilterIn("subject", discussionAts)) 205 + if err != nil { 206 + return nil, fmt.Errorf("failed to query labels: %w", err) 207 + } 208 + for discussionAt, labels := range allLabels { 209 + if discussion, ok := discussionMap[discussionAt.String()]; ok { 210 + discussion.Labels = labels 211 + } 212 + } 213 + 214 + var discussions []models.Discussion 215 + for _, d := range discussionMap { 216 + discussions = append(discussions, *d) 217 + } 218 + 219 + sort.Slice(discussions, func(i, j int) bool { 220 + return discussions[i].Created.After(discussions[j].Created) 221 + }) 222 + 223 + return discussions, nil 224 + } 225 + 226 + // GetDiscussion returns a single discussion by repo and ID 227 + func GetDiscussion(e Execer, repoAt syntax.ATURI, discussionId int) (*models.Discussion, error) { 228 + discussions, err := GetDiscussionsPaginated( 229 + e, 230 + pagination.Page{}, 231 + orm.FilterEq("repo_at", repoAt), 232 + orm.FilterEq("discussion_id", discussionId), 233 + ) 234 + if err != nil { 235 + return nil, err 236 + } 237 + if len(discussions) != 1 { 238 + return nil, sql.ErrNoRows 239 + } 240 + 241 + return &discussions[0], nil 242 + } 243 + 244 + // GetDiscussions returns discussions matching filters 245 + func GetDiscussions(e Execer, filters ...orm.Filter) ([]models.Discussion, error) { 246 + return GetDiscussionsPaginated(e, pagination.Page{}, filters...) 247 + } 248 + 249 + // AddDiscussionPatch adds a patch to a discussion 250 + // Anyone can add patches - the key feature of the Nest model 251 + func AddDiscussionPatch(tx *sql.Tx, patch *models.DiscussionPatch) error { 252 + row := tx.QueryRow(` 253 + insert into discussion_patches (discussion_at, pushed_by_did, patch_hash, patch) 254 + values (?, ?, ?, ?) 255 + returning id 256 + `, patch.DiscussionAt, patch.PushedByDid, patch.PatchHash, patch.Patch) 257 + 258 + return row.Scan(&patch.Id) 259 + } 260 + 261 + // PatchExists checks if a patch with the given hash already exists in the discussion 262 + func PatchExists(e Execer, discussionAt syntax.ATURI, patchHash string) (bool, error) { 263 + var count int 264 + err := e.QueryRow(` 265 + select count(1) from discussion_patches 266 + where discussion_at = ? and patch_hash = ? 267 + `, discussionAt, patchHash).Scan(&count) 268 + if err != nil { 269 + return false, err 270 + } 271 + return count > 0, nil 272 + } 273 + 274 + // RemovePatch marks a patch as removed (soft delete) 275 + func RemovePatch(e Execer, patchId int64) error { 276 + _, err := e.Exec(` 277 + update discussion_patches 278 + set removed = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 279 + where id = ? 280 + `, patchId) 281 + return err 282 + } 283 + 284 + // ReaddPatch re-adds a previously removed patch 285 + func ReaddPatch(e Execer, patchId int64) error { 286 + _, err := e.Exec(` 287 + update discussion_patches 288 + set removed = null 289 + where id = ? 290 + `, patchId) 291 + return err 292 + } 293 + 294 + // GetDiscussionPatches returns patches for discussions 295 + func GetDiscussionPatches(e Execer, filters ...orm.Filter) ([]*models.DiscussionPatch, error) { 296 + var conditions []string 297 + var args []any 298 + for _, filter := range filters { 299 + conditions = append(conditions, filter.Condition()) 300 + args = append(args, filter.Arg()...) 301 + } 302 + 303 + whereClause := "" 304 + if conditions != nil { 305 + whereClause = " where " + strings.Join(conditions, " and ") 306 + } 307 + 308 + query := fmt.Sprintf(` 309 + select 310 + id, 311 + discussion_at, 312 + pushed_by_did, 313 + patch_hash, 314 + patch, 315 + added, 316 + removed 317 + from 318 + discussion_patches 319 + %s 320 + order by added asc 321 + `, whereClause) 322 + 323 + rows, err := e.Query(query, args...) 324 + if err != nil { 325 + return nil, err 326 + } 327 + defer rows.Close() 328 + 329 + var patches []*models.DiscussionPatch 330 + for rows.Next() { 331 + var patch models.DiscussionPatch 332 + var addedAt string 333 + var removedAt sql.Null[string] 334 + err := rows.Scan( 335 + &patch.Id, 336 + &patch.DiscussionAt, 337 + &patch.PushedByDid, 338 + &patch.PatchHash, 339 + &patch.Patch, 340 + &addedAt, 341 + &removedAt, 342 + ) 343 + if err != nil { 344 + return nil, err 345 + } 346 + 347 + if t, err := time.Parse(time.RFC3339, addedAt); err == nil { 348 + patch.Added = t 349 + } 350 + 351 + if removedAt.Valid { 352 + if t, err := time.Parse(time.RFC3339, removedAt.V); err == nil { 353 + patch.Removed = &t 354 + } 355 + } 356 + 357 + patches = append(patches, &patch) 358 + } 359 + 360 + if err = rows.Err(); err != nil { 361 + return nil, err 362 + } 363 + 364 + return patches, nil 365 + } 366 + 367 + // GetDiscussionPatch returns a single patch by ID 368 + func GetDiscussionPatch(e Execer, patchId int64) (*models.DiscussionPatch, error) { 369 + patches, err := GetDiscussionPatches(e, orm.FilterEq("id", patchId)) 370 + if err != nil { 371 + return nil, err 372 + } 373 + if len(patches) != 1 { 374 + return nil, sql.ErrNoRows 375 + } 376 + return patches[0], nil 377 + } 378 + 379 + // AddDiscussionComment adds a comment to a discussion 380 + func AddDiscussionComment(tx *sql.Tx, c models.DiscussionComment) (int64, error) { 381 + result, err := tx.Exec( 382 + `insert into discussion_comments ( 383 + did, 384 + rkey, 385 + discussion_at, 386 + body, 387 + reply_to, 388 + created, 389 + edited 390 + ) 391 + values (?, ?, ?, ?, ?, ?, null) 392 + on conflict(did, rkey) do update set 393 + discussion_at = excluded.discussion_at, 394 + body = excluded.body, 395 + edited = case 396 + when 397 + discussion_comments.discussion_at != excluded.discussion_at 398 + or discussion_comments.body != excluded.body 399 + or discussion_comments.reply_to != excluded.reply_to 400 + then ? 401 + else discussion_comments.edited 402 + end`, 403 + c.Did, 404 + c.Rkey, 405 + c.DiscussionAt, 406 + c.Body, 407 + c.ReplyTo, 408 + c.Created.Format(time.RFC3339), 409 + time.Now().Format(time.RFC3339), 410 + ) 411 + if err != nil { 412 + return 0, err 413 + } 414 + 415 + id, err := result.LastInsertId() 416 + if err != nil { 417 + return 0, err 418 + } 419 + 420 + return id, nil 421 + } 422 + 423 + // GetDiscussionComments returns comments for discussions 424 + func GetDiscussionComments(e Execer, filters ...orm.Filter) ([]models.DiscussionComment, error) { 425 + var conditions []string 426 + var args []any 427 + for _, filter := range filters { 428 + conditions = append(conditions, filter.Condition()) 429 + args = append(args, filter.Arg()...) 430 + } 431 + 432 + whereClause := "" 433 + if conditions != nil { 434 + whereClause = " where " + strings.Join(conditions, " and ") 435 + } 436 + 437 + query := fmt.Sprintf(` 438 + select 439 + id, 440 + did, 441 + rkey, 442 + discussion_at, 443 + reply_to, 444 + body, 445 + created, 446 + edited, 447 + deleted 448 + from 449 + discussion_comments 450 + %s 451 + order by created asc 452 + `, whereClause) 453 + 454 + rows, err := e.Query(query, args...) 455 + if err != nil { 456 + return nil, err 457 + } 458 + defer rows.Close() 459 + 460 + var comments []models.DiscussionComment 461 + for rows.Next() { 462 + var comment models.DiscussionComment 463 + var created string 464 + var rkey, edited, deleted, replyTo sql.Null[string] 465 + err := rows.Scan( 466 + &comment.Id, 467 + &comment.Did, 468 + &rkey, 469 + &comment.DiscussionAt, 470 + &replyTo, 471 + &comment.Body, 472 + &created, 473 + &edited, 474 + &deleted, 475 + ) 476 + if err != nil { 477 + return nil, err 478 + } 479 + 480 + if rkey.Valid { 481 + comment.Rkey = rkey.V 482 + } 483 + 484 + if t, err := time.Parse(time.RFC3339, created); err == nil { 485 + comment.Created = t 486 + } 487 + 488 + if edited.Valid { 489 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 490 + comment.Edited = &t 491 + } 492 + } 493 + 494 + if deleted.Valid { 495 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 496 + comment.Deleted = &t 497 + } 498 + } 499 + 500 + if replyTo.Valid { 501 + comment.ReplyTo = &replyTo.V 502 + } 503 + 504 + comments = append(comments, comment) 505 + } 506 + 507 + if err = rows.Err(); err != nil { 508 + return nil, err 509 + } 510 + 511 + return comments, nil 512 + } 513 + 514 + // DeleteDiscussionComment soft-deletes a comment 515 + func DeleteDiscussionComment(e Execer, filters ...orm.Filter) error { 516 + var conditions []string 517 + var args []any 518 + for _, filter := range filters { 519 + conditions = append(conditions, filter.Condition()) 520 + args = append(args, filter.Arg()...) 521 + } 522 + 523 + whereClause := "" 524 + if conditions != nil { 525 + whereClause = " where " + strings.Join(conditions, " and ") 526 + } 527 + 528 + query := fmt.Sprintf(`update discussion_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 529 + 530 + _, err := e.Exec(query, args...) 531 + return err 532 + } 533 + 534 + // CloseDiscussion closes a discussion 535 + func CloseDiscussion(e Execer, repoAt syntax.ATURI, discussionId int) error { 536 + _, err := e.Exec(` 537 + update discussions set state = ? 538 + where repo_at = ? and discussion_id = ? 539 + `, models.DiscussionClosed, repoAt, discussionId) 540 + return err 541 + } 542 + 543 + // ReopenDiscussion reopens a discussion 544 + func ReopenDiscussion(e Execer, repoAt syntax.ATURI, discussionId int) error { 545 + _, err := e.Exec(` 546 + update discussions set state = ? 547 + where repo_at = ? and discussion_id = ? 548 + `, models.DiscussionOpen, repoAt, discussionId) 549 + return err 550 + } 551 + 552 + // MergeDiscussion marks a discussion as merged 553 + func MergeDiscussion(e Execer, repoAt syntax.ATURI, discussionId int) error { 554 + _, err := e.Exec(` 555 + update discussions set state = ? 556 + where repo_at = ? and discussion_id = ? 557 + `, models.DiscussionMerged, repoAt, discussionId) 558 + return err 559 + } 560 + 561 + // SetDiscussionState sets the state of a discussion 562 + func SetDiscussionState(e Execer, repoAt syntax.ATURI, discussionId int, state models.DiscussionState) error { 563 + _, err := e.Exec(` 564 + update discussions set state = ? 565 + where repo_at = ? and discussion_id = ? 566 + `, state, repoAt, discussionId) 567 + return err 568 + } 569 + 570 + // GetDiscussionCount returns counts of discussions by state 571 + func GetDiscussionCount(e Execer, repoAt syntax.ATURI) (models.DiscussionCount, error) { 572 + row := e.QueryRow(` 573 + select 574 + count(case when state = ? then 1 end) as open_count, 575 + count(case when state = ? then 1 end) as merged_count, 576 + count(case when state = ? then 1 end) as closed_count 577 + from discussions 578 + where repo_at = ?`, 579 + models.DiscussionOpen, 580 + models.DiscussionMerged, 581 + models.DiscussionClosed, 582 + repoAt, 583 + ) 584 + 585 + var count models.DiscussionCount 586 + if err := row.Scan(&count.Open, &count.Merged, &count.Closed); err != nil { 587 + return models.DiscussionCount{}, err 588 + } 589 + 590 + return count, nil 591 + } 592 + 593 + // SubscribeToDiscussion adds a subscription for a user to a discussion 594 + func SubscribeToDiscussion(e Execer, discussionAt syntax.ATURI, subscriberDid string) error { 595 + _, err := e.Exec(` 596 + insert or ignore into discussion_subscriptions (discussion_at, subscriber_did) 597 + values (?, ?) 598 + `, discussionAt, subscriberDid) 599 + return err 600 + } 601 + 602 + // UnsubscribeFromDiscussion removes a subscription 603 + func UnsubscribeFromDiscussion(e Execer, discussionAt syntax.ATURI, subscriberDid string) error { 604 + _, err := e.Exec(` 605 + delete from discussion_subscriptions 606 + where discussion_at = ? and subscriber_did = ? 607 + `, discussionAt, subscriberDid) 608 + return err 609 + } 610 + 611 + // GetDiscussionSubscribers returns all subscribers for a discussion 612 + func GetDiscussionSubscribers(e Execer, discussionAt syntax.ATURI) ([]string, error) { 613 + rows, err := e.Query(` 614 + select subscriber_did from discussion_subscriptions 615 + where discussion_at = ? 616 + `, discussionAt) 617 + if err != nil { 618 + return nil, err 619 + } 620 + defer rows.Close() 621 + 622 + var subscribers []string 623 + for rows.Next() { 624 + var did string 625 + if err := rows.Scan(&did); err != nil { 626 + return nil, err 627 + } 628 + subscribers = append(subscribers, did) 629 + } 630 + 631 + return subscribers, rows.Err() 632 + }
+24 -7
appview/db/repos.go
··· 46 46 website, 47 47 topics, 48 48 source, 49 - spindle 49 + spindle, 50 + vcs 50 51 from 51 52 repos r 52 53 %s ··· 64 65 for rows.Next() { 65 66 var repo models.Repo 66 67 var createdAt string 67 - var description, website, topicStr, source, spindle sql.NullString 68 + var description, website, topicStr, source, spindle, vcs sql.NullString 68 69 69 70 err := rows.Scan( 70 71 &repo.Id, ··· 78 79 &topicStr, 79 80 &source, 80 81 &spindle, 82 + &vcs, 81 83 ) 82 84 if err != nil { 83 85 return nil, fmt.Errorf("failed to execute repo query: %w ", err) ··· 100 102 } 101 103 if spindle.Valid { 102 104 repo.Spindle = spindle.String 105 + } 106 + if vcs.Valid { 107 + repo.Vcs = vcs.String 108 + } else { 109 + repo.Vcs = "git" // default 103 110 } 104 111 105 112 repo.RepoStats = &models.RepoStats{} ··· 352 359 var nullableDescription sql.NullString 353 360 var nullableWebsite sql.NullString 354 361 var nullableTopicStr sql.NullString 362 + var nullableVcs sql.NullString 355 363 356 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 364 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics, vcs from repos where at_uri = ?`, atUri) 357 365 358 366 var createdAt string 359 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 367 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &nullableVcs); err != nil { 360 368 return nil, err 361 369 } 362 370 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 370 378 } 371 379 if nullableTopicStr.Valid { 372 380 repo.Topics = strings.Fields(nullableTopicStr.String) 381 + } 382 + if nullableVcs.Valid { 383 + repo.Vcs = nullableVcs.String 384 + } else { 385 + repo.Vcs = "git" 373 386 } 374 387 375 388 return &repo, nil ··· 387 400 } 388 401 389 402 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 403 + vcs := repo.Vcs 404 + if vcs == "" { 405 + vcs = "git" 406 + } 390 407 _, err := tx.Exec( 391 408 `insert into repos 392 - (did, name, knot, rkey, at_uri, description, website, topics, source) 393 - values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 394 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 409 + (did, name, knot, rkey, at_uri, description, website, topics, source, vcs) 410 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 411 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, vcs, 395 412 ) 396 413 if err != nil { 397 414 return fmt.Errorf("failed to insert repo: %w", err)
+757
appview/discussions/discussions.go
··· 1 + package discussions 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "strconv" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/xrpc" 12 + "github.com/go-chi/chi/v5" 13 + 14 + tangled "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/config" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/mentions" 18 + "tangled.org/core/appview/models" 19 + "tangled.org/core/appview/notify" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/pages/repoinfo" 23 + "tangled.org/core/appview/pagination" 24 + "tangled.org/core/appview/reporesolver" 25 + "tangled.org/core/appview/validator" 26 + "tangled.org/core/idresolver" 27 + "tangled.org/core/orm" 28 + "tangled.org/core/rbac" 29 + "tangled.org/core/tid" 30 + ) 31 + 32 + // Discussions handles the discussions feature for Pijul repositories 33 + type Discussions struct { 34 + oauth *oauth.OAuth 35 + repoResolver *reporesolver.RepoResolver 36 + enforcer *rbac.Enforcer 37 + pages *pages.Pages 38 + idResolver *idresolver.Resolver 39 + mentionsResolver *mentions.Resolver 40 + db *db.DB 41 + config *config.Config 42 + notifier notify.Notifier 43 + logger *slog.Logger 44 + validator *validator.Validator 45 + } 46 + 47 + func New( 48 + oauth *oauth.OAuth, 49 + repoResolver *reporesolver.RepoResolver, 50 + enforcer *rbac.Enforcer, 51 + pages *pages.Pages, 52 + idResolver *idresolver.Resolver, 53 + mentionsResolver *mentions.Resolver, 54 + db *db.DB, 55 + config *config.Config, 56 + notifier notify.Notifier, 57 + validator *validator.Validator, 58 + logger *slog.Logger, 59 + ) *Discussions { 60 + return &Discussions{ 61 + oauth: oauth, 62 + repoResolver: repoResolver, 63 + enforcer: enforcer, 64 + pages: pages, 65 + idResolver: idResolver, 66 + mentionsResolver: mentionsResolver, 67 + db: db, 68 + config: config, 69 + notifier: notifier, 70 + logger: logger, 71 + validator: validator, 72 + } 73 + } 74 + 75 + // rolesFor returns the RolesInRepo for the given user in the repo described by repoInfo. 76 + // rolesFor returns the RolesInRepo for the given user in the repo described by repoInfo. 77 + func (d *Discussions) rolesFor(userDid string, ri repoinfo.RepoInfo) repoinfo.RolesInRepo { 78 + return repoinfo.RolesInRepo{ 79 + Roles: d.enforcer.GetPermissionsInRepo(userDid, ri.Knot, ri.OwnerDid+"/"+ri.Name), 80 + } 81 + } 82 + 83 + // RepoDiscussionsList shows all discussions for a Pijul repository 84 + func (d *Discussions) RepoDiscussionsList(w http.ResponseWriter, r *http.Request) { 85 + l := d.logger.With("handler", "RepoDiscussionsList") 86 + user := d.oauth.GetMultiAccountUser(r) 87 + 88 + repo, err := d.repoResolver.Resolve(r) 89 + if err != nil { 90 + l.Error("failed to get repo", "err", err) 91 + d.pages.Error404(w) 92 + return 93 + } 94 + 95 + // Only allow discussions for Pijul repos 96 + if !repo.IsPijul() { 97 + l.Info("discussions only available for pijul repos") 98 + d.pages.Error404(w) 99 + return 100 + } 101 + 102 + repoAt := repo.RepoAt() 103 + page := pagination.Page{Limit: 50} 104 + 105 + // Filter by state 106 + filter := r.URL.Query().Get("filter") 107 + filters := []orm.Filter{orm.FilterEq("repo_at", repoAt)} 108 + switch filter { 109 + case "closed": 110 + filters = append(filters, orm.FilterEq("state", models.DiscussionClosed)) 111 + case "merged": 112 + filters = append(filters, orm.FilterEq("state", models.DiscussionMerged)) 113 + default: 114 + // Default to open 115 + filters = append(filters, orm.FilterEq("state", models.DiscussionOpen)) 116 + filter = "open" 117 + } 118 + 119 + discussions, err := db.GetDiscussionsPaginated(d.db, page, filters...) 120 + if err != nil { 121 + l.Error("failed to fetch discussions", "err", err) 122 + d.pages.Error503(w) 123 + return 124 + } 125 + 126 + count, err := db.GetDiscussionCount(d.db, repoAt) 127 + if err != nil { 128 + l.Error("failed to get discussion count", "err", err) 129 + } 130 + 131 + d.pages.RepoDiscussionsList(w, pages.RepoDiscussionsListParams{ 132 + LoggedInUser: user, 133 + RepoInfo: d.repoResolver.GetRepoInfo(r, user), 134 + Discussions: discussions, 135 + Filter: filter, 136 + DiscussionCount: count, 137 + }) 138 + } 139 + 140 + // NewDiscussion creates a new discussion 141 + func (d *Discussions) NewDiscussion(w http.ResponseWriter, r *http.Request) { 142 + l := d.logger.With("handler", "NewDiscussion") 143 + user := d.oauth.GetMultiAccountUser(r) 144 + 145 + repo, err := d.repoResolver.Resolve(r) 146 + if err != nil { 147 + l.Error("failed to get repo", "err", err) 148 + d.pages.Error404(w) 149 + return 150 + } 151 + 152 + if !repo.IsPijul() { 153 + l.Info("discussions only available for pijul repos") 154 + d.pages.Error404(w) 155 + return 156 + } 157 + 158 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 159 + 160 + switch r.Method { 161 + case http.MethodGet: 162 + d.pages.NewDiscussion(w, pages.NewDiscussionParams{ 163 + LoggedInUser: user, 164 + RepoInfo: repoInfo, 165 + }) 166 + 167 + case http.MethodPost: 168 + noticeId := "discussion" 169 + 170 + title := r.FormValue("title") 171 + body := r.FormValue("body") 172 + targetChannel := r.FormValue("target_channel") 173 + if targetChannel == "" { 174 + targetChannel = "main" 175 + } 176 + 177 + if title == "" { 178 + d.pages.Notice(w, noticeId, "Title is required") 179 + return 180 + } 181 + 182 + discussion := &models.Discussion{ 183 + Did: user.Active.Did, 184 + Rkey: tid.TID(), 185 + RepoAt: repo.RepoAt(), 186 + Title: title, 187 + Body: body, 188 + TargetChannel: targetChannel, 189 + State: models.DiscussionOpen, 190 + Created: time.Now(), 191 + } 192 + 193 + tx, err := d.db.BeginTx(r.Context(), nil) 194 + if err != nil { 195 + l.Error("failed to begin transaction", "err", err) 196 + d.pages.Notice(w, noticeId, "Failed to create discussion") 197 + return 198 + } 199 + defer tx.Rollback() 200 + 201 + if err := db.NewDiscussion(tx, discussion); err != nil { 202 + l.Error("failed to create discussion", "err", err) 203 + d.pages.Notice(w, noticeId, "Failed to create discussion") 204 + return 205 + } 206 + 207 + if err := tx.Commit(); err != nil { 208 + l.Error("failed to commit transaction", "err", err) 209 + d.pages.Notice(w, noticeId, "Failed to create discussion") 210 + return 211 + } 212 + 213 + // Subscribe the creator to the discussion 214 + db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 215 + 216 + l.Info("discussion created", "discussion_id", discussion.DiscussionId) 217 + 218 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 219 + repo.Did, repo.Name, discussion.DiscussionId)) 220 + } 221 + } 222 + 223 + // RepoSingleDiscussion shows a single discussion 224 + func (d *Discussions) RepoSingleDiscussion(w http.ResponseWriter, r *http.Request) { 225 + l := d.logger.With("handler", "RepoSingleDiscussion") 226 + user := d.oauth.GetMultiAccountUser(r) 227 + 228 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 229 + if !ok { 230 + l.Error("failed to get discussion from context") 231 + d.pages.Error404(w) 232 + return 233 + } 234 + 235 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 236 + 237 + canManage := user != nil && d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() 238 + 239 + d.pages.RepoSingleDiscussion(w, pages.RepoSingleDiscussionParams{ 240 + LoggedInUser: user, 241 + RepoInfo: repoInfo, 242 + Discussion: discussion, 243 + CommentList: discussion.CommentList(), 244 + CanManage: canManage, 245 + ActivePatches: discussion.ActivePatches(), 246 + }) 247 + } 248 + 249 + // AddPatch allows anyone to add a patch to a discussion 250 + func (d *Discussions) AddPatch(w http.ResponseWriter, r *http.Request) { 251 + l := d.logger.With("handler", "AddPatch") 252 + user := d.oauth.GetMultiAccountUser(r) 253 + noticeId := "patch" 254 + 255 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 256 + if !ok { 257 + l.Error("failed to get discussion from context") 258 + d.pages.Notice(w, noticeId, "Discussion not found") 259 + return 260 + } 261 + 262 + if discussion.State != models.DiscussionOpen { 263 + d.pages.Notice(w, noticeId, "Cannot add patches to a closed or merged discussion") 264 + return 265 + } 266 + 267 + patchHash := r.FormValue("patch_hash") 268 + patch := r.FormValue("patch") 269 + 270 + if patchHash == "" || patch == "" { 271 + d.pages.Notice(w, noticeId, "Patch hash and content are required") 272 + return 273 + } 274 + 275 + // Check if patch already exists 276 + exists, err := db.PatchExists(d.db, discussion.AtUri(), patchHash) 277 + if err != nil { 278 + l.Error("failed to check patch existence", "err", err) 279 + d.pages.Notice(w, noticeId, "Failed to add patch") 280 + return 281 + } 282 + if exists { 283 + d.pages.Notice(w, noticeId, "This patch has already been added to the discussion") 284 + return 285 + } 286 + 287 + // Get repo info for verification and dependency checking 288 + repo, err := d.repoResolver.Resolve(r) 289 + if err != nil { 290 + l.Error("failed to resolve repo", "err", err) 291 + d.pages.Notice(w, noticeId, "Failed to add patch") 292 + return 293 + } 294 + 295 + repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 296 + 297 + // Verify the change exists in the Pijul repository 298 + change, err := d.getChangeFromKnot(r.Context(), repo.Knot, repoIdentifier, patchHash) 299 + if err != nil { 300 + l.Info("change verification failed", "hash", patchHash, "err", err) 301 + d.pages.Notice(w, noticeId, "Change not found in repository. Please ensure the change hash is correct and exists in the repo.") 302 + return 303 + } 304 + 305 + l.Debug("change verified", "hash", patchHash, "message", change.Message) 306 + 307 + // Check dependencies - ensure the patch doesn't depend on removed patches 308 + if err := d.canAddPatchWithChange(discussion, change); err != nil { 309 + l.Info("dependency check failed", "err", err) 310 + d.pages.Notice(w, noticeId, err.Error()) 311 + return 312 + } 313 + 314 + discussionPatch := &models.DiscussionPatch{ 315 + DiscussionAt: discussion.AtUri(), 316 + PushedByDid: user.Active.Did, 317 + PatchHash: patchHash, 318 + Patch: patch, 319 + Added: time.Now(), 320 + } 321 + 322 + tx, err := d.db.BeginTx(r.Context(), nil) 323 + if err != nil { 324 + l.Error("failed to begin transaction", "err", err) 325 + d.pages.Notice(w, noticeId, "Failed to add patch") 326 + return 327 + } 328 + defer tx.Rollback() 329 + 330 + if err := db.AddDiscussionPatch(tx, discussionPatch); err != nil { 331 + l.Error("failed to add patch", "err", err) 332 + d.pages.Notice(w, noticeId, "Failed to add patch") 333 + return 334 + } 335 + 336 + if err := tx.Commit(); err != nil { 337 + l.Error("failed to commit transaction", "err", err) 338 + d.pages.Notice(w, noticeId, "Failed to add patch") 339 + return 340 + } 341 + 342 + // Subscribe the patch contributor to the discussion 343 + db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 344 + 345 + l.Info("patch added", "patch_hash", patchHash, "pushed_by", user.Active.Did) 346 + 347 + // Reload the page to show the new patch 348 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 349 + repo.Did, repo.Name, discussion.DiscussionId)) 350 + } 351 + 352 + // RemovePatch removes a patch from a discussion (soft delete) 353 + func (d *Discussions) RemovePatch(w http.ResponseWriter, r *http.Request) { 354 + l := d.logger.With("handler", "RemovePatch") 355 + user := d.oauth.GetMultiAccountUser(r) 356 + noticeId := "patch" 357 + 358 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 359 + if !ok { 360 + l.Error("failed to get discussion from context") 361 + d.pages.Notice(w, noticeId, "Discussion not found") 362 + return 363 + } 364 + 365 + patchIdStr := chi.URLParam(r, "patchId") 366 + patchId, err := strconv.ParseInt(patchIdStr, 10, 64) 367 + if err != nil { 368 + d.pages.Notice(w, noticeId, "Invalid patch ID") 369 + return 370 + } 371 + 372 + patch, err := db.GetDiscussionPatch(d.db, patchId) 373 + if err != nil { 374 + l.Error("failed to get patch", "err", err) 375 + d.pages.Notice(w, noticeId, "Patch not found") 376 + return 377 + } 378 + 379 + // Check permission: patch pusher or repo collaborator 380 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 381 + if patch.PushedByDid != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 382 + d.pages.Notice(w, noticeId, "You don't have permission to remove this patch") 383 + return 384 + } 385 + 386 + // Get repo for dependency checking 387 + repo, err := d.repoResolver.Resolve(r) 388 + if err != nil { 389 + l.Error("failed to resolve repo", "err", err) 390 + d.pages.Notice(w, noticeId, "Failed to remove patch") 391 + return 392 + } 393 + 394 + // Check if other active patches depend on this one 395 + repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 396 + if err := d.canRemovePatch(r.Context(), discussion, repo.Knot, repoIdentifier, patch.PatchHash); err != nil { 397 + l.Info("dependency check failed", "err", err) 398 + d.pages.Notice(w, noticeId, err.Error()) 399 + return 400 + } 401 + 402 + if err := db.RemovePatch(d.db, patchId); err != nil { 403 + l.Error("failed to remove patch", "err", err) 404 + d.pages.Notice(w, noticeId, "Failed to remove patch") 405 + return 406 + } 407 + 408 + l.Info("patch removed", "patch_id", patchId) 409 + 410 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 411 + repo.Did, repo.Name, discussion.DiscussionId)) 412 + } 413 + 414 + // ReaddPatch re-adds a previously removed patch 415 + func (d *Discussions) ReaddPatch(w http.ResponseWriter, r *http.Request) { 416 + l := d.logger.With("handler", "ReaddPatch") 417 + user := d.oauth.GetMultiAccountUser(r) 418 + noticeId := "patch" 419 + 420 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 421 + if !ok { 422 + l.Error("failed to get discussion from context") 423 + d.pages.Notice(w, noticeId, "Discussion not found") 424 + return 425 + } 426 + 427 + patchIdStr := chi.URLParam(r, "patchId") 428 + patchId, err := strconv.ParseInt(patchIdStr, 10, 64) 429 + if err != nil { 430 + d.pages.Notice(w, noticeId, "Invalid patch ID") 431 + return 432 + } 433 + 434 + patch, err := db.GetDiscussionPatch(d.db, patchId) 435 + if err != nil { 436 + l.Error("failed to get patch", "err", err) 437 + d.pages.Notice(w, noticeId, "Patch not found") 438 + return 439 + } 440 + 441 + // Check permission 442 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 443 + if patch.PushedByDid != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 444 + d.pages.Notice(w, noticeId, "You don't have permission to re-add this patch") 445 + return 446 + } 447 + 448 + if err := db.ReaddPatch(d.db, patchId); err != nil { 449 + l.Error("failed to re-add patch", "err", err) 450 + d.pages.Notice(w, noticeId, "Failed to re-add patch") 451 + return 452 + } 453 + 454 + l.Info("patch re-added", "patch_id", patchId) 455 + 456 + repo, _ := d.repoResolver.Resolve(r) 457 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 458 + repo.Did, repo.Name, discussion.DiscussionId)) 459 + } 460 + 461 + // NewComment adds a comment to a discussion 462 + func (d *Discussions) NewComment(w http.ResponseWriter, r *http.Request) { 463 + l := d.logger.With("handler", "NewComment") 464 + user := d.oauth.GetMultiAccountUser(r) 465 + noticeId := "comment" 466 + 467 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 468 + if !ok { 469 + l.Error("failed to get discussion from context") 470 + d.pages.Notice(w, noticeId, "Discussion not found") 471 + return 472 + } 473 + 474 + body := r.FormValue("body") 475 + replyTo := r.FormValue("reply_to") 476 + 477 + if body == "" { 478 + d.pages.Notice(w, noticeId, "Comment body is required") 479 + return 480 + } 481 + 482 + comment := models.DiscussionComment{ 483 + Did: user.Active.Did, 484 + Rkey: tid.TID(), 485 + DiscussionAt: discussion.AtUri().String(), 486 + Body: body, 487 + Created: time.Now(), 488 + } 489 + 490 + if replyTo != "" { 491 + comment.ReplyTo = &replyTo 492 + } 493 + 494 + tx, err := d.db.BeginTx(r.Context(), nil) 495 + if err != nil { 496 + l.Error("failed to begin transaction", "err", err) 497 + d.pages.Notice(w, noticeId, "Failed to add comment") 498 + return 499 + } 500 + defer tx.Rollback() 501 + 502 + if _, err := db.AddDiscussionComment(tx, comment); err != nil { 503 + l.Error("failed to add comment", "err", err) 504 + d.pages.Notice(w, noticeId, "Failed to add comment") 505 + return 506 + } 507 + 508 + if err := tx.Commit(); err != nil { 509 + l.Error("failed to commit transaction", "err", err) 510 + d.pages.Notice(w, noticeId, "Failed to add comment") 511 + return 512 + } 513 + 514 + // Subscribe the commenter to the discussion 515 + db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 516 + 517 + l.Info("comment added", "discussion_id", discussion.DiscussionId) 518 + 519 + repo, _ := d.repoResolver.Resolve(r) 520 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 521 + repo.Did, repo.Name, discussion.DiscussionId)) 522 + } 523 + 524 + // CloseDiscussion closes a discussion 525 + func (d *Discussions) CloseDiscussion(w http.ResponseWriter, r *http.Request) { 526 + l := d.logger.With("handler", "CloseDiscussion") 527 + user := d.oauth.GetMultiAccountUser(r) 528 + noticeId := "discussion" 529 + 530 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 531 + if !ok { 532 + l.Error("failed to get discussion from context") 533 + d.pages.Notice(w, noticeId, "Discussion not found") 534 + return 535 + } 536 + 537 + // Check permission: discussion creator or repo manager 538 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 539 + if discussion.Did != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 540 + d.pages.Notice(w, noticeId, "You don't have permission to close this discussion") 541 + return 542 + } 543 + 544 + if err := db.CloseDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 545 + l.Error("failed to close discussion", "err", err) 546 + d.pages.Notice(w, noticeId, "Failed to close discussion") 547 + return 548 + } 549 + 550 + l.Info("discussion closed", "discussion_id", discussion.DiscussionId) 551 + 552 + repo, _ := d.repoResolver.Resolve(r) 553 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 554 + repo.Did, repo.Name, discussion.DiscussionId)) 555 + } 556 + 557 + // ReopenDiscussion reopens a discussion 558 + func (d *Discussions) ReopenDiscussion(w http.ResponseWriter, r *http.Request) { 559 + l := d.logger.With("handler", "ReopenDiscussion") 560 + user := d.oauth.GetMultiAccountUser(r) 561 + noticeId := "discussion" 562 + 563 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 564 + if !ok { 565 + l.Error("failed to get discussion from context") 566 + d.pages.Notice(w, noticeId, "Discussion not found") 567 + return 568 + } 569 + 570 + // Check permission: discussion creator or repo manager 571 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 572 + if discussion.Did != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 573 + d.pages.Notice(w, noticeId, "You don't have permission to reopen this discussion") 574 + return 575 + } 576 + 577 + if err := db.ReopenDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 578 + l.Error("failed to reopen discussion", "err", err) 579 + d.pages.Notice(w, noticeId, "Failed to reopen discussion") 580 + return 581 + } 582 + 583 + l.Info("discussion reopened", "discussion_id", discussion.DiscussionId) 584 + 585 + repo, _ := d.repoResolver.Resolve(r) 586 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 587 + repo.Did, repo.Name, discussion.DiscussionId)) 588 + } 589 + 590 + // MergeDiscussion applies patches and marks a discussion as merged 591 + func (d *Discussions) MergeDiscussion(w http.ResponseWriter, r *http.Request) { 592 + l := d.logger.With("handler", "MergeDiscussion") 593 + user := d.oauth.GetMultiAccountUser(r) 594 + noticeId := "discussion" 595 + 596 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 597 + if !ok { 598 + l.Error("failed to get discussion from context") 599 + d.pages.Notice(w, noticeId, "Discussion not found") 600 + return 601 + } 602 + 603 + // Only collaborators can merge 604 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 605 + if !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 606 + d.pages.Notice(w, noticeId, "You don't have permission to merge this discussion") 607 + return 608 + } 609 + 610 + // Get all active patches to apply 611 + activePatches := discussion.ActivePatches() 612 + if len(activePatches) == 0 { 613 + d.pages.Notice(w, noticeId, "No patches to merge") 614 + return 615 + } 616 + 617 + // Get repo for API call 618 + repo, err := d.repoResolver.Resolve(r) 619 + if err != nil { 620 + l.Error("failed to resolve repo", "err", err) 621 + d.pages.Notice(w, noticeId, "Failed to merge discussion") 622 + return 623 + } 624 + 625 + // Apply patches via knotserver (needs authenticated client since endpoint requires service auth) 626 + xrpcc, err := d.oauth.ServiceClient( 627 + r, 628 + oauth.WithService(repo.Knot), 629 + oauth.WithLxm(tangled.RepoApplyChangesNSID), 630 + oauth.WithDev(d.config.Core.Dev), 631 + ) 632 + if err != nil { 633 + l.Error("failed to create service client", "err", err) 634 + d.pages.Notice(w, noticeId, "Failed to authenticate with knotserver") 635 + return 636 + } 637 + 638 + // Collect patch hashes in order 639 + changeHashes := make([]string, len(activePatches)) 640 + for i, patch := range activePatches { 641 + changeHashes[i] = patch.PatchHash 642 + } 643 + 644 + repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 645 + applyInput := &tangled.RepoApplyChanges_Input{ 646 + Repo: repoIdentifier, 647 + Channel: discussion.TargetChannel, 648 + Changes: changeHashes, 649 + } 650 + 651 + applyResult, err := tangled.RepoApplyChanges(r.Context(), xrpcc, applyInput) 652 + if err != nil { 653 + l.Error("failed to apply changes", "err", err) 654 + d.pages.Notice(w, noticeId, "Failed to apply patches: "+err.Error()) 655 + return 656 + } 657 + 658 + // Check if all patches were applied 659 + if len(applyResult.Failed) > 0 { 660 + failedHashes := make([]string, len(applyResult.Failed)) 661 + for i, f := range applyResult.Failed { 662 + failedHashes[i] = f.Hash[:12] 663 + } 664 + l.Warn("some patches failed to apply", "failed", failedHashes) 665 + d.pages.Notice(w, noticeId, fmt.Sprintf("Some patches failed to apply: %v", failedHashes)) 666 + return 667 + } 668 + 669 + l.Info("patches applied successfully", "count", len(applyResult.Applied)) 670 + 671 + // Mark discussion as merged 672 + if err := db.MergeDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 673 + l.Error("failed to merge discussion", "err", err) 674 + d.pages.Notice(w, noticeId, "Failed to merge discussion") 675 + return 676 + } 677 + 678 + l.Info("discussion merged", "discussion_id", discussion.DiscussionId) 679 + 680 + repo, _ = d.repoResolver.Resolve(r) 681 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 682 + repo.Did, repo.Name, discussion.DiscussionId)) 683 + } 684 + 685 + // getChangeFromKnot fetches change details (including dependencies) from knotserver 686 + func (d *Discussions) getChangeFromKnot(ctx context.Context, knot, repo, hash string) (*tangled.RepoChangeGet_Output, error) { 687 + scheme := "http" 688 + if d.config.Core.UseTLS() { 689 + scheme = "https" 690 + } 691 + host := fmt.Sprintf("%s://%s", scheme, knot) 692 + 693 + xrpcc := &xrpc.Client{ 694 + Host: host, 695 + } 696 + 697 + return tangled.RepoChangeGet(ctx, xrpcc, hash, repo) 698 + } 699 + 700 + // canAddPatchWithChange checks if a patch can be added to the discussion 701 + // Uses the already-fetched change object to avoid duplicate API calls 702 + // Returns error if the patch depends on a removed patch 703 + func (d *Discussions) canAddPatchWithChange(discussion *models.Discussion, change *tangled.RepoChangeGet_Output) error { 704 + 705 + if len(change.Dependencies) == 0 { 706 + return nil // No dependencies, can always add 707 + } 708 + 709 + // Get all patches in this discussion 710 + patches, err := db.GetDiscussionPatches(d.db, orm.FilterEq("discussion_at", discussion.AtUri())) 711 + if err != nil { 712 + return fmt.Errorf("failed to get discussion patches: %w", err) 713 + } 714 + 715 + // Check if any dependency is a removed patch in this discussion 716 + for _, dep := range change.Dependencies { 717 + for _, patch := range patches { 718 + if patch.PatchHash == dep && !patch.IsActive() { 719 + return fmt.Errorf("cannot add patch: it depends on removed patch %s", dep[:12]) 720 + } 721 + } 722 + } 723 + 724 + return nil 725 + } 726 + 727 + // canRemovePatch checks if a patch can be removed from the discussion 728 + // Returns error if other active patches depend on this patch 729 + func (d *Discussions) canRemovePatch(ctx context.Context, discussion *models.Discussion, knot, repo, patchHashToRemove string) error { 730 + // Get all active patches in this discussion 731 + patches, err := db.GetDiscussionPatches(d.db, orm.FilterEq("discussion_at", discussion.AtUri())) 732 + if err != nil { 733 + return fmt.Errorf("failed to get discussion patches: %w", err) 734 + } 735 + 736 + // For each active patch, check if it depends on the patch we want to remove 737 + for _, patch := range patches { 738 + if !patch.IsActive() || patch.PatchHash == patchHashToRemove { 739 + continue 740 + } 741 + 742 + // Get the change details to check its dependencies 743 + change, err := d.getChangeFromKnot(ctx, knot, repo, patch.PatchHash) 744 + if err != nil { 745 + d.logger.Warn("failed to get change dependencies", "hash", patch.PatchHash, "err", err) 746 + continue // Skip if we can't get the change, but don't block removal 747 + } 748 + 749 + for _, dep := range change.Dependencies { 750 + if dep == patchHashToRemove { 751 + return fmt.Errorf("cannot remove patch: patch %s depends on it", patch.PatchHash[:12]) 752 + } 753 + } 754 + } 755 + 756 + return nil 757 + }
+53
appview/discussions/router.go
··· 1 + package discussions 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 + ) 9 + 10 + func (d *Discussions) Router(mw *middleware.Middleware) http.Handler { 11 + r := chi.NewRouter() 12 + 13 + r.Route("/", func(r chi.Router) { 14 + r.With(middleware.Paginate).Get("/", d.RepoDiscussionsList) 15 + 16 + // Authenticated routes for creating discussions 17 + r.Group(func(r chi.Router) { 18 + r.Use(middleware.AuthMiddleware(d.oauth)) 19 + r.Get("/new", d.NewDiscussion) 20 + r.Post("/new", d.NewDiscussion) 21 + }) 22 + 23 + // Single discussion routes 24 + r.Route("/{discussion}", func(r chi.Router) { 25 + r.Use(mw.ResolveDiscussion) 26 + r.Get("/", d.RepoSingleDiscussion) 27 + 28 + // Authenticated routes 29 + r.Group(func(r chi.Router) { 30 + r.Use(middleware.AuthMiddleware(d.oauth)) 31 + 32 + // Comments 33 + r.Post("/comment", d.NewComment) 34 + 35 + // Patches - anyone authenticated can add patches 36 + r.Post("/patches", d.AddPatch) 37 + 38 + // Patch management 39 + r.Route("/patches/{patchId}", func(r chi.Router) { 40 + r.Delete("/", d.RemovePatch) 41 + r.Post("/readd", d.ReaddPatch) 42 + }) 43 + 44 + // Discussion state changes 45 + r.Post("/close", d.CloseDiscussion) 46 + r.Post("/reopen", d.ReopenDiscussion) 47 + r.Post("/merge", d.MergeDiscussion) 48 + }) 49 + }) 50 + }) 51 + 52 + return r 53 + }
+31
appview/middleware/middleware.go
··· 313 313 }) 314 314 } 315 315 316 + // middleware that is tacked on top of /{user}/{repo}/discussions/{discussion} 317 + func (mw Middleware) ResolveDiscussion(next http.Handler) http.Handler { 318 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 319 + f, err := mw.repoResolver.Resolve(r) 320 + if err != nil { 321 + log.Println("failed to fully resolve repo", err) 322 + w.WriteHeader(http.StatusNotFound) 323 + mw.pages.ErrorKnot404(w) 324 + return 325 + } 326 + 327 + discussionIdStr := chi.URLParam(r, "discussion") 328 + discussionId, err := strconv.Atoi(discussionIdStr) 329 + if err != nil { 330 + log.Println("failed to fully resolve discussion ID", err) 331 + mw.pages.Error404(w) 332 + return 333 + } 334 + 335 + discussion, err := db.GetDiscussion(mw.db, f.RepoAt(), discussionId) 336 + if err != nil { 337 + log.Println("failed to get discussion", "err", err) 338 + mw.pages.Error404(w) 339 + return 340 + } 341 + 342 + ctx := context.WithValue(r.Context(), "discussion", discussion) 343 + next.ServeHTTP(w, r.WithContext(ctx)) 344 + }) 345 + } 346 + 316 347 // this should serve the go-import meta tag even if the path is technically 317 348 // a 404 like tangled.sh/oppi.li/go-git/v5 318 349 //
+243
appview/models/discussion.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + // DiscussionState represents the state of a discussion 12 + type DiscussionState int 13 + 14 + const ( 15 + DiscussionClosed DiscussionState = iota 16 + DiscussionOpen 17 + DiscussionMerged 18 + ) 19 + 20 + func (s DiscussionState) String() string { 21 + switch s { 22 + case DiscussionOpen: 23 + return "open" 24 + case DiscussionMerged: 25 + return "merged" 26 + case DiscussionClosed: 27 + return "closed" 28 + default: 29 + return "closed" 30 + } 31 + } 32 + 33 + func (s DiscussionState) IsOpen() bool { return s == DiscussionOpen } 34 + func (s DiscussionState) IsMerged() bool { return s == DiscussionMerged } 35 + func (s DiscussionState) IsClosed() bool { return s == DiscussionClosed } 36 + 37 + // Discussion represents a discussion in a Pijul repository 38 + // Anyone can add patches to a discussion 39 + type Discussion struct { 40 + // ids 41 + Id int64 42 + Did string 43 + Rkey string 44 + RepoAt syntax.ATURI 45 + DiscussionId int 46 + 47 + // content 48 + Title string 49 + Body string 50 + TargetChannel string 51 + State DiscussionState 52 + 53 + // meta 54 + Created time.Time 55 + Edited *time.Time 56 + 57 + // populated on query 58 + Patches []*DiscussionPatch 59 + Comments []DiscussionComment 60 + Labels LabelState 61 + Repo *Repo 62 + } 63 + 64 + const DiscussionNSID = "sh.tangled.repo.discussion" 65 + 66 + func (d *Discussion) AtUri() syntax.ATURI { 67 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", d.Did, DiscussionNSID, d.Rkey)) 68 + } 69 + 70 + // ActivePatches returns only the patches that haven't been removed 71 + func (d *Discussion) ActivePatches() []*DiscussionPatch { 72 + var active []*DiscussionPatch 73 + for _, p := range d.Patches { 74 + if p.IsActive() { 75 + active = append(active, p) 76 + } 77 + } 78 + return active 79 + } 80 + 81 + // Participants returns all DIDs that have participated in this discussion 82 + // (creator + patch pushers + commenters) 83 + func (d *Discussion) Participants() []string { 84 + participantSet := make(map[string]struct{}) 85 + participants := []string{} 86 + 87 + addParticipant := func(did string) { 88 + if _, exists := participantSet[did]; !exists { 89 + participantSet[did] = struct{}{} 90 + participants = append(participants, did) 91 + } 92 + } 93 + 94 + // Discussion creator 95 + addParticipant(d.Did) 96 + 97 + // Patch pushers 98 + for _, p := range d.Patches { 99 + addParticipant(p.PushedByDid) 100 + } 101 + 102 + // Commenters 103 + for _, c := range d.Comments { 104 + addParticipant(c.Did) 105 + } 106 + 107 + return participants 108 + } 109 + 110 + // CommentList returns a threaded comment list 111 + func (d *Discussion) CommentList() []DiscussionCommentListItem { 112 + toplevel := make(map[string]*DiscussionCommentListItem) 113 + var replies []*DiscussionComment 114 + 115 + for i := range d.Comments { 116 + comment := &d.Comments[i] 117 + if comment.IsTopLevel() { 118 + toplevel[comment.AtUri().String()] = &DiscussionCommentListItem{ 119 + Self: comment, 120 + } 121 + } else { 122 + replies = append(replies, comment) 123 + } 124 + } 125 + 126 + for _, r := range replies { 127 + parentAt := *r.ReplyTo 128 + if parent, exists := toplevel[parentAt]; exists { 129 + parent.Replies = append(parent.Replies, r) 130 + } 131 + } 132 + 133 + var listing []DiscussionCommentListItem 134 + for _, v := range toplevel { 135 + listing = append(listing, *v) 136 + } 137 + 138 + // Sort by creation time 139 + sortFunc := func(a, b *DiscussionComment) bool { 140 + return a.Created.Before(b.Created) 141 + } 142 + sort.Slice(listing, func(i, j int) bool { 143 + return sortFunc(listing[i].Self, listing[j].Self) 144 + }) 145 + for _, r := range listing { 146 + sort.Slice(r.Replies, func(i, j int) bool { 147 + return sortFunc(r.Replies[i], r.Replies[j]) 148 + }) 149 + } 150 + 151 + return listing 152 + } 153 + 154 + // TotalComments returns the total number of comments 155 + func (d *Discussion) TotalComments() int { 156 + return len(d.Comments) 157 + } 158 + 159 + // DiscussionPatch represents a patch added to a discussion 160 + // Key difference from PullSubmission: it has pushed_by_did 161 + type DiscussionPatch struct { 162 + Id int64 163 + DiscussionAt syntax.ATURI 164 + PushedByDid string 165 + PatchHash string 166 + Patch string 167 + Added time.Time 168 + Removed *time.Time 169 + } 170 + 171 + // IsActive returns true if the patch hasn't been removed 172 + func (p *DiscussionPatch) IsActive() bool { 173 + return p.Removed == nil 174 + } 175 + 176 + // CanRemove checks if the given user can remove this patch 177 + // A patch can be removed by: 178 + // 1. The person who pushed it 179 + // 2. Someone with edit permissions on the repo 180 + func (p *DiscussionPatch) CanRemove(userDid string, hasEditPerm bool) bool { 181 + return p.PushedByDid == userDid || hasEditPerm 182 + } 183 + 184 + // DiscussionComment represents a comment on a discussion 185 + type DiscussionComment struct { 186 + Id int64 187 + Did string 188 + Rkey string 189 + DiscussionAt string 190 + ReplyTo *string 191 + Body string 192 + Created time.Time 193 + Edited *time.Time 194 + Deleted *time.Time 195 + } 196 + 197 + const DiscussionCommentNSID = "sh.tangled.repo.discussion.comment" 198 + 199 + func (c *DiscussionComment) AtUri() syntax.ATURI { 200 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, DiscussionCommentNSID, c.Rkey)) 201 + } 202 + 203 + func (c *DiscussionComment) IsTopLevel() bool { 204 + return c.ReplyTo == nil 205 + } 206 + 207 + func (c *DiscussionComment) IsReply() bool { 208 + return c.ReplyTo != nil 209 + } 210 + 211 + // DiscussionCommentListItem represents a top-level comment with its replies 212 + type DiscussionCommentListItem struct { 213 + Self *DiscussionComment 214 + Replies []*DiscussionComment 215 + } 216 + 217 + // Participants returns all DIDs that participated in this comment thread 218 + func (item *DiscussionCommentListItem) Participants() []syntax.DID { 219 + participantSet := make(map[syntax.DID]struct{}) 220 + participants := []syntax.DID{} 221 + 222 + addParticipant := func(did syntax.DID) { 223 + if _, exists := participantSet[did]; !exists { 224 + participantSet[did] = struct{}{} 225 + participants = append(participants, did) 226 + } 227 + } 228 + 229 + addParticipant(syntax.DID(item.Self.Did)) 230 + 231 + for _, c := range item.Replies { 232 + addParticipant(syntax.DID(c.Did)) 233 + } 234 + 235 + return participants 236 + } 237 + 238 + // DiscussionCount holds counts for different discussion states 239 + type DiscussionCount struct { 240 + Open int 241 + Merged int 242 + Closed int 243 + }
+14 -4
appview/models/repo.go
··· 22 22 Topics []string 23 23 Spindle string 24 24 Labels []string 25 + Vcs string // "git" or "pijul" 25 26 26 27 // optionally, populate this when querying for reverse mappings 27 28 RepoStats *RepoStats 28 29 29 30 // optional 30 31 Source string 32 + } 33 + 34 + func (r *Repo) IsGit() bool { 35 + return r.Vcs == "" || r.Vcs == "git" 36 + } 37 + 38 + func (r *Repo) IsPijul() bool { 39 + return r.Vcs == "pijul" 31 40 } 32 41 33 42 func (r *Repo) AsRecord() tangled.Repo { ··· 76 85 } 77 86 78 87 type RepoStats struct { 79 - Language string 80 - StarCount int 81 - IssueCount IssueCount 82 - PullCount PullCount 88 + Language string 89 + StarCount int 90 + IssueCount IssueCount 91 + PullCount PullCount 92 + DiscussionCount DiscussionCount 83 93 } 84 94 85 95 type IssueCount struct {
+4 -1
appview/oauth/oauth.go
··· 7 7 "log/slog" 8 8 "net/http" 9 9 "net/url" 10 + "strings" 10 11 "sync" 11 12 "time" 12 13 ··· 321 322 } 322 323 323 324 func (s *ServiceClientOpts) Audience() string { 324 - return fmt.Sprintf("did:web:%s", s.service) 325 + // did:web spec requires colons to be encoded as %3A 326 + encoded := strings.ReplaceAll(s.service, ":", "%3A") 327 + return fmt.Sprintf("did:web:%s", encoded) 325 328 } 326 329 327 330 func (s *ServiceClientOpts) Host() string {
+99
appview/pages/pages.go
··· 771 771 return p.executeRepo("repo/log", w, params) 772 772 } 773 773 774 + type PijulChangeView struct { 775 + Hash string 776 + Authors []*tangled.RepoChangeList_Author 777 + Message string 778 + Dependencies []string 779 + Timestamp time.Time 780 + HasTimestamp bool 781 + } 782 + 783 + type RepoChangesParams struct { 784 + LoggedInUser *oauth.MultiAccountUser 785 + RepoInfo repoinfo.RepoInfo 786 + Active string 787 + Page int 788 + Changes []PijulChangeView 789 + } 790 + 791 + func (p *Pages) RepoChanges(w io.Writer, params RepoChangesParams) error { 792 + params.Active = "changes" 793 + return p.executeRepo("repo/changes", w, params) 794 + } 795 + 796 + type RepoChangeParams struct { 797 + LoggedInUser *oauth.MultiAccountUser 798 + RepoInfo repoinfo.RepoInfo 799 + Active string 800 + Change PijulChangeDetail 801 + } 802 + 803 + func (p *Pages) RepoChange(w io.Writer, params RepoChangeParams) error { 804 + params.Active = "changes" 805 + return p.executeRepo("repo/change", w, params) 806 + } 807 + 808 + type PijulChangeDetail struct { 809 + Hash string 810 + Authors []*tangled.RepoChangeGet_Author 811 + Message string 812 + Dependencies []string 813 + Diff string 814 + HasDiff bool 815 + DiffLines []PijulDiffLine 816 + Timestamp time.Time 817 + HasTimestamp bool 818 + } 819 + 820 + type PijulDiffLine struct { 821 + Kind string 822 + Op string 823 + Body string 824 + Text string 825 + OldLine int64 826 + NewLine int64 827 + HasOld bool 828 + HasNew bool 829 + } 830 + 774 831 type RepoCommitParams struct { 775 832 LoggedInUser *oauth.MultiAccountUser 776 833 RepoInfo repoinfo.RepoInfo ··· 1604 1661 func (p *Pages) Error503(w io.Writer) error { 1605 1662 return p.execute("errors/503", w, nil) 1606 1663 } 1664 + 1665 + // Pijul Discussion pages - these are different from Git's issues/PRs 1666 + 1667 + type RepoDiscussionsListParams struct { 1668 + LoggedInUser *oauth.MultiAccountUser 1669 + RepoInfo repoinfo.RepoInfo 1670 + Active string 1671 + Discussions []models.Discussion 1672 + Filter string 1673 + DiscussionCount models.DiscussionCount 1674 + } 1675 + 1676 + func (p *Pages) RepoDiscussionsList(w io.Writer, params RepoDiscussionsListParams) error { 1677 + params.Active = "discussions" 1678 + return p.executeRepo("repo/pijul/discussions/list", w, params) 1679 + } 1680 + 1681 + type NewDiscussionParams struct { 1682 + LoggedInUser *oauth.MultiAccountUser 1683 + RepoInfo repoinfo.RepoInfo 1684 + Active string 1685 + } 1686 + 1687 + func (p *Pages) NewDiscussion(w io.Writer, params NewDiscussionParams) error { 1688 + params.Active = "discussions" 1689 + return p.executeRepo("repo/pijul/discussions/new", w, params) 1690 + } 1691 + 1692 + type RepoSingleDiscussionParams struct { 1693 + LoggedInUser *oauth.MultiAccountUser 1694 + RepoInfo repoinfo.RepoInfo 1695 + Active string 1696 + Discussion *models.Discussion 1697 + CommentList []models.DiscussionCommentListItem 1698 + CanManage bool 1699 + ActivePatches []*models.DiscussionPatch 1700 + } 1701 + 1702 + func (p *Pages) RepoSingleDiscussion(w io.Writer, params RepoSingleDiscussionParams) error { 1703 + params.Active = "discussions" 1704 + return p.executeRepo("repo/pijul/discussions/single", w, params) 1705 + }
+34 -7
appview/pages/repoinfo/repoinfo.go
··· 38 38 func (r RepoInfo) GetTabs() [][]string { 39 39 tabs := [][]string{ 40 40 {"overview", "/", "square-chart-gantt"}, 41 - {"issues", "/issues", "circle-dot"}, 42 - {"pulls", "/pulls", "git-pull-request"}, 43 - {"pipelines", "/pipelines", "layers-2"}, 41 + } 42 + 43 + if r.IsPijul() { 44 + tabs = append(tabs, []string{"changes", "/changes", "logs"}) 45 + tabs = append(tabs, []string{"discussions", "/discussions", "message-square"}) 46 + } else { 47 + tabs = append(tabs, []string{"issues", "/issues", "circle-dot"}) 48 + tabs = append(tabs, []string{"pulls", "/pulls", "git-pull-request"}) 44 49 } 45 50 51 + tabs = append(tabs, []string{"pipelines", "/pipelines", "layers-2"}) 52 + 46 53 if r.Roles.SettingsAllowed() { 47 54 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 48 55 } ··· 64 71 Topics []string 65 72 Knot string 66 73 Spindle string 74 + Vcs string // "git" or "pijul" 67 75 IsStarred bool 68 76 Stats models.RepoStats 69 77 Roles RolesInRepo ··· 72 80 CurrentDir string 73 81 } 74 82 83 + func (r RepoInfo) IsGit() bool { 84 + return r.Vcs == "" || r.Vcs == "git" 85 + } 86 + 87 + func (r RepoInfo) IsPijul() bool { 88 + return r.Vcs == "pijul" 89 + } 90 + 75 91 // each tab on a repo could have some metadata: 76 92 // 77 93 // issues -> number of open issues etc. ··· 82 98 func (r RepoInfo) TabMetadata() map[string]any { 83 99 meta := make(map[string]any) 84 100 85 - meta["pulls"] = r.Stats.PullCount.Open 86 - meta["issues"] = r.Stats.IssueCount.Open 87 - 88 - // more stuff? 101 + if r.IsPijul() { 102 + // Pijul repos use discussions 103 + meta["discussions"] = r.Stats.DiscussionCount.Open 104 + } else { 105 + // Git repos use separate issues and pulls 106 + meta["issues"] = r.Stats.IssueCount.Open 107 + meta["pulls"] = r.Stats.PullCount.Open 108 + } 89 109 90 110 return meta 91 111 } ··· 117 137 func (r RolesInRepo) IsPushAllowed() bool { 118 138 return slices.Contains(r.Roles, "repo:push") 119 139 } 140 + 141 + // CanManageRepo returns true if the user has any role that grants management 142 + // access (owner, collaborator, or push). Used for actions like closing/merging 143 + // discussions, removing patches, etc. 144 + func (r RolesInRepo) CanManageRepo() bool { 145 + return r.IsOwner() || r.IsCollaborator() || r.IsPushAllowed() 146 + }
+87
appview/pages/templates/repo/change.html
··· 1 + {{ define "title" }} change {{ .Change.Hash }} &middot; {{ .RepoInfo.FullName }} {{ end }} 2 + 3 + {{ define "repoContent" }} 4 + {{ $repo := .RepoInfo.FullName }} 5 + {{ $change := .Change }} 6 + 7 + <section class="dark:text-white"> 8 + <h1 class="mt-2">{{ $change.Message }}</h1> 9 + 10 + <div class="flex items-center gap-3 py-3"> 11 + {{ if $change.Authors }} 12 + {{ $author := index $change.Authors 0 }} 13 + {{ placeholderAvatar "md" }} 14 + <div class="flex flex-col"> 15 + <div> 16 + {{ range $idx, $a := $change.Authors }} 17 + {{ if gt $idx 0 }}, {{ end }} 18 + {{ $a.Name }}{{ if $a.Email }} &lt;{{ $a.Email }}&gt;{{ end }} 19 + {{ end }} 20 + </div> 21 + {{ if $change.HasTimestamp }} 22 + <div class="text-sm text-gray-500 dark:text-gray-400"> 23 + {{ template "repo/fragments/time" $change.Timestamp }} 24 + </div> 25 + {{ end }} 26 + <div class="font-mono text-sm text-gray-500 dark:text-gray-400 break-all">{{ $change.Hash }}</div> 27 + </div> 28 + {{ else }} 29 + {{ placeholderAvatar "md" }} 30 + <div class="text-gray-500">unknown</div> 31 + {{ end }} 32 + </div> 33 + 34 + <h2 class="mt-6 text-sm uppercase text-gray-600 dark:text-gray-400">Dependencies</h2> 35 + <ul class="mt-2"> 36 + {{ if $change.Dependencies }} 37 + {{ range $change.Dependencies }} 38 + <li class="font-mono text-sm break-all"> 39 + <a class="no-underline hover:underline" href="/{{ $repo }}/change/{{ . }}">{{ . }}</a> 40 + </li> 41 + {{ end }} 42 + {{ else }} 43 + <li class="text-sm text-gray-500">none</li> 44 + {{ end }} 45 + </ul> 46 + 47 + <h2 class="mt-8 text-sm uppercase text-gray-600 dark:text-gray-400">Change contents</h2> 48 + {{ if $change.HasDiff }} 49 + {{ if $change.DiffLines }} 50 + <div class="overflow-x-auto text-sm bg-gray-50 dark:bg-gray-900 p-3 rounded mt-2 font-mono"> 51 + {{ $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" }} 52 + {{ $lineNrSepStyle1 := "" }} 53 + {{ $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" }} 54 + {{ $containerStyle := "inline-flex w-full items-center" }} 55 + {{ range $change.DiffLines }} 56 + {{ if eq .Kind "section" }} 57 + <div class="whitespace-pre text-xs uppercase tracking-wide text-gray-600 dark:text-gray-300 mt-3">{{ .Text }}</div> 58 + {{ else if eq .Kind "meta" }} 59 + <div class="whitespace-pre text-gray-500 dark:text-gray-400">{{ .Text }}</div> 60 + {{ else }} 61 + {{ $lineClass := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" }} 62 + {{ if eq .Kind "add" }} 63 + {{ $lineClass = "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" }} 64 + {{ else if eq .Kind "del" }} 65 + {{ $lineClass = "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400" }} 66 + {{ end }} 67 + <div class="{{ $containerStyle }} {{ $lineClass }}"> 68 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}"> 69 + {{ if .HasOld }}{{ .OldLine }}{{ else }}<span aria-hidden="true" class="invisible">0</span>{{ end }} 70 + </div> 71 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}"> 72 + {{ if .HasNew }}{{ .NewLine }}{{ else }}<span aria-hidden="true" class="invisible">0</span>{{ end }} 73 + </div> 74 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op }}</div> 75 + <div class="px-2 whitespace-pre">{{ .Body }}</div> 76 + </div> 77 + {{ end }} 78 + {{ end }} 79 + </div> 80 + {{ else }} 81 + <pre class="overflow-x-auto text-sm bg-gray-50 dark:bg-gray-900 p-3 rounded mt-2 font-mono whitespace-pre">{{ $change.Diff }}</pre> 82 + {{ end }} 83 + {{ else }} 84 + <div class="text-sm text-gray-500 mt-2">no diff available</div> 85 + {{ end }} 86 + </section> 87 + {{ end }}
+149
appview/pages/templates/repo/changes.html
··· 1 + {{ define "title" }}changes &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $title := printf "changes &middot; %s" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/changes" .RepoInfo.FullName }} 6 + 7 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 + {{ end }} 9 + 10 + {{ define "repoContent" }} 11 + <section id="change-table" class="overflow-x-auto"> 12 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 + changes 14 + </h2> 15 + 16 + <!-- desktop view (hidden on small screens) --> 17 + <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 + {{ $grid := "grid grid-cols-14 gap-4" }} 19 + <div class="{{ $grid }}"> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Author</div> 21 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Change</div> 22 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 24 + </div> 25 + {{ range $index, $change := .Changes }} 26 + {{ $messageParts := splitN $change.Message "\n\n" 2 }} 27 + <div class="{{ $grid }} py-3"> 28 + <div class="align-top col-span-3"> 29 + {{ template "pijulAttribution" $change }} 30 + </div> 31 + <div class="align-top font-mono flex items-start col-span-3"> 32 + {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 33 + <a href="/{{ $.RepoInfo.FullName }}/change/{{ $change.Hash }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 34 + {{ slice $change.Hash 0 12 }} 35 + </a> 36 + <div class="ml-2 inline-flex"> 37 + <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 38 + title="Copy hash" 39 + onclick="navigator.clipboard.writeText('{{ $change.Hash }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 40 + {{ i "copy" "w-4 h-4" }} 41 + </button> 42 + </div> 43 + </div> 44 + <div class="align-top col-span-6"> 45 + <div> 46 + <a href="/{{ $.RepoInfo.FullName }}/change/{{ $change.Hash }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 47 + 48 + {{ if gt (len $messageParts) 1 }} 49 + <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 50 + {{ end }} 51 + 52 + {{ if $change.Dependencies }} 53 + <span class="ml-2 text-xs text-gray-500" title="Depends on {{ len $change.Dependencies }} changes"> 54 + {{ i "git-branch" "w-3 h-3 inline" }} {{ len $change.Dependencies }} 55 + </span> 56 + {{ end }} 57 + </div> 58 + 59 + {{ if gt (len $messageParts) 1 }} 60 + <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 61 + {{ end }} 62 + </div> 63 + <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2"> 64 + {{ if $change.HasTimestamp }} 65 + {{ template "repo/fragments/shortTimeAgo" $change.Timestamp }} 66 + {{ end }} 67 + </div> 68 + </div> 69 + {{ end }} 70 + </div> 71 + 72 + <!-- mobile view (visible only on small screens) --> 73 + <div class="md:hidden"> 74 + {{ range $index, $change := .Changes }} 75 + <div class="relative p-2 mb-2 {{ if ne $index (sub (len $.Changes) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 76 + <div id="change-message"> 77 + {{ $messageParts := splitN $change.Message "\n\n" 2 }} 78 + <div class="text-base cursor-pointer"> 79 + <div class="flex items-center justify-between"> 80 + <div class="flex-1"> 81 + <div> 82 + <a href="/{{ $.RepoInfo.FullName }}/change/{{ $change.Hash }}" 83 + class="inline no-underline hover:underline dark:text-white"> 84 + {{ index $messageParts 0 }} 85 + </a> 86 + {{ if gt (len $messageParts) 1 }} 87 + <button 88 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 89 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 90 + {{ i "ellipsis" "w-3 h-3" }} 91 + </button> 92 + {{ end }} 93 + </div> 94 + 95 + {{ if gt (len $messageParts) 1 }} 96 + <p class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"> 97 + {{ nl2br (index $messageParts 1) }} 98 + </p> 99 + {{ end }} 100 + </div> 101 + </div> 102 + </div> 103 + </div> 104 + 105 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 106 + {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 107 + <span class="font-mono"> 108 + <a href="/{{ $.RepoInfo.FullName }}/change/{{ $change.Hash }}" 109 + class="no-underline hover:underline {{ $hashStyle }} px-2 py-1 rounded flex items-center gap-2"> 110 + {{ slice $change.Hash 0 12 }} 111 + </a> 112 + </span> 113 + <span class="mx-2 before:content-['·'] before:select-none"></span> 114 + {{ template "pijulAttribution" $change }} 115 + {{ if $change.HasTimestamp }} 116 + <div class="inline-block px-1 select-none after:content-['·']"></div> 117 + <span>{{ template "repo/fragments/shortTime" $change.Timestamp }}</span> 118 + {{ end }} 119 + </div> 120 + </div> 121 + {{ end }} 122 + </div> 123 + </section> 124 + 125 + {{ end }} 126 + 127 + {{ define "pijulAttribution" }} 128 + <span class="flex items-center gap-1"> 129 + {{ if .Authors }} 130 + {{ $author := index .Authors 0 }} 131 + {{ placeholderAvatar "tiny" }} 132 + <span class="text-gray-700 dark:text-gray-300"> 133 + {{ $author.Name }} 134 + {{ if gt (len .Authors) 1 }} +{{ sub (len .Authors) 1 }}{{ end }} 135 + </span> 136 + {{ else }} 137 + {{ placeholderAvatar "tiny" }} 138 + <span class="text-gray-500">unknown</span> 139 + {{ end }} 140 + </span> 141 + {{ end }} 142 + 143 + {{ define "repoAfter" }} 144 + {{ $changes_len := len .Changes }} 145 + <div class="flex justify-end mt-4 gap-2"> 146 + {{ if gt .Page 1 }}<a class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" hx-boost="true" onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'">{{ i "chevron-left" "w-4 h-4" }} previous</a>{{ else }}<div></div>{{ end }} 147 + {{ if eq $changes_len 60 }}<a class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" hx-boost="true" onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'">next {{ i "chevron-right" "w-4 h-4" }}</a>{{ end }} 148 + </div> 149 + {{ end }}
+91
appview/pages/templates/repo/channels.html
··· 1 + {{ define "title" }} 2 + channels &middot; {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "extrameta" }} 6 + {{ $title := printf "channels &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/channels" .RepoInfo.FullName }} 8 + 9 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 + {{ end }} 11 + 12 + {{ define "repoContent" }} 13 + <section id="channels-table" class="overflow-x-auto"> 14 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 15 + Channels 16 + </h2> 17 + 18 + <!-- desktop view (hidden on small screens) --> 19 + <table class="w-full border-collapse hidden md:table"> 20 + <thead> 21 + <tr> 22 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Name</th> 23 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Status</th> 24 + </tr> 25 + </thead> 26 + <tbody> 27 + {{ range $index, $channel := .Channels }} 28 + <tr class="{{ if ne $index (sub (len $.Channels) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 29 + <td class="py-3 whitespace-nowrap"> 30 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2"> 31 + <span class="dark:text-white"> 32 + {{ .Name }} 33 + </span> 34 + {{ if .IsCurrent }} 35 + <span class=" 36 + text-sm rounded 37 + bg-gray-100 dark:bg-gray-700 text-black dark:text-white 38 + font-mono 39 + px-2 mx-1/2 40 + inline-flex items-center 41 + "> 42 + current 43 + </span> 44 + {{ end }} 45 + </a> 46 + </td> 47 + <td class="py-3 whitespace-nowrap"> 48 + {{ if .IsCurrent }} 49 + <span class="text-green-600 dark:text-green-400 text-sm">active</span> 50 + {{ else }} 51 + <span class="text-gray-500 text-sm">-</span> 52 + {{ end }} 53 + </td> 54 + </tr> 55 + {{ end }} 56 + </tbody> 57 + </table> 58 + 59 + <!-- mobile view (visible only on small screens) --> 60 + <div class="md:hidden"> 61 + {{ range $index, $channel := .Channels }} 62 + <div class="relative p-2 {{ if ne $index (sub (len $.Channels) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 63 + <div class="flex items-center justify-between"> 64 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2"> 65 + <span class="dark:text-white font-medium"> 66 + {{ .Name }} 67 + </span> 68 + {{ if .IsCurrent }} 69 + <span class=" 70 + text-xs rounded 71 + bg-gray-100 dark:bg-gray-700 text-black dark:text-white 72 + font-mono 73 + px-2 74 + inline-flex items-center 75 + "> 76 + current 77 + </span> 78 + {{ end }} 79 + </a> 80 + </div> 81 + </div> 82 + {{ end }} 83 + </div> 84 + 85 + {{ if not .Channels }} 86 + <div class="text-gray-500 dark:text-gray-400 py-4"> 87 + No channels found in this repository. 88 + </div> 89 + {{ end }} 90 + </section> 91 + {{ end }}
+1 -1
appview/pages/templates/repo/empty.html
··· 10 10 {{ if gt (len .BranchesTrunc) 0 }} 11 11 <div class="flex flex-col items-center"> 12 12 <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 13 - This branch is empty. Other branches in this repository are populated: 13 + This {{ if .RepoInfo.IsPijul }}channel{{ else }}branch{{ end }} is empty. Other {{ if .RepoInfo.IsPijul }}channels{{ else }}branches{{ end }} in this repository are populated: 14 14 </p> 15 15 <div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 16 16 {{ range $br := .BranchesTrunc }}
+12 -10
appview/pages/templates/repo/fragments/compareForm.html
··· 53 53 {{ $name := index . 1 }} 54 54 {{ $default := index . 2 }} 55 55 <select name="{{$name}}" id="{{$name}}-select" class="p-1 border max-w-32 md:max-w-64 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 56 - <optgroup label="branches ({{ len $root.Branches }})" class="bold text-sm"> 56 + <optgroup label="{{ if $root.RepoInfo.IsPijul }}channels{{ else }}branches{{ end }} ({{ len $root.Branches }})" class="bold text-sm"> 57 57 {{ range $root.Branches }} 58 58 <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 59 59 {{ .Reference.Name }} 60 60 </option> 61 61 {{ end }} 62 62 </optgroup> 63 - <optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm"> 64 - {{ range $root.Tags }} 65 - <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 66 - {{ .Reference.Name }} 67 - </option> 68 - {{ else }} 69 - <option class="py-1" disabled>no tags found</option> 70 - {{ end }} 71 - </optgroup> 63 + {{ if not $root.RepoInfo.IsPijul }} 64 + <optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm"> 65 + {{ range $root.Tags }} 66 + <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 67 + {{ .Reference.Name }} 68 + </option> 69 + {{ else }} 70 + <option class="py-1" disabled>no tags found</option> 71 + {{ end }} 72 + </optgroup> 73 + {{ end }} 72 74 </select> 73 75 {{ end }}
+24 -9
appview/pages/templates/repo/index.html
··· 18 18 <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 21 + <a href="/{{ .RepoInfo.FullName }}/{{ if .RepoInfo.IsPijul }}channels{{ else }}branches{{ end }}" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 22 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 23 </a> 24 + {{ if not .RepoInfo.IsPijul }} 24 25 <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 25 26 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 27 </a> 28 + {{ end }} 27 29 {{ template "repo/fragments/cloneDropdown" . }} 28 30 </div> 29 31 </div> ··· 71 73 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 74 class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 73 75 > 74 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 76 + <optgroup label="{{ if .RepoInfo.IsPijul }}channels{{ else }}branches{{ end }} ({{len .Branches}})" class="bold text-sm"> 75 77 {{ range .Branches }} 76 78 <option 77 79 value="{{ .Reference.Name }}" ··· 84 86 </option> 85 87 {{ end }} 86 88 </optgroup> 89 + {{ if not .RepoInfo.IsPijul }} 87 90 <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 88 91 {{ range .Tags }} 89 92 <option ··· 99 102 <option class="py-1" disabled>no tags found</option> 100 103 {{ end }} 101 104 </optgroup> 105 + {{ end }} 102 106 </select> 103 107 <div class="flex items-center gap-2"> 104 108 <a ··· 165 169 {{ define "commitLog" }} 166 170 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 167 171 <div class="flex justify-between items-center"> 168 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 169 - {{ i "logs" "w-4 h-4" }} commits 170 - <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 171 - </a> 172 + {{ if .RepoInfo.IsPijul }} 173 + <a href="/{{ .RepoInfo.FullName }}/changes/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 174 + {{ i "logs" "w-4 h-4" }} changes 175 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 176 + </a> 177 + {{ else }} 178 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 179 + {{ i "logs" "w-4 h-4" }} commits 180 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 181 + </a> 182 + {{ end }} 172 183 </div> 173 184 <div class="flex flex-col gap-6"> 185 + {{ if .RepoInfo.IsPijul }} 186 + <div class="text-sm text-gray-500 dark:text-gray-400">See changes for the latest activity.</div> 187 + {{ else }} 174 188 {{ range .CommitsTrunc }} 175 189 <div> 176 190 <div id="commit-message"> ··· 247 261 </div> 248 262 </div> 249 263 {{ end }} 264 + {{ end }} 250 265 </div> 251 266 </div> 252 267 {{ end }} ··· 285 300 {{ define "branchList" }} 286 301 {{ if gt (len .BranchesTrunc) 0 }} 287 302 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 288 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 289 - {{ i "git-branch" "w-4 h-4" }} branches 303 + <a href="/{{ .RepoInfo.FullName }}/{{ if .RepoInfo.IsPijul }}channels{{ else }}branches{{ end }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 304 + {{ i "git-branch" "w-4 h-4" }} {{ if .RepoInfo.IsPijul }}channels{{ else }}branches{{ end }} 290 305 <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 291 306 </a> 292 307 <div class="flex flex-col gap-1"> ··· 321 336 {{ end }} 322 337 323 338 {{ define "tagList" }} 324 - {{ if gt (len .TagsTrunc) 0 }} 339 + {{ if and (not .RepoInfo.IsPijul) (gt (len .TagsTrunc) 0) }} 325 340 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 326 341 <div class="flex justify-between items-center"> 327 342 <a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
+37 -1
appview/pages/templates/repo/new.html
··· 69 69 70 70 <div class="space-y-2"> 71 71 {{ template "defaultBranch" . }} 72 + {{ template "vcs" . }} 72 73 {{ template "knot" . }} 73 74 </div> 74 75 </div> ··· 140 141 </div> 141 142 {{ end }} 142 143 144 + {{ define "vcs" }} 145 + <!-- Version Control --> 146 + <div> 147 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 148 + Repository type 149 + </label> 150 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 151 + <div class="flex items-center"> 152 + <input 153 + type="radio" 154 + name="vcs" 155 + value="git" 156 + class="mr-2" 157 + id="vcs-git" 158 + checked 159 + /> 160 + <label for="vcs-git" class="dark:text-white">git</label> 161 + </div> 162 + <div class="flex items-center"> 163 + <input 164 + type="radio" 165 + name="vcs" 166 + value="pijul" 167 + class="mr-2" 168 + id="vcs-pijul" 169 + /> 170 + <label for="vcs-pijul" class="dark:text-white">pijul</label> 171 + </div> 172 + </div> 173 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 174 + Choose the version control system for this repository. 175 + </p> 176 + </div> 177 + {{ end }} 178 + 143 179 {{ define "knot" }} 144 180 <!-- Knot Selection --> 145 181 <div> ··· 165 201 {{ end }} 166 202 </div> 167 203 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 168 - A knot hosts repository data and handles Git operations. 204 + A knot hosts repository data and handles version control operations. 169 205 You can also <a href="/settings/knots" class="underline">register your own knot</a>. 170 206 </p> 171 207 </div>
+120
appview/pages/templates/repo/pijul/discussions/list.html
··· 1 + {{ define "title" }}discussions &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $title := "discussions"}} 5 + {{ $url := printf "https://tangled.org/%s/discussions" .RepoInfo.FullName }} 6 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 + {{ end }} 8 + 9 + {{ define "repoContent" }} 10 + {{ $active := .Filter }} 11 + {{ if eq $active "" }} 12 + {{ $active = "open" }} 13 + {{ end }} 14 + 15 + {{ $open := 16 + (dict 17 + "Key" "open" 18 + "Value" "open" 19 + "Icon" "message-circle" 20 + "Meta" (string .DiscussionCount.Open)) }} 21 + {{ $merged := 22 + (dict 23 + "Key" "merged" 24 + "Value" "merged" 25 + "Icon" "git-merge" 26 + "Meta" (string .DiscussionCount.Merged)) }} 27 + {{ $closed := 28 + (dict 29 + "Key" "closed" 30 + "Value" "closed" 31 + "Icon" "ban" 32 + "Meta" (string .DiscussionCount.Closed)) }} 33 + {{ $values := list $open $merged $closed }} 34 + 35 + <div class="flex items-center justify-between gap-4 mb-4"> 36 + <div> 37 + {{ template "fragments/tabSelector" (dict "Name" "filter" "Values" $values "Active" $active) }} 38 + </div> 39 + <a 40 + href="/{{ .RepoInfo.FullName }}/discussions/new" 41 + class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 42 + > 43 + {{ i "message-square-plus" "w-4 h-4" }} 44 + <span>new discussion</span> 45 + </a> 46 + </div> 47 + <div class="error" id="discussions"></div> 48 + {{ end }} 49 + 50 + {{ define "repoAfter" }} 51 + <div class="mt-2"> 52 + {{ if .Discussions }} 53 + <div class="flex flex-col gap-2"> 54 + {{ range .Discussions }} 55 + {{ $stateBg := "bg-gray-800 dark:bg-gray-700" }} 56 + {{ $stateIcon := "ban" }} 57 + {{ $stateText := "closed" }} 58 + {{ if .State.IsOpen }} 59 + {{ $stateBg = "bg-green-600 dark:bg-green-700" }} 60 + {{ $stateIcon = "message-circle" }} 61 + {{ $stateText = "open" }} 62 + {{ else if .State.IsMerged }} 63 + {{ $stateBg = "bg-purple-600 dark:bg-purple-700" }} 64 + {{ $stateIcon = "git-merge" }} 65 + {{ $stateText = "merged" }} 66 + {{ end }} 67 + 68 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 69 + <div class="pb-2"> 70 + <a href="/{{ $.RepoInfo.FullName }}/discussions/{{ .DiscussionId }}" 71 + class="no-underline hover:underline"> 72 + {{ .Title | description }} 73 + <span class="text-gray-500 dark:text-gray-400">#{{ .DiscussionId }}</span> 74 + </a> 75 + </div> 76 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 77 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $stateBg }}"> 78 + {{ i $stateIcon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 79 + <span class="text-white dark:text-white text-sm">{{ $stateText }}</span> 80 + </span> 81 + 82 + {{ if .TargetChannel }} 83 + <span class="inline-flex items-center rounded px-2 py-[5px] bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-sm ml-1"> 84 + {{ i "git-branch" "w-3 h-3 mr-1" }} 85 + {{ .TargetChannel }} 86 + </span> 87 + {{ end }} 88 + 89 + <span class="ml-1"> 90 + {{ template "user/fragments/picHandleLink" .Did }} 91 + </span> 92 + 93 + <span class="before:content-['·']"> 94 + {{ template "repo/fragments/time" .Created }} 95 + </span> 96 + 97 + {{ $patchCount := len .ActivePatches }} 98 + {{ if gt $patchCount 0 }} 99 + <span class="before:content-['·']"> 100 + {{ $patchCount }} patch{{ if ne $patchCount 1 }}es{{ end }} 101 + </span> 102 + {{ end }} 103 + 104 + {{ $commentCount := .TotalComments }} 105 + {{ if gt $commentCount 0 }} 106 + <span class="before:content-['·']"> 107 + {{ $commentCount }} comment{{ if ne $commentCount 1 }}s{{ end }} 108 + </span> 109 + {{ end }} 110 + </div> 111 + </div> 112 + {{ end }} 113 + </div> 114 + {{ else }} 115 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center py-8"> 116 + No discussions yet. Start a new discussion to propose changes. 117 + </div> 118 + {{ end }} 119 + </div> 120 + {{ end }}
+72
appview/pages/templates/repo/pijul/discussions/new.html
··· 1 + {{ define "title" }}new discussion &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <div class="max-w-2xl"> 5 + <h2 class="text-xl font-semibold mb-4">Start a new discussion</h2> 6 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-6"> 7 + Discussions are for proposing changes to Pijul repositories. Anyone can add patches to a discussion. 8 + </p> 9 + 10 + <form 11 + method="POST" 12 + hx-post="/{{ .RepoInfo.FullName }}/discussions/new" 13 + hx-swap="none" 14 + class="flex flex-col gap-4" 15 + > 16 + <div> 17 + <label for="title" class="block text-sm font-medium mb-1">Title</label> 18 + <input 19 + type="text" 20 + id="title" 21 + name="title" 22 + required 23 + class="w-full px-3 py-2 border rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800" 24 + placeholder="Discussion title" 25 + > 26 + </div> 27 + 28 + <div> 29 + <label for="body" class="block text-sm font-medium mb-1">Description (optional)</label> 30 + <textarea 31 + id="body" 32 + name="body" 33 + rows="6" 34 + class="w-full px-3 py-2 border rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800 font-mono text-sm" 35 + placeholder="Describe your proposed changes..." 36 + ></textarea> 37 + </div> 38 + 39 + <div> 40 + <label for="target_channel" class="block text-sm font-medium mb-1">Target channel</label> 41 + <input 42 + type="text" 43 + id="target_channel" 44 + name="target_channel" 45 + value="main" 46 + class="w-full px-3 py-2 border rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800" 47 + placeholder="main" 48 + > 49 + <p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> 50 + The channel where patches should be applied when merged. 51 + </p> 52 + </div> 53 + 54 + <div class="error" id="discussion"></div> 55 + 56 + <div class="flex justify-end gap-2"> 57 + <a 58 + href="/{{ .RepoInfo.FullName }}/discussions" 59 + class="px-4 py-2 text-sm border rounded border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 no-underline" 60 + > 61 + Cancel 62 + </a> 63 + <button 64 + type="submit" 65 + class="btn-create px-4 py-2 text-sm" 66 + > 67 + Create discussion 68 + </button> 69 + </div> 70 + </form> 71 + </div> 72 + {{ end }}
+264
appview/pages/templates/repo/pijul/discussions/single.html
··· 1 + {{ define "title" }}{{ .Discussion.Title }} &middot; discussion #{{ .Discussion.DiscussionId }} &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContentLayout" }} 4 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 5 + <div class="col-span-1 md:col-span-8"> 6 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 7 + {{ block "repoContent" . }}{{ end }} 8 + </section> 9 + {{ block "repoAfter" . }}{{ end }} 10 + </div> 11 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 12 + {{ template "discussionSidebar" . }} 13 + </div> 14 + </div> 15 + {{ end }} 16 + 17 + {{ define "discussionSidebar" }} 18 + <div class="bg-white dark:bg-gray-800 rounded p-4"> 19 + <h3 class="text-sm font-semibold mb-2">Target Channel</h3> 20 + <div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"> 21 + {{ i "git-branch" "w-4 h-4" }} 22 + <span>{{ .Discussion.TargetChannel }}</span> 23 + </div> 24 + </div> 25 + 26 + <div class="bg-white dark:bg-gray-800 rounded p-4"> 27 + <h3 class="text-sm font-semibold mb-2">Patches ({{ len .ActivePatches }})</h3> 28 + {{ if .ActivePatches }} 29 + <ul class="text-sm space-y-1"> 30 + {{ range .ActivePatches }} 31 + <li class="flex items-center gap-2 text-gray-600 dark:text-gray-400"> 32 + {{ i "file-diff" "w-3 h-3" }} 33 + <code class="text-xs">{{ .PatchHash | truncate 12 }}</code> 34 + </li> 35 + {{ end }} 36 + </ul> 37 + {{ else }} 38 + <p class="text-sm text-gray-500 dark:text-gray-400">No patches yet</p> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/fragments/participants" .Discussion.Participants }} 43 + {{ end }} 44 + 45 + {{ define "repoContent" }} 46 + <section id="discussion-{{ .Discussion.DiscussionId }}"> 47 + {{ template "discussionHeader" . }} 48 + {{ template "discussionInfo" . }} 49 + {{ if .Discussion.Body }} 50 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Discussion.Body | markdown }}</article> 51 + {{ end }} 52 + </section> 53 + {{ end }} 54 + 55 + {{ define "discussionHeader" }} 56 + <header class="pb-2"> 57 + <h1 class="text-2xl"> 58 + {{ .Discussion.Title | description }} 59 + <span class="text-gray-500 dark:text-gray-400">#{{ .Discussion.DiscussionId }}</span> 60 + </h1> 61 + </header> 62 + {{ end }} 63 + 64 + {{ define "discussionInfo" }} 65 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 66 + {{ $icon := "ban" }} 67 + {{ $stateText := "closed" }} 68 + {{ if .Discussion.State.IsOpen }} 69 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 70 + {{ $icon = "message-circle" }} 71 + {{ $stateText = "open" }} 72 + {{ else if .Discussion.State.IsMerged }} 73 + {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 74 + {{ $icon = "git-merge" }} 75 + {{ $stateText = "merged" }} 76 + {{ end }} 77 + 78 + <div class="inline-flex items-center gap-2 flex-wrap"> 79 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 80 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 81 + <span class="text-white dark:text-white text-sm">{{ $stateText }}</span> 82 + </span> 83 + 84 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 85 + opened by 86 + {{ template "user/fragments/picHandleLink" .Discussion.Did }} 87 + <span class="select-none before:content-['\00B7']"></span> 88 + {{ if .Discussion.Edited }} 89 + edited {{ template "repo/fragments/time" .Discussion.Edited }} 90 + {{ else }} 91 + {{ template "repo/fragments/time" .Discussion.Created }} 92 + {{ end }} 93 + </span> 94 + </div> 95 + <div id="discussion-actions-error" class="error"></div> 96 + {{ end }} 97 + 98 + {{ define "repoAfter" }} 99 + <div class="flex flex-col gap-4 mt-4"> 100 + <!-- Patches section --> 101 + <section class="bg-white dark:bg-gray-800 p-6 rounded"> 102 + <h3 class="text-lg font-semibold mb-4">Patches</h3> 103 + {{ if .Discussion.Patches }} 104 + <div class="space-y-3"> 105 + {{ range .Discussion.Patches }} 106 + <div class="border rounded p-4 dark:border-gray-700 {{ if not .IsActive }}opacity-50{{ end }}"> 107 + <div class="flex items-center justify-between"> 108 + <div class="flex items-center gap-2"> 109 + {{ i "file-diff" "w-4 h-4 text-gray-500" }} 110 + <code class="text-sm font-mono">{{ .PatchHash | truncate 20 }}</code> 111 + {{ if not .IsActive }} 112 + <span class="text-xs text-red-500">(removed)</span> 113 + {{ end }} 114 + </div> 115 + <div class="flex items-center gap-2 text-sm text-gray-500"> 116 + <span>by {{ template "user/fragments/picHandleLink" .PushedByDid }}</span> 117 + <span>{{ template "repo/fragments/time" .Added }}</span> 118 + </div> 119 + </div> 120 + {{ if $.CanManage }} 121 + <div class="mt-2 flex gap-2"> 122 + {{ if .IsActive }} 123 + <form hx-delete="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/patches/{{ .Id }}" hx-swap="none"> 124 + <button type="submit" class="text-xs text-red-600 hover:underline">Remove</button> 125 + </form> 126 + {{ else }} 127 + <form hx-post="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/patches/{{ .Id }}/readd" hx-swap="none"> 128 + <button type="submit" class="text-xs text-green-600 hover:underline">Re-add</button> 129 + </form> 130 + {{ end }} 131 + </div> 132 + {{ end }} 133 + </div> 134 + {{ end }} 135 + </div> 136 + {{ else }} 137 + <p class="text-gray-500 dark:text-gray-400 text-sm">No patches have been added to this discussion yet.</p> 138 + {{ end }} 139 + 140 + <!-- Add patch form --> 141 + {{ if and $.LoggedInUser $.Discussion.State.IsOpen }} 142 + <div class="mt-4 pt-4 border-t dark:border-gray-700"> 143 + <h4 class="text-sm font-semibold mb-2">Add a patch</h4> 144 + <form 145 + hx-post="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/patches" 146 + hx-swap="none" 147 + class="space-y-3" 148 + > 149 + <div> 150 + <label for="patch_hash" class="block text-xs text-gray-500 mb-1">Patch Hash</label> 151 + <input 152 + type="text" 153 + id="patch_hash" 154 + name="patch_hash" 155 + required 156 + class="w-full px-3 py-2 text-sm border rounded dark:border-gray-600 dark:bg-gray-700" 157 + placeholder="Pijul change hash" 158 + > 159 + </div> 160 + <div> 161 + <label for="patch" class="block text-xs text-gray-500 mb-1">Patch Content</label> 162 + <textarea 163 + id="patch" 164 + name="patch" 165 + required 166 + rows="4" 167 + class="w-full px-3 py-2 text-sm border rounded font-mono dark:border-gray-600 dark:bg-gray-700" 168 + placeholder="Paste your patch content here..." 169 + ></textarea> 170 + </div> 171 + <div class="error" id="patch"></div> 172 + <button type="submit" class="btn-primary text-sm px-4 py-2">Add patch</button> 173 + </form> 174 + </div> 175 + {{ end }} 176 + </section> 177 + 178 + <!-- Comments section --> 179 + <section class="bg-white dark:bg-gray-800 p-6 rounded"> 180 + <h3 class="text-lg font-semibold mb-4">Comments</h3> 181 + {{ if .CommentList }} 182 + <div class="space-y-4"> 183 + {{ range .CommentList }} 184 + <div class="border rounded p-4 dark:border-gray-700"> 185 + <div class="flex items-center gap-2 text-sm text-gray-500 mb-2"> 186 + {{ template "user/fragments/picHandleLink" .Self.Did }} 187 + <span>{{ template "repo/fragments/time" .Self.Created }}</span> 188 + </div> 189 + <div class="prose dark:prose-invert">{{ .Self.Body | markdown }}</div> 190 + {{ if .Replies }} 191 + <div class="mt-4 ml-4 space-y-3 border-l-2 border-gray-200 dark:border-gray-600 pl-4"> 192 + {{ range .Replies }} 193 + <div class="text-sm"> 194 + <div class="flex items-center gap-2 text-gray-500 mb-1"> 195 + {{ template "user/fragments/picHandleLink" .Did }} 196 + <span>{{ template "repo/fragments/time" .Created }}</span> 197 + </div> 198 + <div class="prose dark:prose-invert prose-sm">{{ .Body | markdown }}</div> 199 + </div> 200 + {{ end }} 201 + </div> 202 + {{ end }} 203 + </div> 204 + {{ end }} 205 + </div> 206 + {{ else }} 207 + <p class="text-gray-500 dark:text-gray-400 text-sm">No comments yet.</p> 208 + {{ end }} 209 + 210 + <!-- New comment form --> 211 + {{ if $.LoggedInUser }} 212 + <div class="mt-4 pt-4 border-t dark:border-gray-700"> 213 + <form 214 + hx-post="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/comment" 215 + hx-swap="none" 216 + class="space-y-3" 217 + > 218 + <textarea 219 + name="body" 220 + required 221 + rows="3" 222 + class="w-full px-3 py-2 text-sm border rounded dark:border-gray-600 dark:bg-gray-700" 223 + placeholder="Add a comment..." 224 + ></textarea> 225 + <div class="error" id="comment"></div> 226 + <button type="submit" class="btn-primary text-sm px-4 py-2">Comment</button> 227 + </form> 228 + </div> 229 + {{ end }} 230 + </section> 231 + 232 + <!-- Discussion actions --> 233 + {{ if $.LoggedInUser }} 234 + <section class="bg-white dark:bg-gray-800 p-6 rounded"> 235 + <div class="flex flex-wrap gap-2"> 236 + {{ if $.Discussion.State.IsOpen }} 237 + {{ if $.CanManage }} 238 + <form hx-post="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/merge" hx-swap="none"> 239 + <button type="submit" class="btn-create text-sm px-4 py-2 flex items-center gap-2"> 240 + {{ i "git-merge" "w-4 h-4" }} 241 + Merge 242 + </button> 243 + </form> 244 + {{ end }} 245 + <form hx-post="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/close" hx-swap="none"> 246 + <button type="submit" class="btn-secondary text-sm px-4 py-2 flex items-center gap-2"> 247 + {{ i "ban" "w-4 h-4" }} 248 + Close 249 + </button> 250 + </form> 251 + {{ else if $.Discussion.State.IsClosed }} 252 + <form hx-post="/{{ $.RepoInfo.FullName }}/discussions/{{ $.Discussion.DiscussionId }}/reopen" hx-swap="none"> 253 + <button type="submit" class="btn-primary text-sm px-4 py-2 flex items-center gap-2"> 254 + {{ i "refresh-cw" "w-4 h-4" }} 255 + Reopen 256 + </button> 257 + </form> 258 + {{ end }} 259 + </div> 260 + <div class="error" id="discussion"></div> 261 + </section> 262 + {{ end }} 263 + </div> 264 + {{ end }}
+1 -2
appview/pages/templates/repo/settings/general.html
··· 97 97 <div class="col-span-1 md:col-span-2"> 98 98 <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 99 99 <p class="text-gray-500 dark:text-gray-400"> 100 - Manage your issues and pulls by creating labels to categorize them. Only 100 + Manage your {{ if .RepoInfo.IsPijul }}discussions{{ else }}issues and pulls{{ end }} by creating labels to categorize them. Only 101 101 repository owners may configure labels. You may choose to subscribe to 102 102 default labels, or create entirely custom labels. 103 103 <p> ··· 240 240 </div> 241 241 {{ end }} 242 242 {{ end }} 243 -
+35 -8
appview/repo/blob.go
··· 59 59 Host: host, 60 60 } 61 61 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 62 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 63 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 64 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 65 - rp.pages.Error503(w) 66 - return 62 + 63 + var resp *tangled.RepoBlob_Output 64 + if f.IsPijul() { 65 + pResp, err := tangled.RepoPijulBlob(r.Context(), xrpcc, ref, filePath, repo) 66 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 67 + l.Error("failed to call XRPC repo.pijulBlob", "err", xrpcerr) 68 + rp.pages.Error503(w) 69 + return 70 + } 71 + resp = &tangled.RepoBlob_Output{ 72 + Path: pResp.Path, 73 + Content: pResp.Contents, 74 + IsBinary: &pResp.Is_binary, 75 + } 76 + } else { 77 + var err error 78 + resp, err = tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 79 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 80 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 81 + rp.pages.Error503(w) 82 + return 83 + } 67 84 } 68 85 69 86 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 140 157 scheme = "https" 141 158 } 142 159 repo := f.DidSlashRepo() 160 + xrpcPath := "/xrpc/sh.tangled.repo.blob" 161 + if f.IsPijul() { 162 + xrpcPath = "/xrpc/sh.tangled.repo.pijulBlob" 163 + } 143 164 baseURL := &url.URL{ 144 165 Scheme: scheme, 145 166 Host: f.Knot, 146 - Path: "/xrpc/sh.tangled.repo.blob", 167 + Path: xrpcPath, 147 168 } 148 169 query := baseURL.Query() 149 170 query.Set("repo", repo) 150 - query.Set("ref", ref) 171 + if f.IsPijul() { 172 + query.Set("channel", ref) 173 + } else { 174 + query.Set("ref", ref) 175 + } 151 176 query.Set("path", filePath) 152 - query.Set("raw", "true") 177 + if !f.IsPijul() { 178 + query.Set("raw", "true") 179 + } 153 180 baseURL.RawQuery = query.Encode() 154 181 blobURL := baseURL.String() 155 182 req, err := http.NewRequest("GET", blobURL, nil)
+306
appview/repo/changes.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strconv" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/pages" 13 + xrpcclient "tangled.org/core/appview/xrpcclient" 14 + 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-chi/chi/v5" 17 + ) 18 + 19 + func (rp *Repo) Changes(w http.ResponseWriter, r *http.Request) { 20 + l := rp.logger.With("handler", "RepoChanges") 21 + 22 + f, err := rp.repoResolver.Resolve(r) 23 + if err != nil { 24 + l.Error("failed to fully resolve repo", "err", err) 25 + return 26 + } 27 + if !f.IsPijul() { 28 + rp.pages.Error404(w) 29 + return 30 + } 31 + 32 + page := 1 33 + if r.URL.Query().Get("page") != "" { 34 + page, err = strconv.Atoi(r.URL.Query().Get("page")) 35 + if err != nil { 36 + page = 1 37 + } 38 + } 39 + 40 + ref := chi.URLParam(r, "ref") 41 + ref, _ = url.PathUnescape(ref) 42 + 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 + } 51 + 52 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 53 + 54 + if ref == "" { 55 + channels, err := tangled.RepoChannelList(r.Context(), xrpcc, "", 0, repo) 56 + if err != nil { 57 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 + l.Error("failed to call XRPC repo.channelList", "err", xrpcerr) 59 + rp.pages.Error503(w) 60 + return 61 + } 62 + rp.pages.Error503(w) 63 + return 64 + } 65 + for _, ch := range channels.Channels { 66 + if ch.Is_current != nil && *ch.Is_current { 67 + ref = ch.Name 68 + break 69 + } 70 + } 71 + if ref == "" && len(channels.Channels) > 0 { 72 + ref = channels.Channels[0].Name 73 + } 74 + } 75 + 76 + limit := int64(60) 77 + cursor := "" 78 + if page > 1 { 79 + offset := (page - 1) * int(limit) 80 + cursor = strconv.Itoa(offset) 81 + } 82 + 83 + resp, err := tangled.RepoChangeList(r.Context(), xrpcc, ref, cursor, limit, repo) 84 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 85 + l.Error("failed to call XRPC repo.changeList", "err", xrpcerr) 86 + rp.pages.Error503(w) 87 + return 88 + } 89 + 90 + changes := make([]pages.PijulChangeView, 0, len(resp.Changes)) 91 + for _, change := range resp.Changes { 92 + view := pages.PijulChangeView{ 93 + Hash: change.Hash, 94 + Authors: change.Authors, 95 + Message: change.Message, 96 + Dependencies: change.Dependencies, 97 + } 98 + if change.Timestamp != nil { 99 + if parsed, err := time.Parse(time.RFC3339, *change.Timestamp); err == nil { 100 + view.Timestamp = parsed 101 + view.HasTimestamp = true 102 + } 103 + } 104 + changes = append(changes, view) 105 + } 106 + 107 + user := rp.oauth.GetMultiAccountUser(r) 108 + rp.pages.RepoChanges(w, pages.RepoChangesParams{ 109 + LoggedInUser: user, 110 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 111 + Page: page, 112 + Changes: changes, 113 + }) 114 + } 115 + 116 + func (rp *Repo) Change(w http.ResponseWriter, r *http.Request) { 117 + l := rp.logger.With("handler", "RepoChange") 118 + 119 + f, err := rp.repoResolver.Resolve(r) 120 + if err != nil { 121 + l.Error("failed to fully resolve repo", "err", err) 122 + return 123 + } 124 + if !f.IsPijul() { 125 + rp.pages.Error404(w) 126 + return 127 + } 128 + 129 + hash := chi.URLParam(r, "hash") 130 + if hash == "" { 131 + rp.pages.Error404(w) 132 + return 133 + } 134 + 135 + scheme := "http" 136 + if !rp.config.Core.Dev { 137 + scheme = "https" 138 + } 139 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 140 + xrpcc := &indigoxrpc.Client{ 141 + Host: host, 142 + } 143 + 144 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 145 + resp, err := tangled.RepoChangeGet(r.Context(), xrpcc, hash, repo) 146 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 147 + l.Error("failed to call XRPC repo.changeGet", "err", xrpcerr) 148 + rp.pages.Error503(w) 149 + return 150 + } 151 + 152 + change := pages.PijulChangeDetail{ 153 + Hash: resp.Hash, 154 + Authors: resp.Authors, 155 + Message: resp.Message, 156 + Dependencies: resp.Dependencies, 157 + } 158 + if resp.Diff != nil { 159 + change.Diff = *resp.Diff 160 + change.HasDiff = true 161 + change.DiffLines = parsePijulDiffLines(change.Diff) 162 + } 163 + if resp.Timestamp != nil { 164 + if parsed, err := time.Parse(time.RFC3339, *resp.Timestamp); err == nil { 165 + change.Timestamp = parsed 166 + change.HasTimestamp = true 167 + } 168 + } 169 + 170 + user := rp.oauth.GetMultiAccountUser(r) 171 + rp.pages.RepoChange(w, pages.RepoChangeParams{ 172 + LoggedInUser: user, 173 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 174 + Change: change, 175 + }) 176 + } 177 + 178 + func parsePijulDiffLines(diff string) []pages.PijulDiffLine { 179 + if diff == "" { 180 + return nil 181 + } 182 + lines := strings.Split(diff, "\n") 183 + out := make([]pages.PijulDiffLine, 0, len(lines)) 184 + var oldLine int64 185 + var newLine int64 186 + var hasOld bool 187 + var hasNew bool 188 + for _, line := range lines { 189 + kind := "context" 190 + op := " " 191 + body := line 192 + switch { 193 + case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "): 194 + kind = "meta" 195 + op = "" 196 + hasOld = false 197 + hasNew = false 198 + case strings.HasPrefix(line, "@@"): 199 + kind = "meta" 200 + op = "" 201 + if o, n, ok := parseUnifiedHunkHeader(line); ok { 202 + oldLine = o 203 + newLine = n 204 + hasOld = true 205 + hasNew = true 206 + } else { 207 + hasOld = false 208 + hasNew = false 209 + } 210 + case strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "index "): 211 + kind = "meta" 212 + op = "" 213 + hasOld = false 214 + hasNew = false 215 + case strings.HasPrefix(line, "#"): 216 + kind = "section" 217 + op = "" 218 + hasOld = false 219 + hasNew = false 220 + case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): 221 + kind = "add" 222 + op = "+" 223 + body = line[1:] 224 + case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): 225 + kind = "del" 226 + op = "-" 227 + body = line[1:] 228 + case strings.HasPrefix(line, " "): 229 + body = line[1:] 230 + } 231 + diffLine := pages.PijulDiffLine{ 232 + Kind: kind, 233 + Op: op, 234 + Body: body, 235 + Text: line, 236 + } 237 + if kind != "meta" { 238 + if kind == "del" { 239 + if hasOld { 240 + diffLine.OldLine = oldLine 241 + diffLine.HasOld = true 242 + oldLine++ 243 + } 244 + } else if kind == "add" { 245 + if hasNew { 246 + diffLine.NewLine = newLine 247 + diffLine.HasNew = true 248 + newLine++ 249 + } 250 + } else { 251 + if hasOld { 252 + diffLine.OldLine = oldLine 253 + diffLine.HasOld = true 254 + oldLine++ 255 + } 256 + if hasNew { 257 + diffLine.NewLine = newLine 258 + diffLine.HasNew = true 259 + newLine++ 260 + } 261 + } 262 + } 263 + out = append(out, diffLine) 264 + } 265 + return out 266 + } 267 + 268 + func parseUnifiedHunkHeader(line string) (int64, int64, bool) { 269 + start := strings.Index(line, "@@") 270 + if start == -1 { 271 + return 0, 0, false 272 + } 273 + trimmed := strings.TrimSpace(line[start+2:]) 274 + end := strings.Index(trimmed, "@@") 275 + if end == -1 { 276 + return 0, 0, false 277 + } 278 + fields := strings.Fields(strings.TrimSpace(trimmed[:end])) 279 + if len(fields) < 2 { 280 + return 0, 0, false 281 + } 282 + oldStart, okOld := parseUnifiedRange(fields[0], "-") 283 + newStart, okNew := parseUnifiedRange(fields[1], "+") 284 + if !okOld || !okNew { 285 + return 0, 0, false 286 + } 287 + return oldStart, newStart, true 288 + } 289 + 290 + func parseUnifiedRange(value, prefix string) (int64, bool) { 291 + if !strings.HasPrefix(value, prefix) { 292 + return 0, false 293 + } 294 + value = strings.TrimPrefix(value, prefix) 295 + if value == "" { 296 + return 0, false 297 + } 298 + if idx := strings.Index(value, ","); idx >= 0 { 299 + value = value[:idx] 300 + } 301 + out, err := strconv.ParseInt(value, 10, 64) 302 + if err != nil { 303 + return 0, false 304 + } 305 + return out, true 306 + }
+6 -1
appview/repo/index.go
··· 173 173 currentRef string, 174 174 isDefaultRef bool, 175 175 ) ([]types.RepoLanguageDetails, error) { 176 + if repo.IsPijul() { 177 + return nil, nil 178 + } 176 179 // first attempt to fetch from db 177 180 langs, err := db.GetRepoLanguages( 178 181 rp.db, ··· 257 260 return languageStats, nil 258 261 } 259 262 260 - // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 263 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel. 264 + // Works for both git and pijul repos since the XRPC endpoints are VCS-agnostic. 261 265 func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 262 266 didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 263 267 ··· 385 389 386 390 return result, nil 387 391 } 392 +
+2 -3
appview/repo/repo.go
··· 796 796 fail("Failed to add collaborator permissions.", err) 797 797 return 798 798 } 799 - 800 799 err = db.AddCollaborator(tx, models.Collaborator{ 801 800 Did: syntax.DID(currentUser.Active.Did), 802 801 Rkey: rkey, ··· 914 913 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 915 914 return 916 915 } 917 - 918 916 // remove repo from db 919 917 err = db.RemoveRepo(tx, f.Did, f.Name) 920 918 if err != nil { ··· 1080 1078 Description: f.Description, 1081 1079 Created: time.Now(), 1082 1080 Labels: rp.config.Label.DefaultLabelDefs, 1081 + Vcs: f.Vcs, 1083 1082 } 1084 1083 record := repo.AsRecord() 1085 1084 ··· 1156 1155 &tangled.RepoCreate_Input{ 1157 1156 Rkey: rkey, 1158 1157 Source: &forkSourceUrl, 1158 + Vcs: &f.Vcs, 1159 1159 }, 1160 1160 ) 1161 1161 if err := xrpcclient.HandleXrpcErr(err); err != nil { ··· 1178 1178 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1179 1179 return 1180 1180 } 1181 - 1182 1181 err = tx.Commit() 1183 1182 if err != nil { 1184 1183 l.Error("failed to commit changes", "err", err)
+3
appview/repo/router.go
··· 13 13 r.Get("/opengraph", rp.Opengraph) 14 14 r.Get("/feed.atom", rp.AtomFeed) 15 15 r.Get("/commits/{ref}", rp.Log) 16 + r.Get("/changes", rp.Changes) 17 + r.Get("/changes/{ref}", rp.Changes) 16 18 r.Route("/tree/{ref}", func(r chi.Router) { 17 19 r.Get("/", rp.Index) 18 20 r.Get("/*", rp.Tree) 19 21 }) 20 22 r.Get("/commit/{ref}", rp.Commit) 23 + r.Get("/change/{hash}", rp.Change) 21 24 r.Get("/branches", rp.Branches) 22 25 r.Delete("/branches", rp.DeleteBranch) 23 26 r.Route("/tags", func(r chi.Router) {
+8
appview/repo/tags.go
··· 27 27 l.Error("failed to get repo and knot", "err", err) 28 28 return 29 29 } 30 + if f.IsPijul() { 31 + rp.pages.Error404(w) 32 + return 33 + } 34 + if f.IsPijul() { 35 + rp.pages.Error404(w) 36 + return 37 + } 30 38 scheme := "http" 31 39 if !rp.config.Core.Dev { 32 40 scheme = "https"
+65
appview/repo/tree.go
··· 42 42 Host: host, 43 43 } 44 44 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 45 + if f.IsPijul() { 46 + xrpcResp, err := tangled.RepoPijulTree(r.Context(), xrpcc, ref, treePath, repo) 47 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 48 + l.Error("failed to call XRPC repo.pijulTree", "err", xrpcerr) 49 + rp.pages.Error503(w) 50 + return 51 + } 52 + files := make([]types.NiceTree, len(xrpcResp.Files)) 53 + for i, xrpcFile := range xrpcResp.Files { 54 + files[i] = types.NiceTree{ 55 + Name: xrpcFile.Name, 56 + Mode: xrpcFile.Mode, 57 + Size: xrpcFile.Size, 58 + } 59 + } 60 + result := types.RepoTreeResponse{ 61 + Ref: ref, 62 + Files: files, 63 + } 64 + if xrpcResp.Ref != nil { 65 + result.Ref = *xrpcResp.Ref 66 + } 67 + if xrpcResp.Parent != nil { 68 + result.Parent = *xrpcResp.Parent 69 + } 70 + if xrpcResp.Dotdot != nil { 71 + result.DotDot = *xrpcResp.Dotdot 72 + } 73 + if xrpcResp.Readme != nil { 74 + if xrpcResp.Readme.Filename != nil { 75 + result.ReadmeFileName = *xrpcResp.Readme.Filename 76 + } 77 + if xrpcResp.Readme.Contents != nil { 78 + result.Readme = *xrpcResp.Readme.Contents 79 + } 80 + } 81 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 82 + displayRef := ref 83 + if displayRef == "" { 84 + displayRef = result.Ref 85 + } 86 + if len(result.Files) == 0 && result.Parent == treePath { 87 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(displayRef), result.Parent) 88 + http.Redirect(w, r, redirectTo, http.StatusFound) 89 + return 90 + } 91 + user := rp.oauth.GetMultiAccountUser(r) 92 + var breadcrumbs [][]string 93 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(displayRef))}) 94 + if treePath != "" { 95 + for idx, elem := range strings.Split(treePath, "/") { 96 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 97 + } 98 + } 99 + sortFiles(result.Files) 100 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 101 + LoggedInUser: user, 102 + BreadCrumbs: breadcrumbs, 103 + Path: treePath, 104 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 105 + RepoTreeResponse: result, 106 + }) 107 + return 108 + } 109 + 45 110 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 46 111 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 47 112 l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
+1
appview/reporesolver/resolver.go
··· 121 121 Topics: repo.Topics, 122 122 Knot: repo.Knot, 123 123 Spindle: repo.Spindle, 124 + Vcs: repo.Vcs, 124 125 Stats: *stats, 125 126 126 127 // fork repo upstream
+19
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 + "tangled.org/core/appview/discussions" 8 9 "tangled.org/core/appview/issues" 9 10 "tangled.org/core/appview/knots" 10 11 "tangled.org/core/appview/labels" ··· 92 93 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 93 94 r.Use(mw.GoImport()) 94 95 r.Mount("/", s.RepoRouter(mw)) 96 + r.Mount("/discussions", s.DiscussionsRouter(mw)) 95 97 r.Mount("/issues", s.IssuesRouter(mw)) 96 98 r.Mount("/pulls", s.PullsRouter(mw)) 97 99 r.Mount("/pipelines", s.PipelinesRouter(mw)) ··· 285 287 log.SubLogger(s.logger, "issues"), 286 288 ) 287 289 return issues.Router(mw) 290 + } 291 + 292 + func (s *State) DiscussionsRouter(mw *middleware.Middleware) http.Handler { 293 + discussions := discussions.New( 294 + s.oauth, 295 + s.repoResolver, 296 + s.enforcer, 297 + s.pages, 298 + s.idResolver, 299 + s.mentionsResolver, 300 + s.db, 301 + s.config, 302 + s.notifier, 303 + s.validator, 304 + log.SubLogger(s.logger, "discussions"), 305 + ) 306 + return discussions.Router(mw) 288 307 } 289 308 290 309 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
+14 -1
appview/state/state.go
··· 418 418 } 419 419 l = l.With("defaultBranch", defaultBranch) 420 420 421 + vcs := strings.ToLower(strings.TrimSpace(r.FormValue("vcs"))) 422 + if vcs == "" { 423 + vcs = "git" 424 + } 425 + switch vcs { 426 + case "git", "pijul": 427 + default: 428 + s.pages.Notice(w, "repo", "Invalid repository type.") 429 + return 430 + } 431 + l = l.With("vcs", vcs) 432 + 421 433 description := r.FormValue("description") 422 434 if len([]rune(description)) > 140 { 423 435 s.pages.Notice(w, "repo", "Description must be 140 characters or fewer.") ··· 454 466 Description: description, 455 467 Created: time.Now(), 456 468 Labels: s.config.Label.DefaultLabelDefs, 469 + Vcs: vcs, 457 470 } 458 471 record := repo.AsRecord() 459 472 ··· 527 540 client, 528 541 &tangled.RepoCreate_Input{ 529 542 Rkey: rkey, 543 + Vcs: &vcs, 530 544 }, 531 545 ) 532 546 if err := xrpcclient.HandleXrpcErr(xe); err != nil { ··· 550 564 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 551 565 return 552 566 } 553 - 554 567 err = tx.Commit() 555 568 if err != nil { 556 569 l.Error("txn commit failed", "err", err)
+4
cmd/cborgen/cborgen.go
··· 30 30 tangled.LabelDefinition_ValueType{}, 31 31 tangled.LabelOp{}, 32 32 tangled.LabelOp_Operand{}, 33 + tangled.PijulRefUpdate{}, 33 34 tangled.Pipeline{}, 34 35 tangled.Pipeline_CloneOpts{}, 35 36 tangled.Pipeline_ManualTriggerData{}, ··· 44 45 tangled.Repo{}, 45 46 tangled.RepoArtifact{}, 46 47 tangled.RepoCollaborator{}, 48 + tangled.RepoDiscussion{}, 49 + tangled.RepoDiscussionComment{}, 50 + tangled.RepoDiscussionState{}, 47 51 tangled.RepoIssue{}, 48 52 tangled.RepoIssueComment{}, 49 53 tangled.RepoIssueState{},
-1
flake.nix
··· 317 317 318 318 rm -f api/tangled/* 319 319 lexgen --build-file lexicon-build-config.json lexicons 320 - sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 321 320 # lexgen generates incomplete Marshaler/Unmarshaler for union types 322 321 find api/tangled/*.go -not -name "cbor_gen.go" -exec \ 323 322 sed -i '/^func.*\(MarshalCBOR\|UnmarshalCBOR\)/,/^}/ s/^/\/\/ /' {} +
+89
guard/guard.go
··· 105 105 os.Exit(-1) 106 106 } 107 107 108 + if cmdParts[0] == "pijul" { 109 + if cmdParts[1] != "protocol" { 110 + l.Error("access denied: invalid pijul command", "command", sshCommand) 111 + fmt.Fprintln(os.Stderr, "access denied: invalid pijul command") 112 + return fmt.Errorf("access denied: invalid pijul command") 113 + } 114 + 115 + repoPath, version, err := parsePijulProtocolArgs(cmdParts[2:]) 116 + if err != nil { 117 + l.Error("invalid pijul protocol args", "command", sshCommand, "err", err) 118 + fmt.Fprintln(os.Stderr, "invalid pijul protocol args") 119 + return err 120 + } 121 + if version != "" && version != "3" { 122 + l.Error("unsupported pijul protocol version", "version", version) 123 + fmt.Fprintln(os.Stderr, "unsupported pijul protocol version") 124 + return fmt.Errorf("unsupported pijul protocol version") 125 + } 126 + 127 + qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, "pijul-protocol") 128 + if err != nil { 129 + l.Error("failed to run guard", "err", err) 130 + fmt.Fprintln(os.Stderr, err) 131 + os.Exit(1) 132 + } 133 + 134 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 135 + args := []string{"protocol", "--repository", fullPath} 136 + if version != "" { 137 + args = append(args, "--version", version) 138 + } 139 + 140 + l.Info("processing command", 141 + "user", incomingUser, 142 + "command", "pijul protocol", 143 + "repo", repoPath, 144 + "fullPath", fullPath, 145 + "client", clientIP) 146 + 147 + pijulCmd := exec.Command("pijul", args...) 148 + pijulCmd.Stdout = os.Stdout 149 + pijulCmd.Stderr = os.Stderr 150 + pijulCmd.Stdin = os.Stdin 151 + 152 + if err := pijulCmd.Run(); err != nil { 153 + l.Error("command failed", "error", err) 154 + fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 155 + return fmt.Errorf("command failed: %v", err) 156 + } 157 + 158 + l.Info("command completed", 159 + "user", incomingUser, 160 + "command", "pijul protocol", 161 + "repo", repoPath, 162 + "success", true) 163 + 164 + return nil 165 + } 166 + 108 167 gitCommand := cmdParts[0] 109 168 repoPath := cmdParts[1] 110 169 ··· 171 230 "success", true) 172 231 173 232 return nil 233 + } 234 + 235 + func parsePijulProtocolArgs(args []string) (string, string, error) { 236 + var repo string 237 + var version string 238 + for i := 0; i < len(args); i++ { 239 + arg := args[i] 240 + switch { 241 + case arg == "--repository" || arg == "-r": 242 + if i+1 >= len(args) { 243 + return "", "", fmt.Errorf("missing --repository value") 244 + } 245 + repo = args[i+1] 246 + i++ 247 + case strings.HasPrefix(arg, "--repository="): 248 + repo = strings.TrimPrefix(arg, "--repository=") 249 + case arg == "--version": 250 + if i+1 >= len(args) { 251 + return "", "", fmt.Errorf("missing --version value") 252 + } 253 + version = args[i+1] 254 + i++ 255 + case strings.HasPrefix(arg, "--version="): 256 + version = strings.TrimPrefix(arg, "--version=") 257 + } 258 + } 259 + if repo == "" { 260 + return "", "", fmt.Errorf("missing --repository") 261 + } 262 + return repo, version, nil 174 263 } 175 264 176 265 // runs guardAndQualifyRepo logic
+4 -1
knotserver/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "strings" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/sethvargo/go-envconfig" ··· 35 36 } 36 37 37 38 func (s Server) Did() syntax.DID { 38 - return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 39 + // did:web spec requires colons to be encoded as %3A 40 + encoded := strings.ReplaceAll(s.Hostname, ":", "%3A") 41 + return syntax.DID(fmt.Sprintf("did:web:%s", encoded)) 39 42 } 40 43 41 44 type Config struct {
+5
knotserver/git/git.go
··· 80 80 return g.h 81 81 } 82 82 83 + // Path returns the on-disk path of the repository. 84 + func (g *GitRepo) Path() string { 85 + return g.path 86 + } 87 + 83 88 // re-open a repository and update references 84 89 func (g *GitRepo) Refresh() error { 85 90 refreshed, err := PlainOpen(g.path)
+1
knotserver/ingester.go
··· 18 18 "tangled.org/core/api/tangled" 19 19 "tangled.org/core/knotserver/db" 20 20 "tangled.org/core/knotserver/git" 21 + 21 22 "tangled.org/core/log" 22 23 "tangled.org/core/rbac" 23 24 "tangled.org/core/workflow"
+10 -2
knotserver/internal.go
··· 90 90 // did:foo/repo-name or 91 91 // handle/repo-name or 92 92 // any of the above with a leading slash (/) 93 - components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 93 + components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'\""), "/"), "/") 94 94 l.Info("command components", "components", components) 95 95 96 96 if len(components) != 2 { ··· 116 116 qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName) 117 117 118 118 if gitCommand == "git-receive-pack" { 119 + ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 120 + if err != nil || !ok { 121 + w.WriteHeader(http.StatusForbidden) 122 + fmt.Fprint(w, repo) 123 + return 124 + } 125 + } 126 + if gitCommand == "pijul-protocol" { 119 127 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 120 128 if err != nil || !ok { 121 129 w.WriteHeader(http.StatusForbidden) ··· 216 224 217 225 var errs error 218 226 meta, err := gr.RefUpdateMeta(line) 219 - errors.Join(errs, err) 227 + errs = errors.Join(errs, err) 220 228 221 229 metaRecord := meta.AsRecord() 222 230
+329
knotserver/pijul/change.go
··· 1 + package pijul 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "encoding/json" 7 + "fmt" 8 + "strconv" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + // Change represents a Pijul change (analogous to a Git commit) 14 + type Change struct { 15 + // Hash is the unique identifier for this change (base32 encoded) 16 + Hash string `json:"hash"` 17 + 18 + // Authors who created this change 19 + Authors []Author `json:"authors"` 20 + 21 + // Message is the change description 22 + Message string `json:"message"` 23 + 24 + // Timestamp when the change was recorded 25 + Timestamp time.Time `json:"timestamp"` 26 + 27 + // Dependencies are hashes of changes this change depends on 28 + Dependencies []string `json:"dependencies,omitempty"` 29 + 30 + // Channel where this change exists 31 + Channel string `json:"channel,omitempty"` 32 + } 33 + 34 + // Author represents a change author 35 + type Author struct { 36 + Name string `json:"name"` 37 + Email string `json:"email,omitempty"` 38 + } 39 + 40 + // Changes returns a list of changes in the repository 41 + // offset and limit control pagination 42 + func (p *PijulRepo) Changes(offset, limit int) ([]Change, error) { 43 + args := []string{"--offset", strconv.Itoa(offset), "--limit", strconv.Itoa(limit)} 44 + 45 + if p.channelName != "" { 46 + args = append(args, "--channel", p.channelName) 47 + } 48 + 49 + output, err := p.log(args...) 50 + if err != nil { 51 + if isNoChangesError(err) { 52 + return []Change{}, nil 53 + } 54 + return nil, fmt.Errorf("pijul log: %w", err) 55 + } 56 + 57 + return parseLogOutput(output) 58 + } 59 + 60 + // TotalChanges returns the total number of changes in the current channel 61 + func (p *PijulRepo) TotalChanges() (int, error) { 62 + // pijul log doesn't have a --count option, so we need to count 63 + // We can use pijul log with a large limit or iterate 64 + args := []string{"--hash-only"} 65 + 66 + if p.channelName != "" { 67 + args = append(args, "--channel", p.channelName) 68 + } 69 + 70 + output, err := p.log(args...) 71 + if err != nil { 72 + if isNoChangesError(err) { 73 + return 0, nil 74 + } 75 + return 0, fmt.Errorf("pijul log: %w", err) 76 + } 77 + 78 + // Count lines (each line is a change hash) 79 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 80 + if len(lines) == 1 && lines[0] == "" { 81 + return 0, nil 82 + } 83 + 84 + return len(lines), nil 85 + } 86 + 87 + func isNoChangesError(err error) bool { 88 + if err == nil { 89 + return false 90 + } 91 + lower := strings.ToLower(err.Error()) 92 + return strings.Contains(lower, "no changes") || strings.Contains(lower, "no change") 93 + } 94 + 95 + // GetChange retrieves details for a specific change by hash 96 + func (p *PijulRepo) GetChange(hash string) (*Change, error) { 97 + // Use pijul change to get change details 98 + output, err := p.change(hash) 99 + if err != nil { 100 + return nil, fmt.Errorf("pijul change %s: %w", hash, err) 101 + } 102 + 103 + return parseChangeOutput(hash, output) 104 + } 105 + 106 + // parseLogOutput parses the output of pijul log 107 + // Expected format (default output): 108 + // 109 + // Hash: XXXXX 110 + // Author: Name <email> 111 + // Date: 2024-01-01 12:00:00 112 + // 113 + // Message line 1 114 + // Message line 2 115 + func parseLogOutput(output []byte) ([]Change, error) { 116 + var changes []Change 117 + scanner := bufio.NewScanner(bytes.NewReader(output)) 118 + 119 + var current *Change 120 + var messageLines []string 121 + inMessage := false 122 + 123 + for scanner.Scan() { 124 + line := scanner.Text() 125 + 126 + if strings.HasPrefix(line, "Hash: ") || strings.HasPrefix(line, "Change ") { 127 + // Save previous change if exists 128 + if current != nil { 129 + current.Message = strings.TrimSpace(strings.Join(messageLines, "\n")) 130 + changes = append(changes, *current) 131 + } 132 + 133 + hashLine := line 134 + if strings.HasPrefix(hashLine, "Change ") { 135 + hashLine = strings.Replace(hashLine, "Change ", "Hash: ", 1) 136 + } 137 + current = &Change{ 138 + Hash: strings.TrimPrefix(hashLine, "Hash: "), 139 + } 140 + messageLines = nil 141 + inMessage = false 142 + continue 143 + } 144 + 145 + if current == nil { 146 + continue 147 + } 148 + 149 + if strings.HasPrefix(line, "Author: ") { 150 + authorStr := strings.TrimPrefix(line, "Author: ") 151 + author := parseAuthor(authorStr) 152 + current.Authors = append(current.Authors, author) 153 + continue 154 + } 155 + 156 + if strings.HasPrefix(line, "Date: ") { 157 + dateStr := strings.TrimPrefix(line, "Date: ") 158 + if t, err := parseTimestamp(dateStr); err == nil { 159 + current.Timestamp = t 160 + } 161 + continue 162 + } 163 + 164 + // Empty line before message 165 + if line == "" && !inMessage { 166 + inMessage = true 167 + continue 168 + } 169 + 170 + if inMessage { 171 + messageLines = append(messageLines, strings.TrimPrefix(line, " ")) 172 + } 173 + } 174 + 175 + // Don't forget the last change 176 + if current != nil { 177 + current.Message = strings.TrimSpace(strings.Join(messageLines, "\n")) 178 + changes = append(changes, *current) 179 + } 180 + 181 + return changes, scanner.Err() 182 + } 183 + 184 + // parseChangeOutput parses the output of pijul change <hash> 185 + func parseChangeOutput(hash string, output []byte) (*Change, error) { 186 + change := &Change{Hash: hash} 187 + 188 + scanner := bufio.NewScanner(bytes.NewReader(output)) 189 + var messageLines []string 190 + inMessage := false 191 + inDeps := false 192 + 193 + for scanner.Scan() { 194 + line := scanner.Text() 195 + 196 + if strings.HasPrefix(line, "# Authors") { 197 + inDeps = false 198 + continue 199 + } 200 + 201 + if strings.HasPrefix(line, "# Dependencies") { 202 + inDeps = true 203 + continue 204 + } 205 + 206 + if strings.HasPrefix(line, "# Message") { 207 + inDeps = false 208 + inMessage = true 209 + continue 210 + } 211 + 212 + if strings.HasPrefix(line, "# ") { 213 + inDeps = false 214 + inMessage = false 215 + continue 216 + } 217 + 218 + if inDeps && strings.TrimSpace(line) != "" { 219 + change.Dependencies = append(change.Dependencies, strings.TrimSpace(line)) 220 + continue 221 + } 222 + 223 + if inMessage { 224 + messageLines = append(messageLines, line) 225 + continue 226 + } 227 + 228 + // Parse author line 229 + if strings.Contains(line, "<") && strings.Contains(line, ">") { 230 + author := parseAuthor(line) 231 + change.Authors = append(change.Authors, author) 232 + } 233 + } 234 + 235 + change.Message = strings.TrimSpace(strings.Join(messageLines, "\n")) 236 + 237 + return change, scanner.Err() 238 + } 239 + 240 + // parseAuthor parses an author string like "Name <email>" 241 + func parseAuthor(s string) Author { 242 + s = strings.TrimSpace(s) 243 + 244 + // Try to extract email from angle brackets 245 + if start := strings.Index(s, "<"); start != -1 { 246 + if end := strings.Index(s, ">"); end > start { 247 + return Author{ 248 + Name: strings.TrimSpace(s[:start]), 249 + Email: strings.TrimSpace(s[start+1 : end]), 250 + } 251 + } 252 + } 253 + 254 + return Author{Name: s} 255 + } 256 + 257 + // parseTimestamp parses various timestamp formats 258 + func parseTimestamp(s string) (time.Time, error) { 259 + s = strings.TrimSpace(s) 260 + 261 + // Try common formats 262 + formats := []string{ 263 + "2006-01-02 15:04:05 -0700", 264 + "2006-01-02 15:04:05", 265 + time.RFC3339, 266 + time.RFC3339Nano, 267 + } 268 + 269 + for _, format := range formats { 270 + if t, err := time.Parse(format, s); err == nil { 271 + return t, nil 272 + } 273 + } 274 + 275 + return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s) 276 + } 277 + 278 + // ChangeJSON represents the JSON output format for pijul log --json 279 + type ChangeJSON struct { 280 + Hash string `json:"hash"` 281 + Authors []string `json:"authors"` 282 + Message string `json:"message"` 283 + Timestamp string `json:"timestamp"` 284 + Dependencies []string `json:"dependencies,omitempty"` 285 + } 286 + 287 + // ChangesJSON returns changes using JSON output format (if available in pijul version) 288 + func (p *PijulRepo) ChangesJSON(offset, limit int) ([]Change, error) { 289 + args := []string{ 290 + "--offset", strconv.Itoa(offset), 291 + "-n", strconv.Itoa(limit), 292 + "--json", 293 + } 294 + 295 + if p.channelName != "" { 296 + args = append(args, "--channel", p.channelName) 297 + } 298 + 299 + output, err := p.log(args...) 300 + if err != nil { 301 + // Fall back to text parsing if JSON not supported 302 + return p.Changes(offset, limit) 303 + } 304 + 305 + var jsonChanges []ChangeJSON 306 + if err := json.Unmarshal(output, &jsonChanges); err != nil { 307 + // Fall back to text parsing if JSON parsing fails 308 + return p.Changes(offset, limit) 309 + } 310 + 311 + changes := make([]Change, len(jsonChanges)) 312 + for i, jc := range jsonChanges { 313 + changes[i] = Change{ 314 + Hash: jc.Hash, 315 + Message: jc.Message, 316 + Dependencies: jc.Dependencies, 317 + } 318 + 319 + for _, authorStr := range jc.Authors { 320 + changes[i].Authors = append(changes[i].Authors, parseAuthor(authorStr)) 321 + } 322 + 323 + if t, err := parseTimestamp(jc.Timestamp); err == nil { 324 + changes[i].Timestamp = t 325 + } 326 + } 327 + 328 + return changes, nil 329 + }
+160
knotserver/pijul/channel.go
··· 1 + package pijul 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "fmt" 7 + "strings" 8 + ) 9 + 10 + // Channel represents a Pijul channel (analogous to a Git branch) 11 + type Channel struct { 12 + Name string `json:"name"` 13 + IsCurrent bool `json:"is_current,omitempty"` 14 + } 15 + 16 + // Channels returns the list of channels in the repository 17 + func (p *PijulRepo) Channels() ([]Channel, error) { 18 + output, err := p.channelCmd() 19 + if err != nil { 20 + return nil, fmt.Errorf("listing channels: %w", err) 21 + } 22 + 23 + return parseChannelOutput(output) 24 + } 25 + 26 + // parseChannelOutput parses the output of pijul channel 27 + // Expected format: 28 + // 29 + // * main 30 + // feature-branch 31 + // another-branch 32 + func parseChannelOutput(output []byte) ([]Channel, error) { 33 + var channels []Channel 34 + scanner := bufio.NewScanner(bytes.NewReader(output)) 35 + 36 + for scanner.Scan() { 37 + line := scanner.Text() 38 + if strings.TrimSpace(line) == "" { 39 + continue 40 + } 41 + 42 + isCurrent := strings.HasPrefix(line, "* ") 43 + name := strings.TrimSpace(strings.TrimPrefix(line, "* ")) 44 + name = strings.TrimSpace(strings.TrimPrefix(name, " ")) 45 + 46 + if name != "" { 47 + channels = append(channels, Channel{ 48 + Name: name, 49 + IsCurrent: isCurrent, 50 + }) 51 + } 52 + } 53 + 54 + return channels, scanner.Err() 55 + } 56 + 57 + // ChannelOptions options for channel listing 58 + type ChannelOptions struct { 59 + Limit int 60 + Offset int 61 + } 62 + 63 + // ChannelsWithOptions returns channels with pagination 64 + func (p *PijulRepo) ChannelsWithOptions(opts *ChannelOptions) ([]Channel, error) { 65 + channels, err := p.Channels() 66 + if err != nil { 67 + return nil, err 68 + } 69 + 70 + if opts == nil { 71 + return channels, nil 72 + } 73 + 74 + // Apply offset 75 + if opts.Offset > 0 { 76 + if opts.Offset >= len(channels) { 77 + return []Channel{}, nil 78 + } 79 + channels = channels[opts.Offset:] 80 + } 81 + 82 + // Apply limit 83 + if opts.Limit > 0 && opts.Limit < len(channels) { 84 + channels = channels[:opts.Limit] 85 + } 86 + 87 + return channels, nil 88 + } 89 + 90 + // CreateChannel creates a new channel 91 + func (p *PijulRepo) CreateChannel(name string) error { 92 + _, err := p.channelCmd("new", name) 93 + return err 94 + } 95 + 96 + // DeleteChannel deletes a channel 97 + func (p *PijulRepo) DeleteChannel(name string) error { 98 + _, err := p.channelCmd("delete", name) 99 + return err 100 + } 101 + 102 + // RenameChannel renames a channel 103 + func (p *PijulRepo) RenameChannel(oldName, newName string) error { 104 + _, err := p.channelCmd("rename", oldName, newName) 105 + return err 106 + } 107 + 108 + // SwitchChannel switches to a different channel 109 + func (p *PijulRepo) SwitchChannel(name string) error { 110 + _, err := p.channelCmd("switch", name) 111 + if err != nil { 112 + return fmt.Errorf("switching to channel %s: %w", name, err) 113 + } 114 + p.channelName = name 115 + return nil 116 + } 117 + 118 + // CurrentChannelName returns the name of the current channel 119 + func (p *PijulRepo) CurrentChannelName() (string, error) { 120 + channels, err := p.Channels() 121 + if err != nil { 122 + return "", err 123 + } 124 + 125 + for _, ch := range channels { 126 + if ch.IsCurrent { 127 + return ch.Name, nil 128 + } 129 + } 130 + 131 + // If no current channel marked, default to "main" 132 + return "main", nil 133 + } 134 + 135 + // ForkChannel creates a new channel from an existing one 136 + // This is equivalent to Git's "git checkout -b newbranch oldbranch" 137 + func (p *PijulRepo) ForkChannel(newName, fromChannel string) error { 138 + args := []string{"fork", newName} 139 + if fromChannel != "" { 140 + args = append(args, "--channel", fromChannel) 141 + } 142 + _, err := p.channelCmd(args...) 143 + return err 144 + } 145 + 146 + // ChannelExists checks if a channel exists 147 + func (p *PijulRepo) ChannelExists(name string) (bool, error) { 148 + channels, err := p.Channels() 149 + if err != nil { 150 + return false, err 151 + } 152 + 153 + for _, ch := range channels { 154 + if ch.Name == name { 155 + return true, nil 156 + } 157 + } 158 + 159 + return false, nil 160 + }
+81
knotserver/pijul/cmd.go
··· 1 + package pijul 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "os/exec" 7 + ) 8 + 9 + const ( 10 + fieldSeparator = "\x1f" // ASCII Unit Separator 11 + recordSeparator = "\x1e" // ASCII Record Separator 12 + ) 13 + 14 + // runPijulCmd executes a pijul command in the repository directory 15 + func (p *PijulRepo) runPijulCmd(command string, extraArgs ...string) ([]byte, error) { 16 + var args []string 17 + args = append(args, command) 18 + args = append(args, extraArgs...) 19 + 20 + cmd := exec.Command("pijul", args...) 21 + cmd.Dir = p.path 22 + 23 + var stdout, stderr bytes.Buffer 24 + cmd.Stdout = &stdout 25 + cmd.Stderr = &stderr 26 + 27 + err := cmd.Run() 28 + if err != nil { 29 + if exitErr, ok := err.(*exec.ExitError); ok { 30 + return nil, fmt.Errorf("%w, stderr: %s", exitErr, stderr.String()) 31 + } 32 + return nil, fmt.Errorf("pijul %s: %w", command, err) 33 + } 34 + 35 + return stdout.Bytes(), nil 36 + } 37 + 38 + // runPijulCmdWithStdin executes a pijul command with stdin input 39 + func (p *PijulRepo) runPijulCmdWithStdin(stdin []byte, command string, extraArgs ...string) ([]byte, error) { 40 + var args []string 41 + args = append(args, command) 42 + args = append(args, extraArgs...) 43 + 44 + cmd := exec.Command("pijul", args...) 45 + cmd.Dir = p.path 46 + cmd.Stdin = bytes.NewReader(stdin) 47 + 48 + var stdout, stderr bytes.Buffer 49 + cmd.Stdout = &stdout 50 + cmd.Stderr = &stderr 51 + 52 + err := cmd.Run() 53 + if err != nil { 54 + if exitErr, ok := err.(*exec.ExitError); ok { 55 + return nil, fmt.Errorf("%w, stderr: %s", exitErr, stderr.String()) 56 + } 57 + return nil, fmt.Errorf("pijul %s: %w", command, err) 58 + } 59 + 60 + return stdout.Bytes(), nil 61 + } 62 + 63 + // log runs pijul log with arguments 64 + func (p *PijulRepo) log(extraArgs ...string) ([]byte, error) { 65 + return p.runPijulCmd("log", extraArgs...) 66 + } 67 + 68 + // channelCmd runs pijul channel with arguments 69 + func (p *PijulRepo) channelCmd(extraArgs ...string) ([]byte, error) { 70 + return p.runPijulCmd("channel", extraArgs...) 71 + } 72 + 73 + // diff runs pijul diff with arguments 74 + func (p *PijulRepo) diff(extraArgs ...string) ([]byte, error) { 75 + return p.runPijulCmd("diff", extraArgs...) 76 + } 77 + 78 + // change runs pijul change (show change details) with arguments 79 + func (p *PijulRepo) change(extraArgs ...string) ([]byte, error) { 80 + return p.runPijulCmd("change", extraArgs...) 81 + }
+73
knotserver/pijul/diff.go
··· 1 + package pijul 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + // Diff represents the difference between two states 8 + type Diff struct { 9 + Raw string `json:"raw"` 10 + Files []FileDiff `json:"files,omitempty"` 11 + Stats *DiffStats `json:"stats,omitempty"` 12 + } 13 + 14 + // FileDiff represents changes to a single file 15 + type FileDiff struct { 16 + Path string `json:"path"` 17 + OldPath string `json:"old_path,omitempty"` // for renames 18 + Status string `json:"status"` // added, modified, deleted, renamed 19 + Additions int `json:"additions"` 20 + Deletions int `json:"deletions"` 21 + Patch string `json:"patch,omitempty"` 22 + } 23 + 24 + // DiffStats contains summary statistics for a diff 25 + type DiffStats struct { 26 + FilesChanged int `json:"files_changed"` 27 + Additions int `json:"additions"` 28 + Deletions int `json:"deletions"` 29 + } 30 + 31 + // Diff returns the diff of uncommitted changes 32 + func (p *PijulRepo) Diff() (*Diff, error) { 33 + output, err := p.diff() 34 + if err != nil { 35 + return nil, fmt.Errorf("pijul diff: %w", err) 36 + } 37 + 38 + return &Diff{ 39 + Raw: string(output), 40 + }, nil 41 + } 42 + 43 + // DiffChange returns the diff for a specific change 44 + func (p *PijulRepo) DiffChange(hash string) (*Diff, error) { 45 + output, err := p.change(hash) 46 + if err != nil { 47 + return nil, fmt.Errorf("pijul change %s: %w", hash, err) 48 + } 49 + 50 + return &Diff{ 51 + Raw: string(output), 52 + }, nil 53 + } 54 + 55 + // DiffBetween returns the diff between two channels or states 56 + func (p *PijulRepo) DiffBetween(from, to string) (*Diff, error) { 57 + args := []string{} 58 + if from != "" { 59 + args = append(args, "--channel", from) 60 + } 61 + if to != "" { 62 + args = append(args, "--channel", to) 63 + } 64 + 65 + output, err := p.diff(args...) 66 + if err != nil { 67 + return nil, fmt.Errorf("pijul diff: %w", err) 68 + } 69 + 70 + return &Diff{ 71 + Raw: string(output), 72 + }, nil 73 + }
+306
knotserver/pijul/pijul.go
··· 1 + package pijul 2 + 3 + import ( 4 + "archive/tar" 5 + "bytes" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "io/fs" 10 + "os" 11 + "os/exec" 12 + "path/filepath" 13 + 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + ) 16 + 17 + var ( 18 + ErrBinaryFile = errors.New("binary file") 19 + ErrNotBinaryFile = errors.New("not binary file") 20 + ErrNoPijulRepo = errors.New("not a pijul repository") 21 + ErrChannelNotFound = errors.New("channel not found") 22 + ErrChangeNotFound = errors.New("change not found") 23 + ErrPathNotFound = errors.New("path not found") 24 + ) 25 + 26 + // PijulRepo represents a Pijul repository 27 + type PijulRepo struct { 28 + path string 29 + channelName string // current channel (empty means default) 30 + } 31 + 32 + // Open opens a Pijul repository at the given path with optional channel 33 + func Open(path string, channel string) (*PijulRepo, error) { 34 + // Verify it's a pijul repository 35 + pijulDir := filepath.Join(path, ".pijul") 36 + if _, err := os.Stat(pijulDir); os.IsNotExist(err) { 37 + return nil, fmt.Errorf("%w: %s", ErrNoPijulRepo, path) 38 + } 39 + 40 + p := &PijulRepo{ 41 + path: path, 42 + channelName: channel, 43 + } 44 + 45 + // Verify channel exists if specified 46 + if channel != "" { 47 + channels, err := p.Channels() 48 + if err != nil { 49 + return nil, fmt.Errorf("listing channels: %w", err) 50 + } 51 + found := false 52 + for _, ch := range channels { 53 + if ch.Name == channel { 54 + found = true 55 + break 56 + } 57 + } 58 + if !found { 59 + return nil, fmt.Errorf("%w: %s", ErrChannelNotFound, channel) 60 + } 61 + } 62 + 63 + return p, nil 64 + } 65 + 66 + // PlainOpen opens a Pijul repository without setting a specific channel 67 + func PlainOpen(path string) (*PijulRepo, error) { 68 + // Verify it's a pijul repository 69 + pijulDir := filepath.Join(path, ".pijul") 70 + if _, err := os.Stat(pijulDir); os.IsNotExist(err) { 71 + return nil, fmt.Errorf("%w: %s", ErrNoPijulRepo, path) 72 + } 73 + 74 + return &PijulRepo{path: path}, nil 75 + } 76 + 77 + // Path returns the repository path 78 + func (p *PijulRepo) Path() string { 79 + return p.path 80 + } 81 + 82 + // CurrentChannel returns the current channel (or empty for default) 83 + func (p *PijulRepo) CurrentChannel() string { 84 + return p.channelName 85 + } 86 + 87 + // FindDefaultChannel returns the default channel name 88 + func (p *PijulRepo) FindDefaultChannel() (string, error) { 89 + channels, err := p.Channels() 90 + if err != nil { 91 + return "", err 92 + } 93 + 94 + // Look for 'main' first, then fall back to first channel 95 + for _, ch := range channels { 96 + if ch.Name == "main" { 97 + return "main", nil 98 + } 99 + } 100 + 101 + if len(channels) > 0 { 102 + return channels[0].Name, nil 103 + } 104 + 105 + return "main", nil // default 106 + } 107 + 108 + // SetDefaultChannel changes which channel is considered default 109 + // In Pijul, this would typically be done by renaming channels 110 + func (p *PijulRepo) SetDefaultChannel(channel string) error { 111 + // Pijul doesn't have a built-in default branch concept like git HEAD 112 + // This is typically managed at application level 113 + // For now, just verify the channel exists 114 + channels, err := p.Channels() 115 + if err != nil { 116 + return err 117 + } 118 + 119 + for _, ch := range channels { 120 + if ch.Name == channel { 121 + return nil 122 + } 123 + } 124 + 125 + return fmt.Errorf("%w: %s", ErrChannelNotFound, channel) 126 + } 127 + 128 + // FileContent reads a file from the working copy at a specific path 129 + // Note: Pijul doesn't have the concept of reading files at a specific revision 130 + // like git. We read from the working directory or need to use pijul credit. 131 + func (p *PijulRepo) FileContent(filePath string) ([]byte, error) { 132 + fullPath, err := securejoin.SecureJoin(p.path, filePath) 133 + if err != nil { 134 + return nil, fmt.Errorf("invalid path: %w", err) 135 + } 136 + 137 + content, err := os.ReadFile(fullPath) 138 + if err != nil { 139 + if os.IsNotExist(err) { 140 + return nil, fmt.Errorf("%w: %s", ErrPathNotFound, filePath) 141 + } 142 + return nil, err 143 + } 144 + 145 + return content, nil 146 + } 147 + 148 + // FileContentN reads up to cap bytes of a file 149 + func (p *PijulRepo) FileContentN(filePath string, cap int64) ([]byte, error) { 150 + fullPath, err := securejoin.SecureJoin(p.path, filePath) 151 + if err != nil { 152 + return nil, fmt.Errorf("invalid path: %w", err) 153 + } 154 + 155 + f, err := os.Open(fullPath) 156 + if err != nil { 157 + if os.IsNotExist(err) { 158 + return nil, fmt.Errorf("%w: %s", ErrPathNotFound, filePath) 159 + } 160 + return nil, err 161 + } 162 + defer f.Close() 163 + 164 + // Check if binary 165 + buf := make([]byte, 512) 166 + n, err := f.Read(buf) 167 + if err != nil && err != io.EOF { 168 + return nil, err 169 + } 170 + if isBinary(buf[:n]) { 171 + return nil, ErrBinaryFile 172 + } 173 + 174 + // Reset and read up to cap 175 + if _, err := f.Seek(0, 0); err != nil { 176 + return nil, err 177 + } 178 + 179 + content := make([]byte, cap) 180 + n, err = f.Read(content) 181 + if err != nil && err != io.EOF { 182 + return nil, err 183 + } 184 + 185 + return content[:n], nil 186 + } 187 + 188 + // RawContent reads raw file content without binary check 189 + func (p *PijulRepo) RawContent(filePath string) ([]byte, error) { 190 + fullPath, err := securejoin.SecureJoin(p.path, filePath) 191 + if err != nil { 192 + return nil, fmt.Errorf("invalid path: %w", err) 193 + } 194 + return os.ReadFile(fullPath) 195 + } 196 + 197 + // isBinary checks if data appears to be binary 198 + func isBinary(data []byte) bool { 199 + for _, b := range data { 200 + if b == 0 { 201 + return true 202 + } 203 + } 204 + return false 205 + } 206 + 207 + // WriteTar writes the repository contents to a tar archive 208 + func (p *PijulRepo) WriteTar(w io.Writer, prefix string) error { 209 + tw := tar.NewWriter(w) 210 + defer tw.Close() 211 + 212 + return filepath.Walk(p.path, func(path string, info fs.FileInfo, err error) error { 213 + if err != nil { 214 + return err 215 + } 216 + 217 + // Skip .pijul directory 218 + if info.IsDir() && filepath.Base(path) == ".pijul" { 219 + return filepath.SkipDir 220 + } 221 + 222 + relPath, err := filepath.Rel(p.path, path) 223 + if err != nil { 224 + return err 225 + } 226 + 227 + if relPath == "." { 228 + return nil 229 + } 230 + 231 + header, err := tar.FileInfoHeader(info, "") 232 + if err != nil { 233 + return err 234 + } 235 + 236 + header.Name = filepath.Join(prefix, relPath) 237 + 238 + if err := tw.WriteHeader(header); err != nil { 239 + return err 240 + } 241 + 242 + if !info.IsDir() { 243 + if err := copyFileToTar(tw, path); err != nil { 244 + return err 245 + } 246 + } 247 + 248 + return nil 249 + }) 250 + } 251 + 252 + // copyFileToTar copies a single file into a tar writer, closing the file before returning. 253 + func copyFileToTar(tw *tar.Writer, path string) error { 254 + f, err := os.Open(path) 255 + if err != nil { 256 + return err 257 + } 258 + defer f.Close() 259 + _, err = io.Copy(tw, f) 260 + return err 261 + } 262 + 263 + // InitRepo initializes a new Pijul repository 264 + func InitRepo(path string, bare bool) error { 265 + if err := os.MkdirAll(path, 0755); err != nil { 266 + return fmt.Errorf("creating directory: %w", err) 267 + } 268 + 269 + args := []string{"init"} 270 + if bare { 271 + // Pijul doesn't have explicit bare repos like git 272 + // A "bare" repo is typically just a repo without a working directory 273 + args = append(args, "--kind=bare") 274 + } 275 + 276 + cmd := exec.Command("pijul", args...) 277 + cmd.Dir = path 278 + 279 + var stderr bytes.Buffer 280 + cmd.Stderr = &stderr 281 + 282 + if err := cmd.Run(); err != nil { 283 + return fmt.Errorf("pijul init: %w, stderr: %s", err, stderr.String()) 284 + } 285 + 286 + return nil 287 + } 288 + 289 + // Clone clones a Pijul repository 290 + func Clone(url, destPath string, channel string) error { 291 + args := []string{"clone", url, destPath} 292 + if channel != "" { 293 + args = append(args, "--channel", channel) 294 + } 295 + 296 + cmd := exec.Command("pijul", args...) 297 + 298 + var stderr bytes.Buffer 299 + cmd.Stderr = &stderr 300 + 301 + if err := cmd.Run(); err != nil { 302 + return fmt.Errorf("pijul clone: %w, stderr: %s", err, stderr.String()) 303 + } 304 + 305 + return nil 306 + }
+117
knotserver/pijul/repo.go
··· 1 + package pijul 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + ) 9 + 10 + // InitBare initializes a bare Pijul repository 11 + func InitBare(repoPath string) error { 12 + if err := os.MkdirAll(repoPath, 0755); err != nil { 13 + return fmt.Errorf("creating directory: %w", err) 14 + } 15 + 16 + cmd := exec.Command("pijul", "init") 17 + cmd.Dir = repoPath 18 + 19 + var stderr bytes.Buffer 20 + cmd.Stderr = &stderr 21 + 22 + if err := cmd.Run(); err != nil { 23 + return fmt.Errorf("pijul init: %w, stderr: %s", err, stderr.String()) 24 + } 25 + 26 + // remove .ignore auto-generated by pijul init 27 + os.Remove(fmt.Sprintf("%s/.ignore", repoPath)) 28 + 29 + return nil 30 + } 31 + 32 + // Fork clones a repository to a new location 33 + func Fork(srcPath, destPath string) error { 34 + // For local fork, we can use pijul clone 35 + cmd := exec.Command("pijul", "clone", srcPath, destPath) 36 + 37 + var stderr bytes.Buffer 38 + cmd.Stderr = &stderr 39 + 40 + if err := cmd.Run(); err != nil { 41 + return fmt.Errorf("pijul clone: %w, stderr: %s", err, stderr.String()) 42 + } 43 + 44 + return nil 45 + } 46 + 47 + // Push pushes changes to a remote 48 + func (p *PijulRepo) Push(remote string, channel string) error { 49 + args := []string{remote} 50 + if channel != "" { 51 + args = append(args, "--channel", channel) 52 + } 53 + 54 + _, err := p.runPijulCmd("push", args...) 55 + return err 56 + } 57 + 58 + // Pull pulls changes from a remote 59 + func (p *PijulRepo) Pull(remote string, channel string) error { 60 + args := []string{remote} 61 + if channel != "" { 62 + args = append(args, "--channel", channel) 63 + } 64 + 65 + _, err := p.runPijulCmd("pull", args...) 66 + return err 67 + } 68 + 69 + // Apply applies a change to the repository 70 + func (p *PijulRepo) Apply(changeHash string) error { 71 + _, err := p.runPijulCmd("apply", changeHash) 72 + return err 73 + } 74 + 75 + // Unrecord removes a change from the channel (like git reset) 76 + func (p *PijulRepo) Unrecord(changeHash string) error { 77 + args := []string{changeHash} 78 + if p.channelName != "" { 79 + args = append(args, "--channel", p.channelName) 80 + } 81 + _, err := p.runPijulCmd("unrecord", args...) 82 + return err 83 + } 84 + 85 + // Record creates a new change (like git commit) 86 + func (p *PijulRepo) Record(message string, authors []Author) error { 87 + args := []string{"-m", message} 88 + 89 + for _, author := range authors { 90 + authorStr := author.Name 91 + if author.Email != "" { 92 + authorStr = fmt.Sprintf("%s <%s>", author.Name, author.Email) 93 + } 94 + args = append(args, "--author", authorStr) 95 + } 96 + 97 + if p.channelName != "" { 98 + args = append(args, "--channel", p.channelName) 99 + } 100 + 101 + _, err := p.runPijulCmd("record", args...) 102 + return err 103 + } 104 + 105 + // Add adds files to be tracked 106 + func (p *PijulRepo) Add(paths ...string) error { 107 + args := append([]string{}, paths...) 108 + _, err := p.runPijulCmd("add", args...) 109 + return err 110 + } 111 + 112 + // Remove removes files from tracking 113 + func (p *PijulRepo) Remove(paths ...string) error { 114 + args := append([]string{}, paths...) 115 + _, err := p.runPijulCmd("remove", args...) 116 + return err 117 + }
+197
knotserver/pijul/tree.go
··· 1 + package pijul 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io/fs" 7 + "os" 8 + "path" 9 + "path/filepath" 10 + "strings" 11 + 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.org/core/types" 14 + ) 15 + 16 + // TreeEntry represents a file or directory in the repository tree 17 + type TreeEntry struct { 18 + Name string `json:"name"` 19 + Mode fs.FileMode `json:"mode"` 20 + Size int64 `json:"size"` 21 + IsDir bool `json:"is_dir"` 22 + } 23 + 24 + // FileTree returns the file tree at the given path 25 + // For Pijul, we read directly from the working directory 26 + func (p *PijulRepo) FileTree(ctx context.Context, treePath string) ([]types.NiceTree, error) { 27 + fullPath, err := securejoin.SecureJoin(p.path, treePath) 28 + if err != nil { 29 + return nil, fmt.Errorf("invalid path: %w", err) 30 + } 31 + 32 + info, err := os.Stat(fullPath) 33 + if err != nil { 34 + if os.IsNotExist(err) { 35 + return nil, ErrPathNotFound 36 + } 37 + return nil, err 38 + } 39 + 40 + // If it's a file, return empty (no tree for files) 41 + if !info.IsDir() { 42 + return []types.NiceTree{}, nil 43 + } 44 + 45 + entries, err := os.ReadDir(fullPath) 46 + if err != nil { 47 + return nil, err 48 + } 49 + 50 + trees := make([]types.NiceTree, 0, len(entries)) 51 + 52 + for _, entry := range entries { 53 + // Skip .pijul directory 54 + if entry.Name() == ".pijul" { 55 + continue 56 + } 57 + 58 + info, err := entry.Info() 59 + if err != nil { 60 + continue 61 + } 62 + 63 + trees = append(trees, types.NiceTree{ 64 + Name: entry.Name(), 65 + Mode: fileModeToString(info.Mode()), 66 + Size: info.Size(), 67 + // LastCommit would require additional work to implement 68 + // For now, we leave it nil 69 + }) 70 + } 71 + 72 + return trees, nil 73 + } 74 + 75 + // fileModeToString converts fs.FileMode to octal string representation 76 + func fileModeToString(mode fs.FileMode) string { 77 + // Convert to git-style mode representation 78 + if mode.IsDir() { 79 + return "040000" 80 + } 81 + if mode&fs.ModeSymlink != 0 { 82 + return "120000" 83 + } 84 + if mode&0111 != 0 { 85 + return "100755" 86 + } 87 + return "100644" 88 + } 89 + 90 + // Walk callback type 91 + type WalkCallback func(path string, info fs.FileInfo, isDir bool) error 92 + 93 + // Walk traverses the file tree 94 + func (p *PijulRepo) Walk(ctx context.Context, root string, cb WalkCallback) error { 95 + startPath, err := securejoin.SecureJoin(p.path, root) 96 + if err != nil { 97 + return fmt.Errorf("invalid path: %w", err) 98 + } 99 + 100 + return filepath.WalkDir(startPath, func(walkPath string, d fs.DirEntry, err error) error { 101 + if err != nil { 102 + return err 103 + } 104 + 105 + // Check context 106 + select { 107 + case <-ctx.Done(): 108 + return ctx.Err() 109 + default: 110 + } 111 + 112 + // Skip .pijul directory 113 + if d.IsDir() && filepath.Base(walkPath) == ".pijul" { 114 + return filepath.SkipDir 115 + } 116 + 117 + // Get relative path 118 + relPath, err := filepath.Rel(p.path, walkPath) 119 + if err != nil { 120 + return err 121 + } 122 + 123 + if relPath == "." { 124 + return nil 125 + } 126 + 127 + info, err := d.Info() 128 + if err != nil { 129 + return err 130 + } 131 + 132 + return cb(relPath, info, d.IsDir()) 133 + }) 134 + } 135 + 136 + // ListFiles returns all tracked files in the repository 137 + func (p *PijulRepo) ListFiles() ([]string, error) { 138 + output, err := p.runPijulCmd("ls") 139 + if err != nil { 140 + return nil, err 141 + } 142 + 143 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 144 + if len(lines) == 1 && lines[0] == "" { 145 + return []string{}, nil 146 + } 147 + 148 + return lines, nil 149 + } 150 + 151 + // IsTracked checks if a file is tracked by Pijul 152 + func (p *PijulRepo) IsTracked(filePath string) (bool, error) { 153 + files, err := p.ListFiles() 154 + if err != nil { 155 + return false, err 156 + } 157 + 158 + for _, f := range files { 159 + if f == filePath { 160 + return true, nil 161 + } 162 + } 163 + 164 + return false, nil 165 + } 166 + 167 + // FileExists checks if a file exists in the working directory 168 + func (p *PijulRepo) FileExists(filePath string) bool { 169 + fullPath, err := securejoin.SecureJoin(p.path, filePath) 170 + if err != nil { 171 + return false 172 + } 173 + _, err = os.Stat(fullPath) 174 + return err == nil 175 + } 176 + 177 + // IsDir checks if a path is a directory 178 + func (p *PijulRepo) IsDir(treePath string) (bool, error) { 179 + fullPath, err := securejoin.SecureJoin(p.path, treePath) 180 + if err != nil { 181 + return false, fmt.Errorf("invalid path: %w", err) 182 + } 183 + info, err := os.Stat(fullPath) 184 + if err != nil { 185 + return false, err 186 + } 187 + return info.IsDir(), nil 188 + } 189 + 190 + // MakeNiceTree creates a NiceTree from file info 191 + func MakeNiceTree(name string, info fs.FileInfo) types.NiceTree { 192 + return types.NiceTree{ 193 + Name: path.Base(name), 194 + Mode: fileModeToString(info.Mode()), 195 + Size: info.Size(), 196 + } 197 + }
+75
knotserver/vcs/backend.go
··· 1 + package vcs 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "io" 7 + 8 + "tangled.org/core/knotserver/git" 9 + "tangled.org/core/knotserver/pijul" 10 + ) 11 + 12 + // ErrBinaryFile is returned by FileContentN when the file appears to be binary. 13 + // It matches both git.ErrBinaryFile and pijul.ErrBinaryFile. 14 + var ErrBinaryFile = errors.New("binary file") 15 + 16 + // IsBinaryFileError returns true if the error indicates a binary file, 17 + // matching against both the vcs, git, and pijul error types. 18 + func IsBinaryFileError(err error) bool { 19 + return errors.Is(err, ErrBinaryFile) || 20 + errors.Is(err, git.ErrBinaryFile) || 21 + errors.Is(err, pijul.ErrBinaryFile) 22 + } 23 + 24 + // ReadRepo provides read-only access to a VCS repository, abstracting 25 + // over git and pijul. 26 + type ReadRepo interface { 27 + // VCSType returns "git" or "pijul". 28 + VCSType() string 29 + 30 + // Path returns the on-disk path of the repository. 31 + Path() string 32 + 33 + // History returns history entries (commits/changes) with pagination. 34 + History(offset, limit int) ([]HistoryEntry, error) 35 + 36 + // TotalHistoryEntries returns the total count of commits/changes. 37 + TotalHistoryEntries() (int, error) 38 + 39 + // HistoryEntry returns a single commit/change by hash. 40 + HistoryEntry(hash string) (*HistoryEntry, error) 41 + 42 + // Branches returns branches/channels with optional pagination. 43 + Branches(opts *PaginationOpts) ([]BranchInfo, error) 44 + 45 + // DefaultBranch returns the name of the default branch/channel. 46 + DefaultBranch() (string, error) 47 + 48 + // FileTree returns the directory listing at the given path. 49 + FileTree(ctx context.Context, path string) ([]TreeEntry, error) 50 + 51 + // FileContentN reads up to cap bytes of a file, returning ErrBinaryFile 52 + // if the file appears to be binary. 53 + FileContentN(path string, cap int64) ([]byte, error) 54 + 55 + // RawContent reads the full raw content of a file. 56 + RawContent(path string) ([]byte, error) 57 + 58 + // WriteTar writes a tar archive of the repository to w, prefixing 59 + // all paths with prefix. 60 + WriteTar(w io.Writer, prefix string) error 61 + 62 + // Tags returns tags with optional pagination. Pijul repos return nil. 63 + Tags(opts *PaginationOpts) ([]TagInfo, error) 64 + } 65 + 66 + // MutableRepo extends ReadRepo with write operations. 67 + type MutableRepo interface { 68 + ReadRepo 69 + 70 + // SetDefaultBranch sets the default branch/channel. 71 + SetDefaultBranch(name string) error 72 + 73 + // DeleteBranch deletes a branch/channel. 74 + DeleteBranch(name string) error 75 + }
+40
knotserver/vcs/detect.go
··· 1 + package vcs 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + ) 8 + 9 + // IsPijulRepo checks if the given path contains a Pijul repository. 10 + func IsPijulRepo(path string) bool { 11 + info, err := os.Stat(filepath.Join(path, ".pijul")) 12 + if err != nil { 13 + return false 14 + } 15 + return info.IsDir() 16 + } 17 + 18 + // IsGitRepo checks if the given path contains a Git repository. 19 + func IsGitRepo(path string) bool { 20 + info, err := os.Stat(filepath.Join(path, ".git")) 21 + if err != nil { 22 + // Also check for bare git repos (HEAD file at top level). 23 + if _, err := os.Stat(filepath.Join(path, "HEAD")); err == nil { 24 + return true 25 + } 26 + return false 27 + } 28 + return info.IsDir() 29 + } 30 + 31 + // DetectVCS detects whether a path contains a Git or Pijul repository. 32 + func DetectVCS(path string) (string, error) { 33 + if IsPijulRepo(path) { 34 + return "pijul", nil 35 + } 36 + if IsGitRepo(path) { 37 + return "git", nil 38 + } 39 + return "", fmt.Errorf("no VCS repository found at %s", path) 40 + }
+226
knotserver/vcs/gitadapter.go
··· 1 + package vcs 2 + 3 + import ( 4 + "context" 5 + "io" 6 + 7 + "tangled.org/core/knotserver/git" 8 + ) 9 + 10 + // gitReadAdapter wraps a git.GitRepo to implement ReadRepo. 11 + type gitReadAdapter struct { 12 + g *git.GitRepo 13 + } 14 + 15 + func newGitReadAdapter(g *git.GitRepo) *gitReadAdapter { 16 + return &gitReadAdapter{g: g} 17 + } 18 + 19 + func (a *gitReadAdapter) VCSType() string { return "git" } 20 + func (a *gitReadAdapter) Path() string { return a.g.Path() } 21 + 22 + func (a *gitReadAdapter) History(offset, limit int) ([]HistoryEntry, error) { 23 + commits, err := a.g.Commits(offset, limit) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + entries := make([]HistoryEntry, 0, len(commits)) 29 + for _, c := range commits { 30 + parents := make([]string, 0, len(c.ParentHashes)) 31 + for _, p := range c.ParentHashes { 32 + parents = append(parents, p.String()) 33 + } 34 + entries = append(entries, HistoryEntry{ 35 + Hash: c.Hash.String(), 36 + Author: Author{ 37 + Name: c.Author.Name, 38 + Email: c.Author.Email, 39 + When: c.Author.When, 40 + }, 41 + Committer: Author{ 42 + Name: c.Committer.Name, 43 + Email: c.Committer.Email, 44 + When: c.Committer.When, 45 + }, 46 + Message: c.Message, 47 + Timestamp: c.Committer.When, 48 + Parents: parents, 49 + }) 50 + } 51 + return entries, nil 52 + } 53 + 54 + func (a *gitReadAdapter) TotalHistoryEntries() (int, error) { 55 + return a.g.TotalCommits() 56 + } 57 + 58 + func (a *gitReadAdapter) HistoryEntry(hash string) (*HistoryEntry, error) { 59 + c, err := a.g.Commit(gitHash(hash)) 60 + if err != nil { 61 + return nil, err 62 + } 63 + 64 + parents := make([]string, 0, len(c.ParentHashes)) 65 + for _, p := range c.ParentHashes { 66 + parents = append(parents, p.String()) 67 + } 68 + 69 + return &HistoryEntry{ 70 + Hash: c.Hash.String(), 71 + Author: Author{ 72 + Name: c.Author.Name, 73 + Email: c.Author.Email, 74 + When: c.Author.When, 75 + }, 76 + Committer: Author{ 77 + Name: c.Committer.Name, 78 + Email: c.Committer.Email, 79 + When: c.Committer.When, 80 + }, 81 + Message: c.Message, 82 + Timestamp: c.Committer.When, 83 + Parents: parents, 84 + }, nil 85 + } 86 + 87 + func (a *gitReadAdapter) Branches(opts *PaginationOpts) ([]BranchInfo, error) { 88 + var gitOpts *git.BranchesOptions 89 + if opts != nil { 90 + gitOpts = &git.BranchesOptions{ 91 + Limit: opts.Limit, 92 + Offset: opts.Offset, 93 + } 94 + } 95 + 96 + branches, err := a.g.Branches(gitOpts) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + infos := make([]BranchInfo, 0, len(branches)) 102 + for _, b := range branches { 103 + info := BranchInfo{ 104 + Name: b.Name, 105 + Hash: b.Hash, 106 + IsDefault: b.IsDefault, 107 + } 108 + if b.Commit != nil { 109 + info.LatestEntry = &HistoryEntry{ 110 + Hash: b.Commit.Hash.String(), 111 + Author: Author{ 112 + Name: b.Commit.Author.Name, 113 + Email: b.Commit.Author.Email, 114 + When: b.Commit.Author.When, 115 + }, 116 + Committer: Author{ 117 + Name: b.Commit.Committer.Name, 118 + Email: b.Commit.Committer.Email, 119 + When: b.Commit.Committer.When, 120 + }, 121 + Message: b.Commit.Message, 122 + Timestamp: b.Commit.Committer.When, 123 + } 124 + } 125 + infos = append(infos, info) 126 + } 127 + return infos, nil 128 + } 129 + 130 + func (a *gitReadAdapter) DefaultBranch() (string, error) { 131 + return a.g.FindMainBranch() 132 + } 133 + 134 + func (a *gitReadAdapter) FileTree(ctx context.Context, path string) ([]TreeEntry, error) { 135 + files, err := a.g.FileTree(ctx, path) 136 + if err != nil { 137 + return nil, err 138 + } 139 + 140 + entries := make([]TreeEntry, 0, len(files)) 141 + for _, f := range files { 142 + entry := TreeEntry{ 143 + Name: f.Name, 144 + Mode: f.Mode, 145 + Size: f.Size, 146 + } 147 + if f.LastCommit != nil { 148 + entry.LastCommit = &LastCommitInfo{ 149 + Hash: f.LastCommit.Hash.String(), 150 + Message: f.LastCommit.Message, 151 + When: f.LastCommit.When, 152 + } 153 + } 154 + entries = append(entries, entry) 155 + } 156 + return entries, nil 157 + } 158 + 159 + func (a *gitReadAdapter) FileContentN(path string, cap int64) ([]byte, error) { 160 + return a.g.FileContentN(path, cap) 161 + } 162 + 163 + func (a *gitReadAdapter) RawContent(path string) ([]byte, error) { 164 + return a.g.RawContent(path) 165 + } 166 + 167 + func (a *gitReadAdapter) WriteTar(w io.Writer, prefix string) error { 168 + return a.g.WriteTar(w, prefix) 169 + } 170 + 171 + func (a *gitReadAdapter) Tags(opts *PaginationOpts) ([]TagInfo, error) { 172 + var gitOpts *git.TagsOptions 173 + if opts != nil { 174 + gitOpts = &git.TagsOptions{ 175 + Limit: opts.Limit, 176 + Offset: opts.Offset, 177 + } 178 + } 179 + 180 + tags, err := a.g.Tags(gitOpts) 181 + if err != nil { 182 + return nil, err 183 + } 184 + 185 + infos := make([]TagInfo, 0, len(tags)) 186 + for _, t := range tags { 187 + info := TagInfo{ 188 + Name: t.Name, 189 + Hash: t.Hash.String(), 190 + Message: t.Message, 191 + Target: t.Target.String(), 192 + } 193 + if t.Tagger.Name != "" { 194 + info.Tagger = &Author{ 195 + Name: t.Tagger.Name, 196 + Email: t.Tagger.Email, 197 + When: t.Tagger.When, 198 + } 199 + } 200 + infos = append(infos, info) 201 + } 202 + return infos, nil 203 + } 204 + 205 + // gitMutableAdapter wraps a git.GitRepo to implement MutableRepo. 206 + type gitMutableAdapter struct { 207 + *gitReadAdapter 208 + } 209 + 210 + func newGitMutableAdapter(g *git.GitRepo) *gitMutableAdapter { 211 + return &gitMutableAdapter{gitReadAdapter: newGitReadAdapter(g)} 212 + } 213 + 214 + func (a *gitMutableAdapter) SetDefaultBranch(name string) error { 215 + return a.g.SetDefaultBranch(name) 216 + } 217 + 218 + func (a *gitMutableAdapter) DeleteBranch(name string) error { 219 + return a.g.DeleteBranch(name) 220 + } 221 + 222 + // Git returns the underlying *git.GitRepo for git-specific operations 223 + // (diff, merge, format-patch, etc.) that don't belong in the VCS interface. 224 + func (a *gitReadAdapter) Git() *git.GitRepo { 225 + return a.g 226 + }
+91
knotserver/vcs/open.go
··· 1 + package vcs 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/go-git/go-git/v5/plumbing" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/knotserver/pijul" 9 + ) 10 + 11 + // Open opens a repository at path with the given ref (branch/tag/hash). 12 + // It auto-detects the VCS type and returns the appropriate adapter. 13 + // For git, ref is resolved as a revision; for pijul, ref is the channel name. 14 + func Open(path, ref string) (ReadRepo, error) { 15 + vcsType, err := DetectVCS(path) 16 + if err != nil { 17 + return nil, err 18 + } 19 + 20 + switch vcsType { 21 + case "git": 22 + g, err := git.Open(path, ref) 23 + if err != nil { 24 + return nil, err 25 + } 26 + return newGitReadAdapter(g), nil 27 + case "pijul": 28 + p, err := pijul.Open(path, ref) 29 + if err != nil { 30 + return nil, err 31 + } 32 + return newPijulReadAdapter(p), nil 33 + default: 34 + return nil, fmt.Errorf("unsupported VCS type: %s", vcsType) 35 + } 36 + } 37 + 38 + // PlainOpen opens a repository at path without setting a specific ref. 39 + // Returns a MutableRepo since no ref is pinned. 40 + func PlainOpen(path string) (MutableRepo, error) { 41 + vcsType, err := DetectVCS(path) 42 + if err != nil { 43 + return nil, err 44 + } 45 + 46 + switch vcsType { 47 + case "git": 48 + g, err := git.PlainOpen(path) 49 + if err != nil { 50 + return nil, err 51 + } 52 + return newGitMutableAdapter(g), nil 53 + case "pijul": 54 + p, err := pijul.PlainOpen(path) 55 + if err != nil { 56 + return nil, err 57 + } 58 + return newPijulMutableAdapter(p), nil 59 + default: 60 + return nil, fmt.Errorf("unsupported VCS type: %s", vcsType) 61 + } 62 + } 63 + 64 + // AsGit returns the underlying *git.GitRepo if repo is a git adapter, or nil. 65 + // Use this for git-specific operations that don't belong in the VCS interface. 66 + func AsGit(repo ReadRepo) *git.GitRepo { 67 + switch r := repo.(type) { 68 + case *gitReadAdapter: 69 + return r.Git() 70 + case *gitMutableAdapter: 71 + return r.Git() 72 + } 73 + return nil 74 + } 75 + 76 + // AsPijul returns the underlying *pijul.PijulRepo if repo is a pijul adapter, or nil. 77 + // Use this for pijul-specific operations that don't belong in the VCS interface. 78 + func AsPijul(repo ReadRepo) *pijul.PijulRepo { 79 + switch r := repo.(type) { 80 + case *pijulReadAdapter: 81 + return r.Pijul() 82 + case *pijulMutableAdapter: 83 + return r.Pijul() 84 + } 85 + return nil 86 + } 87 + 88 + // gitHash converts a hex string to a plumbing.Hash. 89 + func gitHash(s string) plumbing.Hash { 90 + return plumbing.NewHash(s) 91 + }
+160
knotserver/vcs/pijuladapter.go
··· 1 + package vcs 2 + 3 + import ( 4 + "context" 5 + "io" 6 + 7 + "tangled.org/core/knotserver/pijul" 8 + ) 9 + 10 + // pijulReadAdapter wraps a pijul.PijulRepo to implement ReadRepo. 11 + type pijulReadAdapter struct { 12 + p *pijul.PijulRepo 13 + } 14 + 15 + func newPijulReadAdapter(p *pijul.PijulRepo) *pijulReadAdapter { 16 + return &pijulReadAdapter{p: p} 17 + } 18 + 19 + func (a *pijulReadAdapter) VCSType() string { return "pijul" } 20 + func (a *pijulReadAdapter) Path() string { return a.p.Path() } 21 + 22 + func (a *pijulReadAdapter) History(offset, limit int) ([]HistoryEntry, error) { 23 + changes, err := a.p.Changes(offset, limit) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + entries := make([]HistoryEntry, 0, len(changes)) 29 + for _, c := range changes { 30 + var author Author 31 + if len(c.Authors) > 0 { 32 + author = Author{ 33 + Name: c.Authors[0].Name, 34 + Email: c.Authors[0].Email, 35 + } 36 + } 37 + entries = append(entries, HistoryEntry{ 38 + Hash: c.Hash, 39 + Author: author, 40 + Committer: author, 41 + Message: c.Message, 42 + Timestamp: c.Timestamp, 43 + Parents: c.Dependencies, 44 + }) 45 + } 46 + return entries, nil 47 + } 48 + 49 + func (a *pijulReadAdapter) TotalHistoryEntries() (int, error) { 50 + return a.p.TotalChanges() 51 + } 52 + 53 + func (a *pijulReadAdapter) HistoryEntry(hash string) (*HistoryEntry, error) { 54 + c, err := a.p.GetChange(hash) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + var author Author 60 + if len(c.Authors) > 0 { 61 + author = Author{ 62 + Name: c.Authors[0].Name, 63 + Email: c.Authors[0].Email, 64 + } 65 + } 66 + 67 + return &HistoryEntry{ 68 + Hash: c.Hash, 69 + Author: author, 70 + Committer: author, 71 + Message: c.Message, 72 + Timestamp: c.Timestamp, 73 + Parents: c.Dependencies, 74 + }, nil 75 + } 76 + 77 + func (a *pijulReadAdapter) Branches(opts *PaginationOpts) ([]BranchInfo, error) { 78 + var pijulOpts *pijul.ChannelOptions 79 + if opts != nil { 80 + pijulOpts = &pijul.ChannelOptions{ 81 + Limit: opts.Limit, 82 + Offset: opts.Offset, 83 + } 84 + } 85 + 86 + channels, err := a.p.ChannelsWithOptions(pijulOpts) 87 + if err != nil { 88 + return nil, err 89 + } 90 + 91 + infos := make([]BranchInfo, 0, len(channels)) 92 + for _, ch := range channels { 93 + infos = append(infos, BranchInfo{ 94 + Name: ch.Name, 95 + IsDefault: ch.IsCurrent, 96 + }) 97 + } 98 + return infos, nil 99 + } 100 + 101 + func (a *pijulReadAdapter) DefaultBranch() (string, error) { 102 + return a.p.FindDefaultChannel() 103 + } 104 + 105 + func (a *pijulReadAdapter) FileTree(ctx context.Context, path string) ([]TreeEntry, error) { 106 + files, err := a.p.FileTree(ctx, path) 107 + if err != nil { 108 + return nil, err 109 + } 110 + 111 + entries := make([]TreeEntry, 0, len(files)) 112 + for _, f := range files { 113 + entries = append(entries, TreeEntry{ 114 + Name: f.Name, 115 + Mode: f.Mode, 116 + Size: f.Size, 117 + }) 118 + } 119 + return entries, nil 120 + } 121 + 122 + func (a *pijulReadAdapter) FileContentN(path string, cap int64) ([]byte, error) { 123 + return a.p.FileContentN(path, cap) 124 + } 125 + 126 + func (a *pijulReadAdapter) RawContent(path string) ([]byte, error) { 127 + return a.p.RawContent(path) 128 + } 129 + 130 + func (a *pijulReadAdapter) WriteTar(w io.Writer, prefix string) error { 131 + return a.p.WriteTar(w, prefix) 132 + } 133 + 134 + func (a *pijulReadAdapter) Tags(_ *PaginationOpts) ([]TagInfo, error) { 135 + // Pijul doesn't have tags. 136 + return nil, nil 137 + } 138 + 139 + // pijulMutableAdapter wraps a pijul.PijulRepo to implement MutableRepo. 140 + type pijulMutableAdapter struct { 141 + *pijulReadAdapter 142 + } 143 + 144 + func newPijulMutableAdapter(p *pijul.PijulRepo) *pijulMutableAdapter { 145 + return &pijulMutableAdapter{pijulReadAdapter: newPijulReadAdapter(p)} 146 + } 147 + 148 + func (a *pijulMutableAdapter) SetDefaultBranch(name string) error { 149 + return a.p.SetDefaultChannel(name) 150 + } 151 + 152 + func (a *pijulMutableAdapter) DeleteBranch(name string) error { 153 + return a.p.DeleteChannel(name) 154 + } 155 + 156 + // Pijul returns the underlying *pijul.PijulRepo for pijul-specific operations 157 + // that don't belong in the VCS interface. 158 + func (a *pijulReadAdapter) Pijul() *pijul.PijulRepo { 159 + return a.p 160 + }
+62
knotserver/vcs/types.go
··· 1 + package vcs 2 + 3 + import "time" 4 + 5 + // Author represents a commit/change author. 6 + type Author struct { 7 + Name string 8 + Email string 9 + When time.Time 10 + } 11 + 12 + // HistoryEntry is a VCS-agnostic representation of a commit or change. 13 + type HistoryEntry struct { 14 + Hash string 15 + Author Author 16 + Committer Author 17 + Message string 18 + Timestamp time.Time 19 + // Parents lists parent hashes (git) or dependency hashes (pijul). 20 + Parents []string 21 + } 22 + 23 + // BranchInfo is a VCS-agnostic branch/channel. 24 + type BranchInfo struct { 25 + Name string 26 + Hash string 27 + IsDefault bool 28 + // LatestEntry is the most recent commit/change on this branch, if available. 29 + LatestEntry *HistoryEntry 30 + } 31 + 32 + // TreeEntry is a VCS-agnostic file/directory entry. 33 + type TreeEntry struct { 34 + Name string 35 + Mode string 36 + Size int64 37 + LastCommit *LastCommitInfo 38 + } 39 + 40 + // LastCommitInfo holds metadata about the last commit that touched a file. 41 + type LastCommitInfo struct { 42 + Hash string 43 + Message string 44 + When time.Time 45 + Author *Author 46 + } 47 + 48 + // TagInfo is a VCS-agnostic tag. 49 + type TagInfo struct { 50 + Name string 51 + Hash string 52 + Message string 53 + Tagger *Author 54 + // Target is the hash of the tagged object (for annotated tags). 55 + Target string 56 + } 57 + 58 + // PaginationOpts controls pagination for list operations. 59 + type PaginationOpts struct { 60 + Offset int 61 + Limit int 62 + }
+22 -3
knotserver/xrpc/create_repo.go
··· 16 16 "tangled.org/core/api/tangled" 17 17 "tangled.org/core/hook" 18 18 "tangled.org/core/knotserver/git" 19 + "tangled.org/core/knotserver/pijul" 19 20 "tangled.org/core/rbac" 20 21 xrpcerr "tangled.org/core/xrpc/errors" 21 22 ) ··· 49 50 return 50 51 } 51 52 53 + vcs := "git" 54 + if data.Vcs != nil && strings.TrimSpace(*data.Vcs) != "" { 55 + vcs = strings.ToLower(strings.TrimSpace(*data.Vcs)) 56 + } 57 + switch vcs { 58 + case "git", "pijul": 59 + default: 60 + fail(xrpcerr.GenericError(fmt.Errorf("unsupported vcs: %s", vcs))) 61 + return 62 + } 63 + 52 64 rkey := data.Rkey 53 65 54 66 ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) ··· 84 96 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 97 86 98 if data.Source != nil && *data.Source != "" { 87 - err = git.Fork(repoPath, *data.Source, h.Config) 99 + if vcs == "pijul" { 100 + err = pijul.Clone(*data.Source, repoPath, "") 101 + } else { 102 + err = git.Fork(repoPath, *data.Source, h.Config) 103 + } 88 104 if err != nil { 89 105 l.Error("forking repo", "error", err.Error()) 90 106 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 107 return 92 108 } 93 109 } else { 94 - err = git.InitBare(repoPath, defaultBranch) 110 + if vcs == "pijul" { 111 + err = pijul.InitBare(repoPath) 112 + } else { 113 + err = git.InitBare(repoPath, defaultBranch) 114 + } 95 115 if err != nil { 96 116 l.Error("initializing bare repo", "error", err.Error()) 97 117 if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { ··· 111 131 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 132 return 113 133 } 114 - 115 134 hook.SetupRepo( 116 135 hook.Config( 117 136 hook.WithScanPath(h.Config.Repo.ScanPath),
+158
knotserver/xrpc/pijul_apply.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.org/core/knotserver/pijul" 11 + "tangled.org/core/rbac" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + // ApplyChangesRequest is the request body for applying changes 16 + type ApplyChangesRequest struct { 17 + Repo string `json:"repo"` 18 + Channel string `json:"channel"` 19 + Changes []string `json:"changes"` 20 + } 21 + 22 + // ApplyChangesResponse is the response for applying changes 23 + type ApplyChangesResponse struct { 24 + Applied []string `json:"applied"` 25 + Failed []ApplyChangeFailure `json:"failed,omitempty"` 26 + } 27 + 28 + // ApplyChangeFailure represents a failed change application 29 + type ApplyChangeFailure struct { 30 + Hash string `json:"hash"` 31 + Error string `json:"error"` 32 + } 33 + 34 + // RepoApplyChanges handles the sh.tangled.repo.applyChanges endpoint 35 + // Applies Pijul changes to a repository channel (used for merging discussions) 36 + func (x *Xrpc) RepoApplyChanges(w http.ResponseWriter, r *http.Request) { 37 + if r.Method != http.MethodPost { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("InvalidRequest"), 40 + xrpcerr.WithMessage("method not allowed"), 41 + ), http.StatusMethodNotAllowed) 42 + return 43 + } 44 + 45 + var req ApplyChangesRequest 46 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("InvalidRequest"), 49 + xrpcerr.WithMessage("invalid request body"), 50 + ), http.StatusBadRequest) 51 + return 52 + } 53 + 54 + if req.Repo == "" || req.Channel == "" || len(req.Changes) == 0 { 55 + writeError(w, xrpcerr.NewXrpcError( 56 + xrpcerr.WithTag("InvalidRequest"), 57 + xrpcerr.WithMessage("repo, channel, and changes are required"), 58 + ), http.StatusBadRequest) 59 + return 60 + } 61 + 62 + // Authorization: verify the caller has push permission 63 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 64 + if !ok { 65 + writeError(w, xrpcerr.MissingActorDidError, http.StatusBadRequest) 66 + return 67 + } 68 + 69 + repoPath, err := x.parseRepoParam(req.Repo) 70 + if err != nil { 71 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 72 + return 73 + } 74 + 75 + repoParts := strings.SplitN(req.Repo, "/", 2) 76 + if len(repoParts) != 2 { 77 + writeError(w, xrpcerr.NewXrpcError( 78 + xrpcerr.WithTag("InvalidRequest"), 79 + xrpcerr.WithMessage("invalid repo format"), 80 + ), http.StatusBadRequest) 81 + return 82 + } 83 + qualifiedRepo, _ := securejoin.SecureJoin(repoParts[0], repoParts[1]) 84 + pushOk, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, qualifiedRepo) 85 + if err != nil || !pushOk { 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("Forbidden"), 88 + xrpcerr.WithMessage("push permission required to apply changes"), 89 + ), http.StatusForbidden) 90 + return 91 + } 92 + 93 + // Open the repository with the target channel 94 + pr, err := pijul.Open(repoPath, req.Channel) 95 + if err != nil { 96 + writeError(w, xrpcerr.NewXrpcError( 97 + xrpcerr.WithTag("RepoNotFound"), 98 + xrpcerr.WithMessage("failed to open pijul repository"), 99 + ), http.StatusNotFound) 100 + return 101 + } 102 + 103 + // Verify the channel exists 104 + channels, err := pr.Channels() 105 + if err != nil { 106 + writeError(w, xrpcerr.NewXrpcError( 107 + xrpcerr.WithTag("InternalServerError"), 108 + xrpcerr.WithMessage("failed to list channels"), 109 + ), http.StatusInternalServerError) 110 + return 111 + } 112 + 113 + channelExists := false 114 + for _, ch := range channels { 115 + if ch.Name == req.Channel { 116 + channelExists = true 117 + break 118 + } 119 + } 120 + 121 + if !channelExists { 122 + writeError(w, xrpcerr.NewXrpcError( 123 + xrpcerr.WithTag("ChannelNotFound"), 124 + xrpcerr.WithMessage("target channel not found"), 125 + ), http.StatusNotFound) 126 + return 127 + } 128 + 129 + // Apply each change in order 130 + response := ApplyChangesResponse{ 131 + Applied: make([]string, 0), 132 + Failed: make([]ApplyChangeFailure, 0), 133 + } 134 + 135 + for _, changeHash := range req.Changes { 136 + if err := pr.Apply(changeHash); err != nil { 137 + x.Logger.Error("failed to apply change", "hash", changeHash, "error", err.Error()) 138 + response.Failed = append(response.Failed, ApplyChangeFailure{ 139 + Hash: changeHash, 140 + Error: err.Error(), 141 + }) 142 + } else { 143 + response.Applied = append(response.Applied, changeHash) 144 + x.Logger.Info("applied change", "hash", changeHash, "channel", req.Channel) 145 + } 146 + } 147 + 148 + // If any changes failed, return partial success 149 + if len(response.Failed) > 0 && len(response.Applied) == 0 { 150 + writeError(w, xrpcerr.NewXrpcError( 151 + xrpcerr.WithTag("ApplyFailed"), 152 + xrpcerr.WithMessage("all changes failed to apply"), 153 + ), http.StatusInternalServerError) 154 + return 155 + } 156 + 157 + writeJson(w, response) 158 + }
+199
knotserver/xrpc/pijul_changes.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + "time" 7 + 8 + "tangled.org/core/knotserver/vcs" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + // PijulChangeListResponse is the response for listing Pijul changes 13 + type PijulChangeListResponse struct { 14 + Changes []PijulChangeEntry `json:"changes"` 15 + Channel string `json:"channel,omitempty"` 16 + Page int `json:"page"` 17 + PerPage int `json:"per_page"` 18 + Total int `json:"total"` 19 + } 20 + 21 + // PijulChangeEntry represents a single change in the list 22 + type PijulChangeEntry struct { 23 + Hash string `json:"hash"` 24 + Authors []PijulAuthor `json:"authors"` 25 + Message string `json:"message"` 26 + Timestamp string `json:"timestamp,omitempty"` 27 + Dependencies []string `json:"dependencies,omitempty"` 28 + } 29 + 30 + // PijulAuthor represents a change author 31 + type PijulAuthor struct { 32 + Name string `json:"name"` 33 + Email string `json:"email,omitempty"` 34 + } 35 + 36 + // RepoChangeList handles the sh.tangled.repo.changeList endpoint 37 + // Lists changes (Pijul equivalent of commits) in a repository. 38 + // Uses the unified VCS History interface. 39 + func (x *Xrpc) RepoChangeList(w http.ResponseWriter, r *http.Request) { 40 + repo := r.URL.Query().Get("repo") 41 + repoPath, err := x.parseRepoParam(repo) 42 + if err != nil { 43 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 44 + return 45 + } 46 + 47 + channel := r.URL.Query().Get("channel") 48 + cursor := r.URL.Query().Get("cursor") 49 + 50 + limit := 50 // default 51 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 52 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 53 + limit = l 54 + } 55 + } 56 + 57 + rv, err := vcs.Open(repoPath, channel) 58 + if err != nil { 59 + writeError(w, xrpcerr.NewXrpcError( 60 + xrpcerr.WithTag("RepoNotFound"), 61 + xrpcerr.WithMessage("failed to open repository"), 62 + ), http.StatusNotFound) 63 + return 64 + } 65 + 66 + offset := 0 67 + if cursor != "" { 68 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 69 + offset = o 70 + } 71 + } 72 + 73 + entries, err := rv.History(offset, limit) 74 + if err != nil { 75 + x.Logger.Error("fetching changes", "error", err.Error()) 76 + writeError(w, xrpcerr.NewXrpcError( 77 + xrpcerr.WithTag("InternalServerError"), 78 + xrpcerr.WithMessage("failed to read change log"), 79 + ), http.StatusInternalServerError) 80 + return 81 + } 82 + 83 + total, err := rv.TotalHistoryEntries() 84 + if err != nil { 85 + x.Logger.Error("fetching total changes", "error", err.Error()) 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("InternalServerError"), 88 + xrpcerr.WithMessage("failed to fetch total changes"), 89 + ), http.StatusInternalServerError) 90 + return 91 + } 92 + 93 + // Convert to response format 94 + changeEntries := make([]PijulChangeEntry, len(entries)) 95 + for i, e := range entries { 96 + authors := []PijulAuthor{{ 97 + Name: e.Author.Name, 98 + Email: e.Author.Email, 99 + }} 100 + 101 + changeEntries[i] = PijulChangeEntry{ 102 + Hash: e.Hash, 103 + Authors: authors, 104 + Message: e.Message, 105 + Dependencies: e.Parents, 106 + } 107 + 108 + if !e.Timestamp.IsZero() { 109 + changeEntries[i].Timestamp = e.Timestamp.Format(time.RFC3339) 110 + } 111 + } 112 + 113 + response := PijulChangeListResponse{ 114 + Changes: changeEntries, 115 + Channel: channel, 116 + Page: (offset / limit) + 1, 117 + PerPage: limit, 118 + Total: total, 119 + } 120 + 121 + writeJson(w, response) 122 + } 123 + 124 + // PijulChangeGetResponse is the response for getting a single change 125 + type PijulChangeGetResponse struct { 126 + Hash string `json:"hash"` 127 + Authors []PijulAuthor `json:"authors"` 128 + Message string `json:"message"` 129 + Timestamp string `json:"timestamp,omitempty"` 130 + Dependencies []string `json:"dependencies,omitempty"` 131 + Diff string `json:"diff,omitempty"` 132 + } 133 + 134 + // RepoChangeGet handles the sh.tangled.repo.changeGet endpoint 135 + // Gets details for a specific change 136 + func (x *Xrpc) RepoChangeGet(w http.ResponseWriter, r *http.Request) { 137 + repo := r.URL.Query().Get("repo") 138 + repoPath, err := x.parseRepoParam(repo) 139 + if err != nil { 140 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 141 + return 142 + } 143 + 144 + hash := r.URL.Query().Get("hash") 145 + if hash == "" { 146 + writeError(w, xrpcerr.NewXrpcError( 147 + xrpcerr.WithTag("InvalidRequest"), 148 + xrpcerr.WithMessage("missing hash parameter"), 149 + ), http.StatusBadRequest) 150 + return 151 + } 152 + 153 + rv, err := vcs.Open(repoPath, "") 154 + if err != nil { 155 + writeError(w, xrpcerr.NewXrpcError( 156 + xrpcerr.WithTag("RepoNotFound"), 157 + xrpcerr.WithMessage("failed to open repository"), 158 + ), http.StatusNotFound) 159 + return 160 + } 161 + 162 + entry, err := rv.HistoryEntry(hash) 163 + if err != nil { 164 + x.Logger.Error("fetching change", "error", err.Error(), "hash", hash) 165 + writeError(w, xrpcerr.NewXrpcError( 166 + xrpcerr.WithTag("ChangeNotFound"), 167 + xrpcerr.WithMessage("change not found"), 168 + ), http.StatusNotFound) 169 + return 170 + } 171 + 172 + authors := []PijulAuthor{{ 173 + Name: entry.Author.Name, 174 + Email: entry.Author.Email, 175 + }} 176 + 177 + response := PijulChangeGetResponse{ 178 + Hash: entry.Hash, 179 + Authors: authors, 180 + Message: entry.Message, 181 + Dependencies: entry.Parents, 182 + } 183 + 184 + if !entry.Timestamp.IsZero() { 185 + response.Timestamp = entry.Timestamp.Format(time.RFC3339) 186 + } 187 + 188 + // Get diff for pijul repos 189 + if pr := vcs.AsPijul(rv); pr != nil { 190 + diff, err := pr.DiffChange(hash) 191 + if err != nil { 192 + x.Logger.Warn("failed to get diff for change", "hash", hash, "error", err) 193 + } else if diff != nil { 194 + response.Diff = diff.Raw 195 + } 196 + } 197 + 198 + writeJson(w, response) 199 + }
+95
knotserver/xrpc/pijul_channels.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/knotserver/vcs" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 + ) 9 + 10 + // PijulChannelListResponse is the response for listing Pijul channels 11 + type PijulChannelListResponse struct { 12 + Channels []PijulChannel `json:"channels"` 13 + } 14 + 15 + // PijulChannel represents a Pijul channel 16 + type PijulChannel struct { 17 + Name string `json:"name"` 18 + IsCurrent bool `json:"is_current,omitempty"` 19 + } 20 + 21 + // RepoChannelList handles the sh.tangled.repo.channelList endpoint 22 + // Kept for backwards compatibility - delegates to the unified Branches interface. 23 + func (x *Xrpc) RepoChannelList(w http.ResponseWriter, r *http.Request) { 24 + repo := r.URL.Query().Get("repo") 25 + repoPath, err := x.parseRepoParam(repo) 26 + if err != nil { 27 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + rv, err := vcs.PlainOpen(repoPath) 32 + if err != nil { 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("RepoNotFound"), 35 + xrpcerr.WithMessage("failed to open repository"), 36 + ), http.StatusNotFound) 37 + return 38 + } 39 + 40 + branches, err := rv.Branches(nil) 41 + if err != nil { 42 + x.Logger.Error("fetching channels", "error", err.Error()) 43 + writeError(w, xrpcerr.NewXrpcError( 44 + xrpcerr.WithTag("InternalServerError"), 45 + xrpcerr.WithMessage("failed to list channels"), 46 + ), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + channelList := make([]PijulChannel, len(branches)) 51 + for i, b := range branches { 52 + channelList[i] = PijulChannel{ 53 + Name: b.Name, 54 + IsCurrent: b.IsDefault, 55 + } 56 + } 57 + 58 + writeJson(w, PijulChannelListResponse{Channels: channelList}) 59 + } 60 + 61 + // PijulGetDefaultChannelResponse is the response for getting the default channel 62 + type PijulGetDefaultChannelResponse struct { 63 + Channel string `json:"channel"` 64 + } 65 + 66 + // RepoGetDefaultChannel handles the sh.tangled.repo.getDefaultChannel endpoint 67 + func (x *Xrpc) RepoGetDefaultChannel(w http.ResponseWriter, r *http.Request) { 68 + repo := r.URL.Query().Get("repo") 69 + repoPath, err := x.parseRepoParam(repo) 70 + if err != nil { 71 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 72 + return 73 + } 74 + 75 + rv, err := vcs.PlainOpen(repoPath) 76 + if err != nil { 77 + writeError(w, xrpcerr.NewXrpcError( 78 + xrpcerr.WithTag("RepoNotFound"), 79 + xrpcerr.WithMessage("failed to open repository"), 80 + ), http.StatusNotFound) 81 + return 82 + } 83 + 84 + channel, err := rv.DefaultBranch() 85 + if err != nil { 86 + x.Logger.Error("finding default channel", "error", err.Error()) 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("InternalServerError"), 89 + xrpcerr.WithMessage("failed to find default channel"), 90 + ), http.StatusInternalServerError) 91 + return 92 + } 93 + 94 + writeJson(w, PijulGetDefaultChannelResponse{Channel: channel}) 95 + }
+85
knotserver/xrpc/pijul_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/knotserver/vcs" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 + 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + // RepoPijulTree is kept as an alias for backwards compatibility. 13 + // It delegates to the unified RepoTree handler. 14 + func (x *Xrpc) RepoPijulTree(w http.ResponseWriter, r *http.Request) { 15 + // Map channel param to ref if present, then delegate 16 + q := r.URL.Query() 17 + if channel := q.Get("channel"); channel != "" && q.Get("ref") == "" { 18 + q.Set("ref", channel) 19 + r.URL.RawQuery = q.Encode() 20 + } 21 + x.RepoTree(w, r) 22 + } 23 + 24 + // RepoPijulBlob handles the sh.tangled.repo.pijulBlob endpoint 25 + // Returns file content from a Pijul repository 26 + func (x *Xrpc) RepoPijulBlob(w http.ResponseWriter, r *http.Request) { 27 + repo := r.URL.Query().Get("repo") 28 + repoPath, err := x.parseRepoParam(repo) 29 + if err != nil { 30 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 31 + return 32 + } 33 + 34 + channel := r.URL.Query().Get("channel") 35 + path := r.URL.Query().Get("path") 36 + 37 + if path == "" { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("InvalidRequest"), 40 + xrpcerr.WithMessage("missing path parameter"), 41 + ), http.StatusBadRequest) 42 + return 43 + } 44 + 45 + rv, err := vcs.Open(repoPath, channel) 46 + if err != nil { 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("RepoNotFound"), 49 + xrpcerr.WithMessage("failed to open pijul repository"), 50 + ), http.StatusNotFound) 51 + return 52 + } 53 + 54 + // Try to read as text first 55 + const maxSize = 1024 * 1024 // 1MB 56 + content, err := rv.FileContentN(path, maxSize) 57 + if err != nil { 58 + if vcs.IsBinaryFileError(err) { 59 + // Return binary indicator 60 + response := tangled.RepoPijulBlob_Output{ 61 + Is_binary: true, 62 + Path: path, 63 + Ref: &channel, 64 + } 65 + writeJson(w, response) 66 + return 67 + } 68 + 69 + x.Logger.Error("failed to read file", "error", err, "path", path) 70 + writeError(w, xrpcerr.NewXrpcError( 71 + xrpcerr.WithTag("PathNotFound"), 72 + xrpcerr.WithMessage("failed to read file"), 73 + ), http.StatusNotFound) 74 + return 75 + } 76 + 77 + contentStr := string(content) 78 + response := tangled.RepoPijulBlob_Output{ 79 + Contents: &contentStr, 80 + Path: path, 81 + Ref: &channel, 82 + } 83 + 84 + writeJson(w, response) 85 + }
+36 -7
knotserver/xrpc/repo_branches.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.org/core/knotserver/git" 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + "tangled.org/core/knotserver/vcs" 8 10 "tangled.org/core/types" 9 11 xrpcerr "tangled.org/core/xrpc/errors" 10 12 ) ··· 29 31 offset = o 30 32 } 31 33 32 - gr, err := git.PlainOpen(repoPath) 34 + rv, err := vcs.PlainOpen(repoPath) 33 35 if err != nil { 34 36 writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 35 37 return 36 38 } 37 39 38 - branches, _ := gr.Branches(&git.BranchesOptions{ 40 + branchInfos, err := rv.Branches(&vcs.PaginationOpts{ 39 41 Limit: limit, 40 42 Offset: offset, 41 43 }) 44 + if err != nil { 45 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 46 + return 47 + } 42 48 43 - // Create response using existing types.RepoBranchesResponse 44 - response := types.RepoBranchesResponse{ 45 - Branches: branches, 49 + branches := make([]types.Branch, 0, len(branchInfos)) 50 + for _, bi := range branchInfos { 51 + b := types.Branch{ 52 + Reference: types.Reference{ 53 + Name: bi.Name, 54 + Hash: bi.Hash, 55 + }, 56 + IsDefault: bi.IsDefault, 57 + } 58 + if bi.LatestEntry != nil { 59 + b.Commit = &object.Commit{ 60 + Hash: plumbing.NewHash(bi.LatestEntry.Hash), 61 + Author: object.Signature{ 62 + Name: bi.LatestEntry.Author.Name, 63 + Email: bi.LatestEntry.Author.Email, 64 + When: bi.LatestEntry.Author.When, 65 + }, 66 + Committer: object.Signature{ 67 + Name: bi.LatestEntry.Committer.Name, 68 + Email: bi.LatestEntry.Committer.Email, 69 + When: bi.LatestEntry.Committer.When, 70 + }, 71 + Message: bi.LatestEntry.Message, 72 + } 73 + } 74 + branches = append(branches, b) 46 75 } 47 76 48 - writeJson(w, response) 77 + writeJson(w, types.RepoBranchesResponse{Branches: branches}) 49 78 }
+8 -3
knotserver/xrpc/repo_get_default_branch.go
··· 5 5 "time" 6 6 7 7 "tangled.org/core/api/tangled" 8 - "tangled.org/core/knotserver/git" 8 + "tangled.org/core/knotserver/vcs" 9 9 xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 ··· 17 17 return 18 18 } 19 19 20 - gr, err := git.PlainOpen(repoPath) 20 + rv, err := vcs.PlainOpen(repoPath) 21 + if err != nil { 22 + x.Logger.Error("failed to open repository", "error", err) 23 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 24 + return 25 + } 21 26 22 - branch, err := gr.FindMainBranch() 27 + branch, err := rv.DefaultBranch() 23 28 if err != nil { 24 29 x.Logger.Error("getting default branch", "error", err.Error()) 25 30 writeError(w, xrpcerr.NewXrpcError(
+37 -11
knotserver/xrpc/repo_log.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.org/core/knotserver/git" 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + "tangled.org/core/knotserver/vcs" 8 10 "tangled.org/core/types" 9 11 xrpcerr "tangled.org/core/xrpc/errors" 10 12 ) ··· 29 31 } 30 32 } 31 33 32 - gr, err := git.Open(repoPath, ref) 34 + rv, err := vcs.Open(repoPath, ref) 33 35 if err != nil { 34 36 writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 37 return 36 38 } 37 39 40 + // If ref was empty, resolve the actual default branch name 41 + if ref == "" { 42 + if defaultBranch, err := rv.DefaultBranch(); err == nil { 43 + ref = defaultBranch 44 + } 45 + } 46 + 38 47 offset := 0 39 48 if cursor != "" { 40 49 if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { ··· 42 51 } 43 52 } 44 53 45 - commits, err := gr.Commits(offset, limit) 54 + entries, err := rv.History(offset, limit) 46 55 if err != nil { 47 - x.Logger.Error("fetching commits", "error", err.Error()) 56 + x.Logger.Error("fetching history", "error", err.Error()) 48 57 writeError(w, xrpcerr.NewXrpcError( 49 58 xrpcerr.WithTag("PathNotFound"), 50 59 xrpcerr.WithMessage("failed to read commit log"), ··· 52 61 return 53 62 } 54 63 55 - total, err := gr.TotalCommits() 64 + total, err := rv.TotalHistoryEntries() 56 65 if err != nil { 57 - x.Logger.Error("fetching total commits", "error", err.Error()) 66 + x.Logger.Error("fetching total history entries", "error", err.Error()) 58 67 writeError(w, xrpcerr.NewXrpcError( 59 68 xrpcerr.WithTag("InternalServerError"), 60 69 xrpcerr.WithMessage("failed to fetch total commits"), 61 - ), http.StatusNotFound) 70 + ), http.StatusInternalServerError) 62 71 return 63 72 } 64 73 65 - tcommits := make([]types.Commit, len(commits)) 66 - for i, c := range commits { 67 - tcommits[i].FromGoGitCommit(c) 74 + tcommits := make([]types.Commit, len(entries)) 75 + for i, e := range entries { 76 + parents := make([]plumbing.Hash, 0, len(e.Parents)) 77 + for _, p := range e.Parents { 78 + parents = append(parents, plumbing.NewHash(p)) 79 + } 80 + tcommits[i] = types.Commit{ 81 + Hash: plumbing.NewHash(e.Hash), 82 + Author: object.Signature{ 83 + Name: e.Author.Name, 84 + Email: e.Author.Email, 85 + When: e.Author.When, 86 + }, 87 + Committer: object.Signature{ 88 + Name: e.Committer.Name, 89 + Email: e.Committer.Email, 90 + When: e.Committer.When, 91 + }, 92 + Message: e.Message, 93 + ParentHashes: parents, 94 + } 68 95 } 69 96 70 - // Create response using existing types.RepoLogResponse 71 97 response := types.RepoLogResponse{ 72 98 Commits: tcommits, 73 99 Ref: ref,
+110
knotserver/xrpc/repo_permissions.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "os" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/rbac" 13 + xrpcerr "tangled.org/core/xrpc/errors" 14 + ) 15 + 16 + const ( 17 + permPush int64 = 1 << iota 18 + permSettings 19 + permCreateDiscussion 20 + permEditDiscussion 21 + permTagDiscussion 22 + permOwner 23 + permInvite 24 + permDelete 25 + ) 26 + 27 + // RepoPermissions returns the permissions the authenticated user has on a repository. 28 + // This endpoint is VCS-agnostic — it works for both git and pijul repos. 29 + func (x *Xrpc) RepoPermissions(w http.ResponseWriter, r *http.Request) { 30 + l := x.Logger.With("handler", "RepoPermissions") 31 + fail := func(e xrpcerr.XrpcError, status int) { 32 + l.Error("failed", "kind", e.Tag, "error", e.Message) 33 + writeError(w, e, status) 34 + } 35 + 36 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 37 + if !ok { 38 + fail(xrpcerr.MissingActorDidError, http.StatusBadRequest) 39 + return 40 + } 41 + 42 + repo := r.URL.Query().Get("repo") 43 + if repo == "" { 44 + fail(xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("InvalidRequest"), 46 + xrpcerr.WithMessage("missing repo parameter"), 47 + ), http.StatusBadRequest) 48 + return 49 + } 50 + 51 + repoPath, err := x.parseRepoParam(repo) 52 + if err != nil { 53 + fail(err.(xrpcerr.XrpcError), http.StatusBadRequest) 54 + return 55 + } 56 + 57 + if _, err := os.Stat(repoPath); err != nil { 58 + if os.IsNotExist(err) { 59 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 60 + return 61 + } 62 + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) 63 + return 64 + } 65 + 66 + repoParts := strings.SplitN(repo, "/", 2) 67 + if len(repoParts) != 2 { 68 + fail(xrpcerr.NewXrpcError( 69 + xrpcerr.WithTag("InvalidRequest"), 70 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 71 + ), http.StatusBadRequest) 72 + return 73 + } 74 + 75 + didSlashRepo, err := securejoin.SecureJoin(repoParts[0], repoParts[1]) 76 + if err != nil { 77 + fail(xrpcerr.InvalidRepoError(repo), http.StatusBadRequest) 78 + return 79 + } 80 + 81 + roles := x.Enforcer.GetPermissionsInRepo(actorDid.String(), rbac.ThisServer, didSlashRepo) 82 + 83 + var mask int64 84 + var permissions []string 85 + add := func(name string, bit int64, ok bool) { 86 + if !ok { 87 + return 88 + } 89 + mask |= bit 90 + permissions = append(permissions, name) 91 + } 92 + 93 + has := func(perm string) bool { 94 + return slices.Contains(roles, perm) 95 + } 96 + 97 + add("push", permPush, has("repo:push")) 98 + add("settings", permSettings, has("repo:settings")) 99 + add("create_discussion", permCreateDiscussion, has("repo:create_discussion")) 100 + add("edit_discussion", permEditDiscussion, has("repo:edit_discussion")) 101 + add("tag_discussion", permTagDiscussion, has("repo:tag_discussion")) 102 + add("owner", permOwner, has("repo:owner")) 103 + add("invite", permInvite, has("repo:invite")) 104 + add("delete", permDelete, has("repo:delete")) 105 + 106 + writeJson(w, tangled.RepoPermissions_Output{ 107 + Mask: mask, 108 + Permissions: permissions, 109 + }) 110 + }
+12 -1
knotserver/xrpc/repo_tag.go
··· 8 8 "github.com/go-git/go-git/v5/plumbing/object" 9 9 10 10 "tangled.org/core/knotserver/git" 11 + "tangled.org/core/knotserver/vcs" 11 12 "tangled.org/core/types" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) ··· 29 30 return 30 31 } 31 32 32 - gr, err := git.PlainOpen(repoPath) 33 + rv, err := vcs.PlainOpen(repoPath) 33 34 if err != nil { 34 35 x.Logger.Error("failed to open", "error", err) 35 36 writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 37 + return 38 + } 39 + 40 + // pijul has no tags 41 + gr := vcs.AsGit(rv) 42 + if gr == nil { 43 + writeError(w, xrpcerr.NewXrpcError( 44 + xrpcerr.WithTag("TagNotFound"), 45 + xrpcerr.WithMessage("tags are not supported for pijul repositories"), 46 + ), http.StatusNotFound) 36 47 return 37 48 } 38 49
+15 -7
knotserver/xrpc/repo_tags.go
··· 8 8 "github.com/go-git/go-git/v5/plumbing/object" 9 9 10 10 "tangled.org/core/knotserver/git" 11 + "tangled.org/core/knotserver/vcs" 11 12 "tangled.org/core/types" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) ··· 20 21 return 21 22 } 22 23 24 + rv, err := vcs.PlainOpen(repoPath) 25 + if err != nil { 26 + x.Logger.Error("failed to open", "error", err) 27 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 28 + return 29 + } 30 + 31 + // pijul has no tags, return empty list 32 + gr := vcs.AsGit(rv) 33 + if gr == nil { 34 + writeJson(w, types.RepoTagsResponse{Tags: []*types.TagReference{}}) 35 + return 36 + } 37 + 23 38 // default 24 39 limit := 50 25 40 offset := 0 ··· 30 45 31 46 if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 32 47 offset = o 33 - } 34 - 35 - gr, err := git.PlainOpen(repoPath) 36 - if err != nil { 37 - x.Logger.Error("failed to open", "error", err) 38 - writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 39 - return 40 48 } 41 49 42 50 tags, err := gr.Tags(&git.TagsOptions{
+30 -19
knotserver/xrpc/repo_tree.go
··· 6 6 "time" 7 7 "unicode/utf8" 8 8 9 + "github.com/go-git/go-git/v5/plumbing" 9 10 "tangled.org/core/api/tangled" 10 11 "tangled.org/core/appview/pages/markup" 11 - "tangled.org/core/knotserver/git" 12 - "tangled.org/core/types" 12 + "tangled.org/core/knotserver/vcs" 13 13 xrpcerr "tangled.org/core/xrpc/errors" 14 14 ) 15 15 ··· 24 24 } 25 25 26 26 ref := r.URL.Query().Get("ref") 27 - // ref can be empty (git.Open handles this) 27 + // For pijul, also accept "channel" param and fall back to default 28 + if channel := r.URL.Query().Get("channel"); channel != "" { 29 + ref = channel 30 + } 28 31 29 32 path := r.URL.Query().Get("path") 30 - // path can be empty (defaults to root) 31 33 32 - gr, err := git.Open(repoPath, ref) 34 + rv, err := vcs.Open(repoPath, ref) 33 35 if err != nil { 34 - x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 36 + x.Logger.Error("failed to open repository", "error", err, "path", repoPath, "ref", ref) 35 37 writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 36 38 return 37 39 } 38 40 39 - files, err := gr.FileTree(ctx, path) 41 + files, err := rv.FileTree(ctx, path) 40 42 if err != nil { 41 43 x.Logger.Error("failed to get file tree", "error", err, "path", path) 42 44 writeError(w, xrpcerr.NewXrpcError( ··· 51 53 var readmeContents string 52 54 for _, file := range files { 53 55 if markup.IsReadmeFile(file.Name) { 54 - contents, err := gr.RawContent(filepath.Join(path, file.Name)) 56 + contents, err := rv.RawContent(filepath.Join(path, file.Name)) 55 57 if err != nil { 56 58 x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 59 + continue 57 60 } 58 61 59 62 if utf8.Valid(contents) { ··· 64 67 } 65 68 } 66 69 67 - // convert NiceTree -> tangled.RepoTree_TreeEntry 70 + // convert vcs.TreeEntry -> tangled.RepoTree_TreeEntry 68 71 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 69 72 for i, file := range files { 70 73 entry := &tangled.RepoTree_TreeEntry{ ··· 75 78 76 79 if file.LastCommit != nil { 77 80 entry.Last_commit = &tangled.RepoTree_LastCommit{ 78 - Hash: file.LastCommit.Hash.String(), 81 + Hash: file.LastCommit.Hash, 79 82 Message: file.LastCommit.Message, 80 83 When: file.LastCommit.When.Format(time.RFC3339), 81 84 } ··· 109 112 } 110 113 111 114 // calculate lastCommit for the directory as a whole 112 - var lastCommitTree *types.LastCommitInfo 113 - for _, e := range files { 115 + var lastCommitTree *vcs.LastCommitInfo 116 + for i := range files { 117 + e := &files[i] 114 118 if e.LastCommit == nil { 115 119 continue 116 120 } ··· 120 124 continue 121 125 } 122 126 123 - if lastCommitTree.When.After(e.LastCommit.When) { 127 + if lastCommitTree.When.Before(e.LastCommit.When) { 124 128 lastCommitTree = e.LastCommit 125 129 } 126 130 } 127 131 128 132 if lastCommitTree != nil { 129 133 response.LastCommit = &tangled.RepoTree_LastCommit{ 130 - Hash: lastCommitTree.Hash.String(), 134 + Hash: lastCommitTree.Hash, 131 135 Message: lastCommitTree.Message, 132 136 When: lastCommitTree.When.Format(time.RFC3339), 133 137 } 134 138 135 - // try to get author information 136 - commit, err := gr.Commit(lastCommitTree.Hash) 137 - if err == nil { 139 + if lastCommitTree.Author != nil { 138 140 response.LastCommit.Author = &tangled.RepoTree_Signature{ 139 - Name: commit.Author.Name, 140 - Email: commit.Author.Email, 141 + Name: lastCommitTree.Author.Name, 142 + Email: lastCommitTree.Author.Email, 143 + } 144 + } else if gr := vcs.AsGit(rv); gr != nil { 145 + // try to get author information from the git commit 146 + commit, err := gr.Commit(plumbing.NewHash(lastCommitTree.Hash)) 147 + if err == nil { 148 + response.LastCommit.Author = &tangled.RepoTree_Signature{ 149 + Name: commit.Author.Name, 150 + Email: commit.Author.Email, 151 + } 141 152 } 142 153 } 143 154 }
+8
knotserver/xrpc/xrpc.go
··· 45 45 r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 46 46 r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 47 47 r.Post("/"+tangled.RepoMergeNSID, x.Merge) 48 + r.Post("/"+tangled.RepoApplyChangesNSID, x.RepoApplyChanges) 49 + r.Get("/"+tangled.RepoPermissionsNSID, x.RepoPermissions) 48 50 }) 49 51 50 52 // merge check is an open endpoint ··· 61 63 r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 64 r.Get("/"+tangled.RepoTagNSID, x.RepoTag) 63 65 r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 66 + r.Get("/"+tangled.RepoPijulBlobNSID, x.RepoPijulBlob) 64 67 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 65 68 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 66 69 r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 70 + r.Get("/"+tangled.RepoGetDefaultChannelNSID, x.RepoGetDefaultChannel) 67 71 r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 68 72 r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 69 73 r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 74 + r.Get("/"+tangled.RepoChannelListNSID, x.RepoChannelList) 75 + r.Get("/"+tangled.RepoChangeListNSID, x.RepoChangeList) 76 + r.Get("/"+tangled.RepoChangeGetNSID, x.RepoChangeGet) 77 + r.Get("/"+tangled.RepoPijulTreeNSID, x.RepoPijulTree) 70 78 71 79 // knot query endpoints (no auth required) 72 80 r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
+51
lexicons/discussion/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.discussion.comment", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "description": "A comment on a discussion", 11 + "record": { 12 + "type": "object", 13 + "required": ["discussion", "body", "createdAt"], 14 + "properties": { 15 + "discussion": { 16 + "type": "string", 17 + "format": "at-uri", 18 + "description": "The discussion this comment belongs to" 19 + }, 20 + "body": { 21 + "type": "string", 22 + "description": "Comment text" 23 + }, 24 + "replyTo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "If this is a reply, the parent comment's at-uri" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 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 + }
+55
lexicons/discussion/discussion.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.discussion", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "description": "A discussion in a Pijul repository. Anyone can add patches to discussions.", 11 + "record": { 12 + "type": "object", 13 + "required": ["repo", "title", "createdAt"], 14 + "properties": { 15 + "repo": { 16 + "type": "string", 17 + "format": "at-uri", 18 + "description": "The repository this discussion belongs to" 19 + }, 20 + "title": { 21 + "type": "string", 22 + "description": "Discussion title" 23 + }, 24 + "body": { 25 + "type": "string", 26 + "description": "Discussion body/description" 27 + }, 28 + "targetChannel": { 29 + "type": "string", 30 + "description": "Target Pijul channel for merging patches", 31 + "default": "main" 32 + }, 33 + "createdAt": { 34 + "type": "string", 35 + "format": "datetime" 36 + }, 37 + "mentions": { 38 + "type": "array", 39 + "items": { 40 + "type": "string", 41 + "format": "did" 42 + } 43 + }, 44 + "references": { 45 + "type": "array", 46 + "items": { 47 + "type": "string", 48 + "format": "at-uri" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + } 55 + }
+33
lexicons/discussion/state.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.discussion.state", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "description": "A state change event for a discussion (close, reopen, merge)", 11 + "record": { 12 + "type": "object", 13 + "required": ["discussion", "state", "createdAt"], 14 + "properties": { 15 + "discussion": { 16 + "type": "string", 17 + "format": "at-uri", 18 + "description": "The discussion this state change applies to" 19 + }, 20 + "state": { 21 + "type": "string", 22 + "knownValues": ["open", "closed", "merged"], 23 + "description": "The new state of the discussion" 24 + }, 25 + "createdAt": { 26 + "type": "string", 27 + "format": "datetime" 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+44
lexicons/pijul/refUpdate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pijul.refUpdate", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Pijul reference update event - emitted when changes are pushed to a channel", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["repo", "channel", "newState", "changes"], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "description": "Repository identifier" 16 + }, 17 + "channel": { 18 + "type": "string", 19 + "description": "Channel that was updated" 20 + }, 21 + "oldState": { 22 + "type": "string", 23 + "description": "Previous Merkle hash (empty for new channels)" 24 + }, 25 + "newState": { 26 + "type": "string", 27 + "description": "New Merkle hash after update" 28 + }, 29 + "changes": { 30 + "type": "array", 31 + "items": { 32 + "type": "string" 33 + }, 34 + "description": "List of change hashes that were applied" 35 + }, 36 + "languages": { 37 + "type": "object", 38 + "description": "Map of language name to lines of code" 39 + } 40 + } 41 + } 42 + } 43 + } 44 + }
+80
lexicons/repo/applyChanges.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.applyChanges", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Apply Pijul changes to a repository channel. Used for merging discussions.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "channel", "changes"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "description": "Repository identifier in format 'did:plc:.../repoName'" 17 + }, 18 + "channel": { 19 + "type": "string", 20 + "description": "Target channel to apply changes to" 21 + }, 22 + "changes": { 23 + "type": "array", 24 + "items": { 25 + "type": "string" 26 + }, 27 + "description": "List of change hashes to apply (in order)" 28 + } 29 + } 30 + } 31 + }, 32 + "output": { 33 + "encoding": "application/json", 34 + "schema": { 35 + "type": "object", 36 + "required": ["applied"], 37 + "properties": { 38 + "applied": { 39 + "type": "array", 40 + "items": { 41 + "type": "string" 42 + }, 43 + "description": "List of successfully applied change hashes" 44 + }, 45 + "failed": { 46 + "type": "array", 47 + "items": { 48 + "type": "object", 49 + "required": ["hash", "error"], 50 + "properties": { 51 + "hash": { 52 + "type": "string" 53 + }, 54 + "error": { 55 + "type": "string" 56 + } 57 + } 58 + }, 59 + "description": "List of changes that failed to apply" 60 + } 61 + } 62 + } 63 + }, 64 + "errors": [ 65 + { 66 + "name": "InvalidRequest" 67 + }, 68 + { 69 + "name": "RepoNotFound" 70 + }, 71 + { 72 + "name": "ChannelNotFound" 73 + }, 74 + { 75 + "name": "ApplyFailed" 76 + } 77 + ] 78 + } 79 + } 80 + }
+90
lexicons/repo/changeGet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.changeGet", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get details of a specific Pijul change", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo", "hash"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "description": "Repository identifier in format 'did:plc:.../repoName'" 15 + }, 16 + "hash": { 17 + "type": "string", 18 + "description": "Change hash to retrieve" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["hash", "authors", "message"], 27 + "properties": { 28 + "hash": { 29 + "type": "string", 30 + "description": "Change hash (base32 encoded)" 31 + }, 32 + "authors": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#author" 37 + } 38 + }, 39 + "message": { 40 + "type": "string", 41 + "description": "Change description" 42 + }, 43 + "timestamp": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "When the change was recorded" 47 + }, 48 + "dependencies": { 49 + "type": "array", 50 + "items": { 51 + "type": "string" 52 + }, 53 + "description": "Hashes of changes this change depends on" 54 + }, 55 + "diff": { 56 + "type": "string", 57 + "description": "Raw diff content of the change" 58 + } 59 + } 60 + } 61 + }, 62 + "errors": [ 63 + { 64 + "name": "RepoNotFound", 65 + "description": "Repository not found or access denied" 66 + }, 67 + { 68 + "name": "ChangeNotFound", 69 + "description": "Change not found" 70 + }, 71 + { 72 + "name": "InvalidRequest", 73 + "description": "Invalid request parameters" 74 + } 75 + ] 76 + }, 77 + "author": { 78 + "type": "object", 79 + "required": ["name"], 80 + "properties": { 81 + "name": { 82 + "type": "string" 83 + }, 84 + "email": { 85 + "type": "string" 86 + } 87 + } 88 + } 89 + } 90 + }
+122
lexicons/repo/changeList.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.changeList", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List changes in a Pijul repository", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "description": "Repository identifier in format 'did:plc:.../repoName'" 15 + }, 16 + "channel": { 17 + "type": "string", 18 + "description": "Pijul channel name (defaults to main channel)" 19 + }, 20 + "limit": { 21 + "type": "integer", 22 + "description": "Maximum number of changes to return", 23 + "minimum": 1, 24 + "maximum": 100, 25 + "default": 50 26 + }, 27 + "cursor": { 28 + "type": "string", 29 + "description": "Pagination cursor (offset)" 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["changes", "page", "per_page", "total"], 38 + "properties": { 39 + "changes": { 40 + "type": "array", 41 + "items": { 42 + "type": "ref", 43 + "ref": "#changeEntry" 44 + } 45 + }, 46 + "channel": { 47 + "type": "string" 48 + }, 49 + "page": { 50 + "type": "integer" 51 + }, 52 + "per_page": { 53 + "type": "integer" 54 + }, 55 + "total": { 56 + "type": "integer" 57 + } 58 + } 59 + } 60 + }, 61 + "errors": [ 62 + { 63 + "name": "RepoNotFound", 64 + "description": "Repository not found or access denied" 65 + }, 66 + { 67 + "name": "ChannelNotFound", 68 + "description": "Channel not found" 69 + }, 70 + { 71 + "name": "InvalidRequest", 72 + "description": "Invalid request parameters" 73 + } 74 + ] 75 + }, 76 + "changeEntry": { 77 + "type": "object", 78 + "required": ["hash", "authors", "message"], 79 + "properties": { 80 + "hash": { 81 + "type": "string", 82 + "description": "Change hash (base32 encoded)" 83 + }, 84 + "authors": { 85 + "type": "array", 86 + "items": { 87 + "type": "ref", 88 + "ref": "#author" 89 + } 90 + }, 91 + "message": { 92 + "type": "string", 93 + "description": "Change description" 94 + }, 95 + "timestamp": { 96 + "type": "string", 97 + "format": "datetime", 98 + "description": "When the change was recorded" 99 + }, 100 + "dependencies": { 101 + "type": "array", 102 + "items": { 103 + "type": "string" 104 + }, 105 + "description": "Hashes of changes this change depends on" 106 + } 107 + } 108 + }, 109 + "author": { 110 + "type": "object", 111 + "required": ["name"], 112 + "properties": { 113 + "name": { 114 + "type": "string" 115 + }, 116 + "email": { 117 + "type": "string" 118 + } 119 + } 120 + } 121 + } 122 + }
+71
lexicons/repo/channelList.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.channelList", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List channels in a Pijul repository", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "description": "Repository identifier in format 'did:plc:.../repoName'" 15 + }, 16 + "limit": { 17 + "type": "integer", 18 + "description": "Maximum number of channels to return", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50 22 + }, 23 + "cursor": { 24 + "type": "string", 25 + "description": "Pagination cursor (offset)" 26 + } 27 + } 28 + }, 29 + "output": { 30 + "encoding": "application/json", 31 + "schema": { 32 + "type": "object", 33 + "required": ["channels"], 34 + "properties": { 35 + "channels": { 36 + "type": "array", 37 + "items": { 38 + "type": "ref", 39 + "ref": "#channel" 40 + } 41 + } 42 + } 43 + } 44 + }, 45 + "errors": [ 46 + { 47 + "name": "RepoNotFound", 48 + "description": "Repository not found or access denied" 49 + }, 50 + { 51 + "name": "InvalidRequest", 52 + "description": "Invalid request parameters" 53 + } 54 + ] 55 + }, 56 + "channel": { 57 + "type": "object", 58 + "required": ["name"], 59 + "properties": { 60 + "name": { 61 + "type": "string", 62 + "description": "Channel name" 63 + }, 64 + "is_current": { 65 + "type": "boolean", 66 + "description": "Whether this is the currently active channel" 67 + } 68 + } 69 + } 70 + } 71 + }
+4
lexicons/repo/create.json
··· 24 24 "source": { 25 25 "type": "string", 26 26 "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + }, 28 + "vcs": { 29 + "type": "string", 30 + "description": "Version control system to use for the repository (git or pijul)." 27 31 } 28 32 } 29 33 }
+39
lexicons/repo/getDefaultChannel.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultChannel", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the default channel for a Pijul repository", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "description": "Repository identifier in format 'did:plc:.../repoName'" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["channel"], 23 + "properties": { 24 + "channel": { 25 + "type": "string", 26 + "description": "Default channel name" 27 + } 28 + } 29 + } 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + } 36 + ] 37 + } 38 + } 39 + }
+47
lexicons/repo/permissions.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.permissions", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["permissions", "mask"], 22 + "properties": { 23 + "permissions": { 24 + "type": "array", 25 + "items": { 26 + "type": "string" 27 + } 28 + }, 29 + "mask": { 30 + "type": "integer" 31 + } 32 + } 33 + } 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "InvalidRequest", 42 + "description": "Invalid request parameters" 43 + } 44 + ] 45 + } 46 + } 47 + }
+67
lexicons/repo/pijulBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.pijulBlob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get file content from a Pijul repository", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo", "path"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "description": "Repository identifier in format 'did:plc:.../repoName'" 15 + }, 16 + "channel": { 17 + "type": "string", 18 + "description": "Pijul channel name (defaults to main channel)" 19 + }, 20 + "path": { 21 + "type": "string", 22 + "description": "Path to the file within the repository" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["path", "is_binary"], 31 + "properties": { 32 + "contents": { 33 + "type": "string", 34 + "description": "File contents (empty for binary files)" 35 + }, 36 + "is_binary": { 37 + "type": "boolean", 38 + "description": "Whether the file is binary" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "File path" 43 + }, 44 + "ref": { 45 + "type": "string", 46 + "description": "Channel name" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "PathNotFound", 58 + "description": "File not found in repository" 59 + }, 60 + { 61 + "name": "InvalidRequest", 62 + "description": "Invalid request parameters" 63 + } 64 + ] 65 + } 66 + } 67 + }
+101
lexicons/repo/pijulTree.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.pijulTree", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the file tree from a Pijul repository", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "description": "Repository identifier in format 'did:plc:.../repoName'" 15 + }, 16 + "channel": { 17 + "type": "string", 18 + "description": "Pijul channel name (defaults to main channel)" 19 + }, 20 + "path": { 21 + "type": "string", 22 + "description": "Path within the repository (defaults to root)", 23 + "default": "" 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["files"], 32 + "properties": { 33 + "ref": { 34 + "type": "string", 35 + "description": "Channel name" 36 + }, 37 + "parent": { 38 + "type": "string", 39 + "description": "Parent path" 40 + }, 41 + "dotdot": { 42 + "type": "string", 43 + "description": "Path to navigate up" 44 + }, 45 + "files": { 46 + "type": "array", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#treeEntry" 50 + } 51 + }, 52 + "readme": { 53 + "type": "ref", 54 + "ref": "#readme" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "PathNotFound", 66 + "description": "Path not found in repository" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "treeEntry": { 75 + "type": "object", 76 + "required": ["name", "mode", "size"], 77 + "properties": { 78 + "name": { 79 + "type": "string" 80 + }, 81 + "mode": { 82 + "type": "string" 83 + }, 84 + "size": { 85 + "type": "integer" 86 + } 87 + } 88 + }, 89 + "readme": { 90 + "type": "object", 91 + "properties": { 92 + "filename": { 93 + "type": "string" 94 + }, 95 + "contents": { 96 + "type": "string" 97 + } 98 + } 99 + } 100 + } 101 + }
+1
nix/modules/knot.nix
··· 176 176 config = mkIf cfg.enable { 177 177 environment.systemPackages = [ 178 178 pkgs.git 179 + pkgs.pijul 179 180 cfg.package 180 181 ]; 181 182
+2 -1
nix/pkgs/knot.nix
··· 2 2 knot-unwrapped, 3 3 makeWrapper, 4 4 git, 5 + pijul, 5 6 }: 6 7 knot-unwrapped.overrideAttrs (after: before: { 7 8 nativeBuildInputs = (before.nativeBuildInputs or []) ++ [makeWrapper]; ··· 13 14 cp $GOPATH/bin/knot $out/bin/knot 14 15 15 16 wrapProgram $out/bin/knot \ 16 - --prefix PATH : ${git}/bin 17 + --prefix PATH : ${git}/bin:${pijul}/bin 17 18 18 19 runHook postInstall 19 20 '';
+8
rbac/permissions.go
··· 1 + package rbac 2 + 3 + // Generic repo permissions (VCS-agnostic). 4 + const ( 5 + RepoCreateDiscussion = "repo:create_discussion" 6 + RepoEditDiscussion = "repo:edit_discussion" 7 + RepoTagDiscussion = "repo:tag_discussion" 8 + )
+6
rbac/rbac.go
··· 156 156 {member, domain, repo, "repo:owner"}, 157 157 {member, domain, repo, "repo:invite"}, 158 158 {member, domain, repo, "repo:delete"}, 159 + {member, domain, repo, RepoCreateDiscussion}, 160 + {member, domain, repo, RepoEditDiscussion}, 161 + {member, domain, repo, RepoTagDiscussion}, 159 162 {"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo 160 163 } 161 164 } ··· 184 187 {collaborator, domain, repo, "repo:collaborator"}, 185 188 {collaborator, domain, repo, "repo:settings"}, 186 189 {collaborator, domain, repo, "repo:push"}, 190 + {collaborator, domain, repo, RepoCreateDiscussion}, 191 + {collaborator, domain, repo, RepoEditDiscussion}, 192 + {collaborator, domain, repo, RepoTagDiscussion}, 187 193 } 188 194 } 189 195 )