A vibe coded tangled fork which supports pijul.

Add pijul support

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

+10720 -226
+1493
api/tangled/cbor_gen.go
··· 3924 3924 3925 3925 return nil 3926 3926 } 3927 + func (t *PijulRefUpdate) MarshalCBOR(w io.Writer) error { 3928 + if t == nil { 3929 + _, err := w.Write(cbg.CborNull) 3930 + return err 3931 + } 3932 + 3933 + cw := cbg.NewCborWriter(w) 3934 + fieldCount := 8 3935 + 3936 + if t.Languages == nil { 3937 + fieldCount-- 3938 + } 3939 + 3940 + if t.OldState == nil { 3941 + fieldCount-- 3942 + } 3943 + 3944 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3945 + return err 3946 + } 3947 + 3948 + // t.Repo (string) (string) 3949 + if len("repo") > 1000000 { 3950 + return xerrors.Errorf("Value in field \"repo\" was too long") 3951 + } 3952 + 3953 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 3954 + return err 3955 + } 3956 + if _, err := cw.WriteString(string("repo")); err != nil { 3957 + return err 3958 + } 3959 + 3960 + if len(t.Repo) > 1000000 { 3961 + return xerrors.Errorf("Value in field t.Repo was too long") 3962 + } 3963 + 3964 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 3965 + return err 3966 + } 3967 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 3968 + return err 3969 + } 3970 + 3971 + // t.CommitterDid (string) (string) 3972 + if len("committerDid") > 1000000 { 3973 + return xerrors.Errorf("Value in field \"committerDid\" was too long") 3974 + } 3975 + 3976 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("committerDid"))); err != nil { 3977 + return err 3978 + } 3979 + if _, err := cw.WriteString(string("committerDid")); err != nil { 3980 + return err 3981 + } 3982 + 3983 + if len(t.CommitterDid) > 1000000 { 3984 + return xerrors.Errorf("Value in field t.CommitterDid was too long") 3985 + } 3986 + 3987 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CommitterDid))); err != nil { 3988 + return err 3989 + } 3990 + if _, err := cw.WriteString(string(t.CommitterDid)); err != nil { 3991 + return err 3992 + } 3993 + 3994 + // t.LexiconTypeID (string) (string) 3995 + if len("$type") > 1000000 { 3996 + return xerrors.Errorf("Value in field \"$type\" was too long") 3997 + } 3998 + 3999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 4000 + return err 4001 + } 4002 + if _, err := cw.WriteString(string("$type")); err != nil { 4003 + return err 4004 + } 4005 + 4006 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.pijul.refUpdate"))); err != nil { 4007 + return err 4008 + } 4009 + if _, err := cw.WriteString(string("sh.tangled.pijul.refUpdate")); err != nil { 4010 + return err 4011 + } 4012 + 4013 + // t.Changes ([]string) (slice) 4014 + if len("changes") > 1000000 { 4015 + return xerrors.Errorf("Value in field \"changes\" was too long") 4016 + } 4017 + 4018 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("changes"))); err != nil { 4019 + return err 4020 + } 4021 + if _, err := cw.WriteString(string("changes")); err != nil { 4022 + return err 4023 + } 4024 + 4025 + if len(t.Changes) > 8192 { 4026 + return xerrors.Errorf("Slice value in field t.Changes was too long") 4027 + } 4028 + 4029 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Changes))); err != nil { 4030 + return err 4031 + } 4032 + for _, v := range t.Changes { 4033 + if len(v) > 1000000 { 4034 + return xerrors.Errorf("Value in field v was too long") 4035 + } 4036 + 4037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 4038 + return err 4039 + } 4040 + if _, err := cw.WriteString(string(v)); err != nil { 4041 + return err 4042 + } 4043 + 4044 + } 4045 + 4046 + // t.Channel (string) (string) 4047 + if len("channel") > 1000000 { 4048 + return xerrors.Errorf("Value in field \"channel\" was too long") 4049 + } 4050 + 4051 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("channel"))); err != nil { 4052 + return err 4053 + } 4054 + if _, err := cw.WriteString(string("channel")); err != nil { 4055 + return err 4056 + } 4057 + 4058 + if len(t.Channel) > 1000000 { 4059 + return xerrors.Errorf("Value in field t.Channel was too long") 4060 + } 4061 + 4062 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Channel))); err != nil { 4063 + return err 4064 + } 4065 + if _, err := cw.WriteString(string(t.Channel)); err != nil { 4066 + return err 4067 + } 4068 + 4069 + // t.NewState (string) (string) 4070 + if len("newState") > 1000000 { 4071 + return xerrors.Errorf("Value in field \"newState\" was too long") 4072 + } 4073 + 4074 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("newState"))); err != nil { 4075 + return err 4076 + } 4077 + if _, err := cw.WriteString(string("newState")); err != nil { 4078 + return err 4079 + } 4080 + 4081 + if len(t.NewState) > 1000000 { 4082 + return xerrors.Errorf("Value in field t.NewState was too long") 4083 + } 4084 + 4085 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.NewState))); err != nil { 4086 + return err 4087 + } 4088 + if _, err := cw.WriteString(string(t.NewState)); err != nil { 4089 + return err 4090 + } 4091 + 4092 + // t.OldState (string) (string) 4093 + if t.OldState != nil { 4094 + 4095 + if len("oldState") > 1000000 { 4096 + return xerrors.Errorf("Value in field \"oldState\" was too long") 4097 + } 4098 + 4099 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oldState"))); err != nil { 4100 + return err 4101 + } 4102 + if _, err := cw.WriteString(string("oldState")); err != nil { 4103 + return err 4104 + } 4105 + 4106 + if t.OldState == nil { 4107 + if _, err := cw.Write(cbg.CborNull); err != nil { 4108 + return err 4109 + } 4110 + } else { 4111 + if len(*t.OldState) > 1000000 { 4112 + return xerrors.Errorf("Value in field t.OldState was too long") 4113 + } 4114 + 4115 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.OldState))); err != nil { 4116 + return err 4117 + } 4118 + if _, err := cw.WriteString(string(*t.OldState)); err != nil { 4119 + return err 4120 + } 4121 + } 4122 + } 4123 + 4124 + // t.Languages (tangled.PijulRefUpdate_Languages) (struct) 4125 + if t.Languages != nil { 4126 + 4127 + if len("languages") > 1000000 { 4128 + return xerrors.Errorf("Value in field \"languages\" was too long") 4129 + } 4130 + 4131 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("languages"))); err != nil { 4132 + return err 4133 + } 4134 + if _, err := cw.WriteString(string("languages")); err != nil { 4135 + return err 4136 + } 4137 + 4138 + if err := t.Languages.MarshalCBOR(cw); err != nil { 4139 + return err 4140 + } 4141 + } 4142 + return nil 4143 + } 4144 + 4145 + func (t *PijulRefUpdate) UnmarshalCBOR(r io.Reader) (err error) { 4146 + *t = PijulRefUpdate{} 4147 + 4148 + cr := cbg.NewCborReader(r) 4149 + 4150 + maj, extra, err := cr.ReadHeader() 4151 + if err != nil { 4152 + return err 4153 + } 4154 + defer func() { 4155 + if err == io.EOF { 4156 + err = io.ErrUnexpectedEOF 4157 + } 4158 + }() 4159 + 4160 + if maj != cbg.MajMap { 4161 + return fmt.Errorf("cbor input should be of type map") 4162 + } 4163 + 4164 + if extra > cbg.MaxLength { 4165 + return fmt.Errorf("PijulRefUpdate: map struct too large (%d)", extra) 4166 + } 4167 + 4168 + n := extra 4169 + 4170 + nameBuf := make([]byte, 9) 4171 + for i := uint64(0); i < n; i++ { 4172 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4173 + if err != nil { 4174 + return err 4175 + } 4176 + 4177 + if !ok { 4178 + // Field doesn't exist on this type, so ignore it 4179 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4180 + return err 4181 + } 4182 + continue 4183 + } 4184 + 4185 + switch string(nameBuf[:nameLen]) { 4186 + // t.Repo (string) (string) 4187 + case "repo": 4188 + 4189 + { 4190 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4191 + if err != nil { 4192 + return err 4193 + } 4194 + 4195 + t.Repo = string(sval) 4196 + } 4197 + // t.LexiconTypeID (string) (string) 4198 + case "$type": 4199 + 4200 + { 4201 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4202 + if err != nil { 4203 + return err 4204 + } 4205 + 4206 + t.LexiconTypeID = string(sval) 4207 + } 4208 + // t.Changes ([]string) (slice) 4209 + case "changes": 4210 + 4211 + maj, extra, err = cr.ReadHeader() 4212 + if err != nil { 4213 + return err 4214 + } 4215 + 4216 + if extra > 8192 { 4217 + return fmt.Errorf("t.Changes: array too large (%d)", extra) 4218 + } 4219 + 4220 + if maj != cbg.MajArray { 4221 + return fmt.Errorf("expected cbor array") 4222 + } 4223 + 4224 + if extra > 0 { 4225 + t.Changes = make([]string, extra) 4226 + } 4227 + 4228 + for i := 0; i < int(extra); i++ { 4229 + { 4230 + var maj byte 4231 + var extra uint64 4232 + var err error 4233 + _ = maj 4234 + _ = extra 4235 + _ = err 4236 + 4237 + { 4238 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4239 + if err != nil { 4240 + return err 4241 + } 4242 + 4243 + t.Changes[i] = string(sval) 4244 + } 4245 + 4246 + } 4247 + } 4248 + // t.Channel (string) (string) 4249 + case "channel": 4250 + 4251 + { 4252 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4253 + if err != nil { 4254 + return err 4255 + } 4256 + 4257 + t.Channel = string(sval) 4258 + } 4259 + // t.CommitterDid (string) (string) 4260 + case "committerDid": 4261 + 4262 + { 4263 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4264 + if err != nil { 4265 + return err 4266 + } 4267 + 4268 + t.CommitterDid = string(sval) 4269 + } 4270 + // t.NewState (string) (string) 4271 + case "newState": 4272 + 4273 + { 4274 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4275 + if err != nil { 4276 + return err 4277 + } 4278 + 4279 + t.NewState = string(sval) 4280 + } 4281 + // t.OldState (string) (string) 4282 + case "oldState": 4283 + 4284 + { 4285 + b, err := cr.ReadByte() 4286 + if err != nil { 4287 + return err 4288 + } 4289 + if b != cbg.CborNull[0] { 4290 + if err := cr.UnreadByte(); err != nil { 4291 + return err 4292 + } 4293 + 4294 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4295 + if err != nil { 4296 + return err 4297 + } 4298 + 4299 + t.OldState = (*string)(&sval) 4300 + } 4301 + } 4302 + // t.Languages (tangled.PijulRefUpdate_Languages) (struct) 4303 + case "languages": 4304 + 4305 + { 4306 + 4307 + b, err := cr.ReadByte() 4308 + if err != nil { 4309 + return err 4310 + } 4311 + if b != cbg.CborNull[0] { 4312 + if err := cr.UnreadByte(); err != nil { 4313 + return err 4314 + } 4315 + t.Languages = new(PijulRefUpdate_Languages) 4316 + if err := t.Languages.UnmarshalCBOR(cr); err != nil { 4317 + return xerrors.Errorf("unmarshaling t.Languages pointer: %w", err) 4318 + } 4319 + } 4320 + 4321 + } 4322 + 4323 + default: 4324 + // Field doesn't exist on this type, so ignore it 4325 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4326 + return err 4327 + } 4328 + } 4329 + } 4330 + 4331 + return nil 4332 + } 3927 4333 func (t *Pipeline) MarshalCBOR(w io.Writer) error { 3928 4334 if t == nil { 3929 4335 _, err := w.Write(cbg.CborNull) ··· 7560 7966 } 7561 7967 7562 7968 t.CreatedAt = string(sval) 7969 + } 7970 + 7971 + default: 7972 + // Field doesn't exist on this type, so ignore it 7973 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7974 + return err 7975 + } 7976 + } 7977 + } 7978 + 7979 + return nil 7980 + } 7981 + func (t *RepoDiscussion) MarshalCBOR(w io.Writer) error { 7982 + if t == nil { 7983 + _, err := w.Write(cbg.CborNull) 7984 + return err 7985 + } 7986 + 7987 + cw := cbg.NewCborWriter(w) 7988 + fieldCount := 8 7989 + 7990 + if t.Body == nil { 7991 + fieldCount-- 7992 + } 7993 + 7994 + if t.Mentions == nil { 7995 + fieldCount-- 7996 + } 7997 + 7998 + if t.References == nil { 7999 + fieldCount-- 8000 + } 8001 + 8002 + if t.TargetChannel == nil { 8003 + fieldCount-- 8004 + } 8005 + 8006 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 8007 + return err 8008 + } 8009 + 8010 + // t.Body (string) (string) 8011 + if t.Body != nil { 8012 + 8013 + if len("body") > 1000000 { 8014 + return xerrors.Errorf("Value in field \"body\" was too long") 8015 + } 8016 + 8017 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 8018 + return err 8019 + } 8020 + if _, err := cw.WriteString(string("body")); err != nil { 8021 + return err 8022 + } 8023 + 8024 + if t.Body == nil { 8025 + if _, err := cw.Write(cbg.CborNull); err != nil { 8026 + return err 8027 + } 8028 + } else { 8029 + if len(*t.Body) > 1000000 { 8030 + return xerrors.Errorf("Value in field t.Body was too long") 8031 + } 8032 + 8033 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Body))); err != nil { 8034 + return err 8035 + } 8036 + if _, err := cw.WriteString(string(*t.Body)); err != nil { 8037 + return err 8038 + } 8039 + } 8040 + } 8041 + 8042 + // t.Repo (string) (string) 8043 + if len("repo") > 1000000 { 8044 + return xerrors.Errorf("Value in field \"repo\" was too long") 8045 + } 8046 + 8047 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 8048 + return err 8049 + } 8050 + if _, err := cw.WriteString(string("repo")); err != nil { 8051 + return err 8052 + } 8053 + 8054 + if len(t.Repo) > 1000000 { 8055 + return xerrors.Errorf("Value in field t.Repo was too long") 8056 + } 8057 + 8058 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 8059 + return err 8060 + } 8061 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 8062 + return err 8063 + } 8064 + 8065 + // t.LexiconTypeID (string) (string) 8066 + if len("$type") > 1000000 { 8067 + return xerrors.Errorf("Value in field \"$type\" was too long") 8068 + } 8069 + 8070 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8071 + return err 8072 + } 8073 + if _, err := cw.WriteString(string("$type")); err != nil { 8074 + return err 8075 + } 8076 + 8077 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.discussion"))); err != nil { 8078 + return err 8079 + } 8080 + if _, err := cw.WriteString(string("sh.tangled.repo.discussion")); err != nil { 8081 + return err 8082 + } 8083 + 8084 + // t.Title (string) (string) 8085 + if len("title") > 1000000 { 8086 + return xerrors.Errorf("Value in field \"title\" was too long") 8087 + } 8088 + 8089 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("title"))); err != nil { 8090 + return err 8091 + } 8092 + if _, err := cw.WriteString(string("title")); err != nil { 8093 + return err 8094 + } 8095 + 8096 + if len(t.Title) > 1000000 { 8097 + return xerrors.Errorf("Value in field t.Title was too long") 8098 + } 8099 + 8100 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Title))); err != nil { 8101 + return err 8102 + } 8103 + if _, err := cw.WriteString(string(t.Title)); err != nil { 8104 + return err 8105 + } 8106 + 8107 + // t.Mentions ([]string) (slice) 8108 + if t.Mentions != nil { 8109 + 8110 + if len("mentions") > 1000000 { 8111 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8112 + } 8113 + 8114 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8115 + return err 8116 + } 8117 + if _, err := cw.WriteString(string("mentions")); err != nil { 8118 + return err 8119 + } 8120 + 8121 + if len(t.Mentions) > 8192 { 8122 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8123 + } 8124 + 8125 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8126 + return err 8127 + } 8128 + for _, v := range t.Mentions { 8129 + if len(v) > 1000000 { 8130 + return xerrors.Errorf("Value in field v was too long") 8131 + } 8132 + 8133 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8134 + return err 8135 + } 8136 + if _, err := cw.WriteString(string(v)); err != nil { 8137 + return err 8138 + } 8139 + 8140 + } 8141 + } 8142 + 8143 + // t.CreatedAt (string) (string) 8144 + if len("createdAt") > 1000000 { 8145 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8146 + } 8147 + 8148 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8149 + return err 8150 + } 8151 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8152 + return err 8153 + } 8154 + 8155 + if len(t.CreatedAt) > 1000000 { 8156 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8157 + } 8158 + 8159 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8160 + return err 8161 + } 8162 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8163 + return err 8164 + } 8165 + 8166 + // t.References ([]string) (slice) 8167 + if t.References != nil { 8168 + 8169 + if len("references") > 1000000 { 8170 + return xerrors.Errorf("Value in field \"references\" was too long") 8171 + } 8172 + 8173 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8174 + return err 8175 + } 8176 + if _, err := cw.WriteString(string("references")); err != nil { 8177 + return err 8178 + } 8179 + 8180 + if len(t.References) > 8192 { 8181 + return xerrors.Errorf("Slice value in field t.References was too long") 8182 + } 8183 + 8184 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8185 + return err 8186 + } 8187 + for _, v := range t.References { 8188 + if len(v) > 1000000 { 8189 + return xerrors.Errorf("Value in field v was too long") 8190 + } 8191 + 8192 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8193 + return err 8194 + } 8195 + if _, err := cw.WriteString(string(v)); err != nil { 8196 + return err 8197 + } 8198 + 8199 + } 8200 + } 8201 + 8202 + // t.TargetChannel (string) (string) 8203 + if t.TargetChannel != nil { 8204 + 8205 + if len("targetChannel") > 1000000 { 8206 + return xerrors.Errorf("Value in field \"targetChannel\" was too long") 8207 + } 8208 + 8209 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetChannel"))); err != nil { 8210 + return err 8211 + } 8212 + if _, err := cw.WriteString(string("targetChannel")); err != nil { 8213 + return err 8214 + } 8215 + 8216 + if t.TargetChannel == nil { 8217 + if _, err := cw.Write(cbg.CborNull); err != nil { 8218 + return err 8219 + } 8220 + } else { 8221 + if len(*t.TargetChannel) > 1000000 { 8222 + return xerrors.Errorf("Value in field t.TargetChannel was too long") 8223 + } 8224 + 8225 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.TargetChannel))); err != nil { 8226 + return err 8227 + } 8228 + if _, err := cw.WriteString(string(*t.TargetChannel)); err != nil { 8229 + return err 8230 + } 8231 + } 8232 + } 8233 + return nil 8234 + } 8235 + 8236 + func (t *RepoDiscussion) UnmarshalCBOR(r io.Reader) (err error) { 8237 + *t = RepoDiscussion{} 8238 + 8239 + cr := cbg.NewCborReader(r) 8240 + 8241 + maj, extra, err := cr.ReadHeader() 8242 + if err != nil { 8243 + return err 8244 + } 8245 + defer func() { 8246 + if err == io.EOF { 8247 + err = io.ErrUnexpectedEOF 8248 + } 8249 + }() 8250 + 8251 + if maj != cbg.MajMap { 8252 + return fmt.Errorf("cbor input should be of type map") 8253 + } 8254 + 8255 + if extra > cbg.MaxLength { 8256 + return fmt.Errorf("RepoDiscussion: map struct too large (%d)", extra) 8257 + } 8258 + 8259 + n := extra 8260 + 8261 + nameBuf := make([]byte, 13) 8262 + for i := uint64(0); i < n; i++ { 8263 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8264 + if err != nil { 8265 + return err 8266 + } 8267 + 8268 + if !ok { 8269 + // Field doesn't exist on this type, so ignore it 8270 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8271 + return err 8272 + } 8273 + continue 8274 + } 8275 + 8276 + switch string(nameBuf[:nameLen]) { 8277 + // t.Body (string) (string) 8278 + case "body": 8279 + 8280 + { 8281 + b, err := cr.ReadByte() 8282 + if err != nil { 8283 + return err 8284 + } 8285 + if b != cbg.CborNull[0] { 8286 + if err := cr.UnreadByte(); err != nil { 8287 + return err 8288 + } 8289 + 8290 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8291 + if err != nil { 8292 + return err 8293 + } 8294 + 8295 + t.Body = (*string)(&sval) 8296 + } 8297 + } 8298 + // t.Repo (string) (string) 8299 + case "repo": 8300 + 8301 + { 8302 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8303 + if err != nil { 8304 + return err 8305 + } 8306 + 8307 + t.Repo = string(sval) 8308 + } 8309 + // t.LexiconTypeID (string) (string) 8310 + case "$type": 8311 + 8312 + { 8313 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8314 + if err != nil { 8315 + return err 8316 + } 8317 + 8318 + t.LexiconTypeID = string(sval) 8319 + } 8320 + // t.Title (string) (string) 8321 + case "title": 8322 + 8323 + { 8324 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8325 + if err != nil { 8326 + return err 8327 + } 8328 + 8329 + t.Title = string(sval) 8330 + } 8331 + // t.Mentions ([]string) (slice) 8332 + case "mentions": 8333 + 8334 + maj, extra, err = cr.ReadHeader() 8335 + if err != nil { 8336 + return err 8337 + } 8338 + 8339 + if extra > 8192 { 8340 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8341 + } 8342 + 8343 + if maj != cbg.MajArray { 8344 + return fmt.Errorf("expected cbor array") 8345 + } 8346 + 8347 + if extra > 0 { 8348 + t.Mentions = make([]string, extra) 8349 + } 8350 + 8351 + for i := 0; i < int(extra); i++ { 8352 + { 8353 + var maj byte 8354 + var extra uint64 8355 + var err error 8356 + _ = maj 8357 + _ = extra 8358 + _ = err 8359 + 8360 + { 8361 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8362 + if err != nil { 8363 + return err 8364 + } 8365 + 8366 + t.Mentions[i] = string(sval) 8367 + } 8368 + 8369 + } 8370 + } 8371 + // t.CreatedAt (string) (string) 8372 + case "createdAt": 8373 + 8374 + { 8375 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8376 + if err != nil { 8377 + return err 8378 + } 8379 + 8380 + t.CreatedAt = string(sval) 8381 + } 8382 + // t.References ([]string) (slice) 8383 + case "references": 8384 + 8385 + maj, extra, err = cr.ReadHeader() 8386 + if err != nil { 8387 + return err 8388 + } 8389 + 8390 + if extra > 8192 { 8391 + return fmt.Errorf("t.References: array too large (%d)", extra) 8392 + } 8393 + 8394 + if maj != cbg.MajArray { 8395 + return fmt.Errorf("expected cbor array") 8396 + } 8397 + 8398 + if extra > 0 { 8399 + t.References = make([]string, extra) 8400 + } 8401 + 8402 + for i := 0; i < int(extra); i++ { 8403 + { 8404 + var maj byte 8405 + var extra uint64 8406 + var err error 8407 + _ = maj 8408 + _ = extra 8409 + _ = err 8410 + 8411 + { 8412 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8413 + if err != nil { 8414 + return err 8415 + } 8416 + 8417 + t.References[i] = string(sval) 8418 + } 8419 + 8420 + } 8421 + } 8422 + // t.TargetChannel (string) (string) 8423 + case "targetChannel": 8424 + 8425 + { 8426 + b, err := cr.ReadByte() 8427 + if err != nil { 8428 + return err 8429 + } 8430 + if b != cbg.CborNull[0] { 8431 + if err := cr.UnreadByte(); err != nil { 8432 + return err 8433 + } 8434 + 8435 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8436 + if err != nil { 8437 + return err 8438 + } 8439 + 8440 + t.TargetChannel = (*string)(&sval) 8441 + } 8442 + } 8443 + 8444 + default: 8445 + // Field doesn't exist on this type, so ignore it 8446 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8447 + return err 8448 + } 8449 + } 8450 + } 8451 + 8452 + return nil 8453 + } 8454 + func (t *RepoDiscussionComment) MarshalCBOR(w io.Writer) error { 8455 + if t == nil { 8456 + _, err := w.Write(cbg.CborNull) 8457 + return err 8458 + } 8459 + 8460 + cw := cbg.NewCborWriter(w) 8461 + fieldCount := 7 8462 + 8463 + if t.Mentions == nil { 8464 + fieldCount-- 8465 + } 8466 + 8467 + if t.References == nil { 8468 + fieldCount-- 8469 + } 8470 + 8471 + if t.ReplyTo == nil { 8472 + fieldCount-- 8473 + } 8474 + 8475 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 8476 + return err 8477 + } 8478 + 8479 + // t.Body (string) (string) 8480 + if len("body") > 1000000 { 8481 + return xerrors.Errorf("Value in field \"body\" was too long") 8482 + } 8483 + 8484 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 8485 + return err 8486 + } 8487 + if _, err := cw.WriteString(string("body")); err != nil { 8488 + return err 8489 + } 8490 + 8491 + if len(t.Body) > 1000000 { 8492 + return xerrors.Errorf("Value in field t.Body was too long") 8493 + } 8494 + 8495 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 8496 + return err 8497 + } 8498 + if _, err := cw.WriteString(string(t.Body)); err != nil { 8499 + return err 8500 + } 8501 + 8502 + // t.LexiconTypeID (string) (string) 8503 + if len("$type") > 1000000 { 8504 + return xerrors.Errorf("Value in field \"$type\" was too long") 8505 + } 8506 + 8507 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8508 + return err 8509 + } 8510 + if _, err := cw.WriteString(string("$type")); err != nil { 8511 + return err 8512 + } 8513 + 8514 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.discussion.comment"))); err != nil { 8515 + return err 8516 + } 8517 + if _, err := cw.WriteString(string("sh.tangled.repo.discussion.comment")); err != nil { 8518 + return err 8519 + } 8520 + 8521 + // t.ReplyTo (string) (string) 8522 + if t.ReplyTo != nil { 8523 + 8524 + if len("replyTo") > 1000000 { 8525 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 8526 + } 8527 + 8528 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 8529 + return err 8530 + } 8531 + if _, err := cw.WriteString(string("replyTo")); err != nil { 8532 + return err 8533 + } 8534 + 8535 + if t.ReplyTo == nil { 8536 + if _, err := cw.Write(cbg.CborNull); err != nil { 8537 + return err 8538 + } 8539 + } else { 8540 + if len(*t.ReplyTo) > 1000000 { 8541 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 8542 + } 8543 + 8544 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 8545 + return err 8546 + } 8547 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 8548 + return err 8549 + } 8550 + } 8551 + } 8552 + 8553 + // t.Mentions ([]string) (slice) 8554 + if t.Mentions != nil { 8555 + 8556 + if len("mentions") > 1000000 { 8557 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8558 + } 8559 + 8560 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8561 + return err 8562 + } 8563 + if _, err := cw.WriteString(string("mentions")); err != nil { 8564 + return err 8565 + } 8566 + 8567 + if len(t.Mentions) > 8192 { 8568 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8569 + } 8570 + 8571 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8572 + return err 8573 + } 8574 + for _, v := range t.Mentions { 8575 + if len(v) > 1000000 { 8576 + return xerrors.Errorf("Value in field v was too long") 8577 + } 8578 + 8579 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8580 + return err 8581 + } 8582 + if _, err := cw.WriteString(string(v)); err != nil { 8583 + return err 8584 + } 8585 + 8586 + } 8587 + } 8588 + 8589 + // t.CreatedAt (string) (string) 8590 + if len("createdAt") > 1000000 { 8591 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8592 + } 8593 + 8594 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8595 + return err 8596 + } 8597 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8598 + return err 8599 + } 8600 + 8601 + if len(t.CreatedAt) > 1000000 { 8602 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8603 + } 8604 + 8605 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8606 + return err 8607 + } 8608 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8609 + return err 8610 + } 8611 + 8612 + // t.Discussion (string) (string) 8613 + if len("discussion") > 1000000 { 8614 + return xerrors.Errorf("Value in field \"discussion\" was too long") 8615 + } 8616 + 8617 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("discussion"))); err != nil { 8618 + return err 8619 + } 8620 + if _, err := cw.WriteString(string("discussion")); err != nil { 8621 + return err 8622 + } 8623 + 8624 + if len(t.Discussion) > 1000000 { 8625 + return xerrors.Errorf("Value in field t.Discussion was too long") 8626 + } 8627 + 8628 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Discussion))); err != nil { 8629 + return err 8630 + } 8631 + if _, err := cw.WriteString(string(t.Discussion)); err != nil { 8632 + return err 8633 + } 8634 + 8635 + // t.References ([]string) (slice) 8636 + if t.References != nil { 8637 + 8638 + if len("references") > 1000000 { 8639 + return xerrors.Errorf("Value in field \"references\" was too long") 8640 + } 8641 + 8642 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8643 + return err 8644 + } 8645 + if _, err := cw.WriteString(string("references")); err != nil { 8646 + return err 8647 + } 8648 + 8649 + if len(t.References) > 8192 { 8650 + return xerrors.Errorf("Slice value in field t.References was too long") 8651 + } 8652 + 8653 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8654 + return err 8655 + } 8656 + for _, v := range t.References { 8657 + if len(v) > 1000000 { 8658 + return xerrors.Errorf("Value in field v was too long") 8659 + } 8660 + 8661 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8662 + return err 8663 + } 8664 + if _, err := cw.WriteString(string(v)); err != nil { 8665 + return err 8666 + } 8667 + 8668 + } 8669 + } 8670 + return nil 8671 + } 8672 + 8673 + func (t *RepoDiscussionComment) UnmarshalCBOR(r io.Reader) (err error) { 8674 + *t = RepoDiscussionComment{} 8675 + 8676 + cr := cbg.NewCborReader(r) 8677 + 8678 + maj, extra, err := cr.ReadHeader() 8679 + if err != nil { 8680 + return err 8681 + } 8682 + defer func() { 8683 + if err == io.EOF { 8684 + err = io.ErrUnexpectedEOF 8685 + } 8686 + }() 8687 + 8688 + if maj != cbg.MajMap { 8689 + return fmt.Errorf("cbor input should be of type map") 8690 + } 8691 + 8692 + if extra > cbg.MaxLength { 8693 + return fmt.Errorf("RepoDiscussionComment: map struct too large (%d)", extra) 8694 + } 8695 + 8696 + n := extra 8697 + 8698 + nameBuf := make([]byte, 10) 8699 + for i := uint64(0); i < n; i++ { 8700 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8701 + if err != nil { 8702 + return err 8703 + } 8704 + 8705 + if !ok { 8706 + // Field doesn't exist on this type, so ignore it 8707 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8708 + return err 8709 + } 8710 + continue 8711 + } 8712 + 8713 + switch string(nameBuf[:nameLen]) { 8714 + // t.Body (string) (string) 8715 + case "body": 8716 + 8717 + { 8718 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8719 + if err != nil { 8720 + return err 8721 + } 8722 + 8723 + t.Body = string(sval) 8724 + } 8725 + // t.LexiconTypeID (string) (string) 8726 + case "$type": 8727 + 8728 + { 8729 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8730 + if err != nil { 8731 + return err 8732 + } 8733 + 8734 + t.LexiconTypeID = string(sval) 8735 + } 8736 + // t.ReplyTo (string) (string) 8737 + case "replyTo": 8738 + 8739 + { 8740 + b, err := cr.ReadByte() 8741 + if err != nil { 8742 + return err 8743 + } 8744 + if b != cbg.CborNull[0] { 8745 + if err := cr.UnreadByte(); err != nil { 8746 + return err 8747 + } 8748 + 8749 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8750 + if err != nil { 8751 + return err 8752 + } 8753 + 8754 + t.ReplyTo = (*string)(&sval) 8755 + } 8756 + } 8757 + // t.Mentions ([]string) (slice) 8758 + case "mentions": 8759 + 8760 + maj, extra, err = cr.ReadHeader() 8761 + if err != nil { 8762 + return err 8763 + } 8764 + 8765 + if extra > 8192 { 8766 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8767 + } 8768 + 8769 + if maj != cbg.MajArray { 8770 + return fmt.Errorf("expected cbor array") 8771 + } 8772 + 8773 + if extra > 0 { 8774 + t.Mentions = make([]string, extra) 8775 + } 8776 + 8777 + for i := 0; i < int(extra); i++ { 8778 + { 8779 + var maj byte 8780 + var extra uint64 8781 + var err error 8782 + _ = maj 8783 + _ = extra 8784 + _ = err 8785 + 8786 + { 8787 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8788 + if err != nil { 8789 + return err 8790 + } 8791 + 8792 + t.Mentions[i] = string(sval) 8793 + } 8794 + 8795 + } 8796 + } 8797 + // t.CreatedAt (string) (string) 8798 + case "createdAt": 8799 + 8800 + { 8801 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8802 + if err != nil { 8803 + return err 8804 + } 8805 + 8806 + t.CreatedAt = string(sval) 8807 + } 8808 + // t.Discussion (string) (string) 8809 + case "discussion": 8810 + 8811 + { 8812 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8813 + if err != nil { 8814 + return err 8815 + } 8816 + 8817 + t.Discussion = string(sval) 8818 + } 8819 + // t.References ([]string) (slice) 8820 + case "references": 8821 + 8822 + maj, extra, err = cr.ReadHeader() 8823 + if err != nil { 8824 + return err 8825 + } 8826 + 8827 + if extra > 8192 { 8828 + return fmt.Errorf("t.References: array too large (%d)", extra) 8829 + } 8830 + 8831 + if maj != cbg.MajArray { 8832 + return fmt.Errorf("expected cbor array") 8833 + } 8834 + 8835 + if extra > 0 { 8836 + t.References = make([]string, extra) 8837 + } 8838 + 8839 + for i := 0; i < int(extra); i++ { 8840 + { 8841 + var maj byte 8842 + var extra uint64 8843 + var err error 8844 + _ = maj 8845 + _ = extra 8846 + _ = err 8847 + 8848 + { 8849 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8850 + if err != nil { 8851 + return err 8852 + } 8853 + 8854 + t.References[i] = string(sval) 8855 + } 8856 + 8857 + } 8858 + } 8859 + 8860 + default: 8861 + // Field doesn't exist on this type, so ignore it 8862 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8863 + return err 8864 + } 8865 + } 8866 + } 8867 + 8868 + return nil 8869 + } 8870 + func (t *RepoDiscussionState) MarshalCBOR(w io.Writer) error { 8871 + if t == nil { 8872 + _, err := w.Write(cbg.CborNull) 8873 + return err 8874 + } 8875 + 8876 + cw := cbg.NewCborWriter(w) 8877 + 8878 + if _, err := cw.Write([]byte{164}); err != nil { 8879 + return err 8880 + } 8881 + 8882 + // t.LexiconTypeID (string) (string) 8883 + if len("$type") > 1000000 { 8884 + return xerrors.Errorf("Value in field \"$type\" was too long") 8885 + } 8886 + 8887 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8888 + return err 8889 + } 8890 + if _, err := cw.WriteString(string("$type")); err != nil { 8891 + return err 8892 + } 8893 + 8894 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.discussion.state"))); err != nil { 8895 + return err 8896 + } 8897 + if _, err := cw.WriteString(string("sh.tangled.repo.discussion.state")); err != nil { 8898 + return err 8899 + } 8900 + 8901 + // t.State (string) (string) 8902 + if len("state") > 1000000 { 8903 + return xerrors.Errorf("Value in field \"state\" was too long") 8904 + } 8905 + 8906 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("state"))); err != nil { 8907 + return err 8908 + } 8909 + if _, err := cw.WriteString(string("state")); err != nil { 8910 + return err 8911 + } 8912 + 8913 + if len(t.State) > 1000000 { 8914 + return xerrors.Errorf("Value in field t.State was too long") 8915 + } 8916 + 8917 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.State))); err != nil { 8918 + return err 8919 + } 8920 + if _, err := cw.WriteString(string(t.State)); err != nil { 8921 + return err 8922 + } 8923 + 8924 + // t.CreatedAt (string) (string) 8925 + if len("createdAt") > 1000000 { 8926 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8927 + } 8928 + 8929 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8930 + return err 8931 + } 8932 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8933 + return err 8934 + } 8935 + 8936 + if len(t.CreatedAt) > 1000000 { 8937 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8938 + } 8939 + 8940 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8941 + return err 8942 + } 8943 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8944 + return err 8945 + } 8946 + 8947 + // t.Discussion (string) (string) 8948 + if len("discussion") > 1000000 { 8949 + return xerrors.Errorf("Value in field \"discussion\" was too long") 8950 + } 8951 + 8952 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("discussion"))); err != nil { 8953 + return err 8954 + } 8955 + if _, err := cw.WriteString(string("discussion")); err != nil { 8956 + return err 8957 + } 8958 + 8959 + if len(t.Discussion) > 1000000 { 8960 + return xerrors.Errorf("Value in field t.Discussion was too long") 8961 + } 8962 + 8963 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Discussion))); err != nil { 8964 + return err 8965 + } 8966 + if _, err := cw.WriteString(string(t.Discussion)); err != nil { 8967 + return err 8968 + } 8969 + return nil 8970 + } 8971 + 8972 + func (t *RepoDiscussionState) UnmarshalCBOR(r io.Reader) (err error) { 8973 + *t = RepoDiscussionState{} 8974 + 8975 + cr := cbg.NewCborReader(r) 8976 + 8977 + maj, extra, err := cr.ReadHeader() 8978 + if err != nil { 8979 + return err 8980 + } 8981 + defer func() { 8982 + if err == io.EOF { 8983 + err = io.ErrUnexpectedEOF 8984 + } 8985 + }() 8986 + 8987 + if maj != cbg.MajMap { 8988 + return fmt.Errorf("cbor input should be of type map") 8989 + } 8990 + 8991 + if extra > cbg.MaxLength { 8992 + return fmt.Errorf("RepoDiscussionState: map struct too large (%d)", extra) 8993 + } 8994 + 8995 + n := extra 8996 + 8997 + nameBuf := make([]byte, 10) 8998 + for i := uint64(0); i < n; i++ { 8999 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 9000 + if err != nil { 9001 + return err 9002 + } 9003 + 9004 + if !ok { 9005 + // Field doesn't exist on this type, so ignore it 9006 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 9007 + return err 9008 + } 9009 + continue 9010 + } 9011 + 9012 + switch string(nameBuf[:nameLen]) { 9013 + // t.LexiconTypeID (string) (string) 9014 + case "$type": 9015 + 9016 + { 9017 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 9018 + if err != nil { 9019 + return err 9020 + } 9021 + 9022 + t.LexiconTypeID = string(sval) 9023 + } 9024 + // t.State (string) (string) 9025 + case "state": 9026 + 9027 + { 9028 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 9029 + if err != nil { 9030 + return err 9031 + } 9032 + 9033 + t.State = string(sval) 9034 + } 9035 + // t.CreatedAt (string) (string) 9036 + case "createdAt": 9037 + 9038 + { 9039 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 9040 + if err != nil { 9041 + return err 9042 + } 9043 + 9044 + t.CreatedAt = string(sval) 9045 + } 9046 + // t.Discussion (string) (string) 9047 + case "discussion": 9048 + 9049 + { 9050 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 9051 + if err != nil { 9052 + return err 9053 + } 9054 + 9055 + t.Discussion = string(sval) 7563 9056 } 7564 9057 7565 9058 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 + }
+81
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: Base32 change hashes pushed in this operation, in application order 28 + Changes []string `json:"changes" cborgen:"changes"` 29 + // channel: Channel that was updated 30 + Channel string `json:"channel" cborgen:"channel"` 31 + // committerDid: DID of the pusher 32 + CommitterDid string `json:"committerDid" cborgen:"committerDid"` 33 + // languages: Optional map of language name to line count 34 + Languages *PijulRefUpdate_Languages `json:"languages,omitempty" cborgen:"languages,omitempty"` 35 + // newState: Pijul Merkle state hash after push 36 + NewState string `json:"newState" cborgen:"newState"` 37 + // oldState: Pijul Merkle state hash before push (empty for new channels) 38 + OldState *string `json:"oldState,omitempty" cborgen:"oldState,omitempty"` 39 + // repo: AT URI of the repository 40 + Repo string `json:"repo" cborgen:"repo"` 41 + } 42 + 43 + // Map of language name to lines of code 44 + type PijulRefUpdate_Languages struct { 45 + } 46 + 47 + func (t *PijulRefUpdate_Languages) MarshalCBOR(w io.Writer) error { 48 + if t == nil { 49 + _, err := w.Write(cbg.CborNull) 50 + return err 51 + } 52 + cw := cbg.NewCborWriter(w) 53 + if err := cw.WriteMajorTypeHeader(cbg.MajMap, 0); err != nil { 54 + return err 55 + } 56 + return nil 57 + } 58 + 59 + func (t *PijulRefUpdate_Languages) UnmarshalCBOR(r io.Reader) error { 60 + *t = PijulRefUpdate_Languages{} 61 + cr := cbg.NewCborReader(r) 62 + maj, extra, err := cr.ReadHeader() 63 + if err != nil { 64 + return err 65 + } 66 + if maj != cbg.MajMap { 67 + return fmt.Errorf("expected cbor map for PijulRefUpdate_Languages") 68 + } 69 + // skip all entries 70 + for i := uint64(0); i < extra; i++ { 71 + // skip key 72 + if err := cbg.ScanForLinks(cr, func(_ cid.Cid) {}); err != nil { 73 + return err 74 + } 75 + // skip value 76 + if err := cbg.ScanForLinks(cr, func(_ cid.Cid) {}); err != nil { 77 + return err 78 + } 79 + } 80 + return nil 81 + }
+50
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 + // newState: Pijul channel Merkle state hash after applying changes. Empty string if unavailable. 34 + NewState string `json:"newState,omitempty" cborgen:"newState,omitempty"` 35 + } 36 + 37 + type RepoApplyChanges_Output_Failed_Elem struct { 38 + Error string `json:"error" cborgen:"error"` 39 + Hash string `json:"hash" cborgen:"hash"` 40 + } 41 + 42 + // RepoApplyChanges calls the XRPC method "sh.tangled.repo.applyChanges". 43 + func RepoApplyChanges(ctx context.Context, c util.LexClient, input *RepoApplyChanges_Input) (*RepoApplyChanges_Output, error) { 44 + var out RepoApplyChanges_Output 45 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.applyChanges", nil, input, &out); err != nil { 46 + return nil, err 47 + } 48 + 49 + return &out, nil 50 + }
+54
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 + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` 20 + Email *string `json:"email,omitempty" cborgen:"email,omitempty"` 21 + Name string `json:"name" cborgen:"name"` 22 + } 23 + 24 + // RepoChangeGet_Output is the output of a sh.tangled.repo.changeGet call. 25 + type RepoChangeGet_Output struct { 26 + Authors []*RepoChangeGet_Author `json:"authors" cborgen:"authors"` 27 + // dependencies: Hashes of changes this change depends on 28 + Dependencies []string `json:"dependencies,omitempty" cborgen:"dependencies,omitempty"` 29 + // diff: Raw diff content of the change 30 + Diff *string `json:"diff,omitempty" cborgen:"diff,omitempty"` 31 + // hash: Change hash (base32 encoded) 32 + Hash string `json:"hash" cborgen:"hash"` 33 + // message: Change description 34 + Message string `json:"message" cborgen:"message"` 35 + // timestamp: When the change was recorded 36 + Timestamp *string `json:"timestamp,omitempty" cborgen:"timestamp,omitempty"` 37 + } 38 + 39 + // RepoChangeGet calls the XRPC method "sh.tangled.repo.changeGet". 40 + // 41 + // hash: Change hash to retrieve 42 + // repo: Repository identifier in format 'did:plc:.../repoName' 43 + func RepoChangeGet(ctx context.Context, c util.LexClient, hash string, repo string) (*RepoChangeGet_Output, error) { 44 + var out RepoChangeGet_Output 45 + 46 + params := map[string]interface{}{} 47 + params["hash"] = hash 48 + params["repo"] = repo 49 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.changeGet", params, nil, &out); err != nil { 50 + return nil, err 51 + } 52 + 53 + return &out, nil 54 + }
+71
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 + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` 20 + Email *string `json:"email,omitempty" cborgen:"email,omitempty"` 21 + Name string `json:"name" cborgen:"name"` 22 + } 23 + 24 + // RepoChangeList_ChangeEntry is a "changeEntry" in the sh.tangled.repo.changeList schema. 25 + type RepoChangeList_ChangeEntry struct { 26 + Authors []*RepoChangeList_Author `json:"authors" cborgen:"authors"` 27 + // dependencies: Hashes of changes this change depends on 28 + Dependencies []string `json:"dependencies,omitempty" cborgen:"dependencies,omitempty"` 29 + // hash: Change hash (base32 encoded) 30 + Hash string `json:"hash" cborgen:"hash"` 31 + // message: Change description 32 + Message string `json:"message" cborgen:"message"` 33 + // timestamp: When the change was recorded 34 + Timestamp *string `json:"timestamp,omitempty" cborgen:"timestamp,omitempty"` 35 + } 36 + 37 + // RepoChangeList_Output is the output of a sh.tangled.repo.changeList call. 38 + type RepoChangeList_Output struct { 39 + Changes []*RepoChangeList_ChangeEntry `json:"changes" cborgen:"changes"` 40 + Channel *string `json:"channel,omitempty" cborgen:"channel,omitempty"` 41 + Page int64 `json:"page" cborgen:"page"` 42 + Per_page int64 `json:"per_page" cborgen:"per_page"` 43 + Total int64 `json:"total" cborgen:"total"` 44 + } 45 + 46 + // RepoChangeList calls the XRPC method "sh.tangled.repo.changeList". 47 + // 48 + // channel: Pijul channel name (defaults to main channel) 49 + // cursor: Pagination cursor (offset) 50 + // limit: Maximum number of changes to return 51 + // repo: Repository identifier in format 'did:plc:.../repoName' 52 + func RepoChangeList(ctx context.Context, c util.LexClient, channel string, cursor string, limit int64, repo string) (*RepoChangeList_Output, error) { 53 + var out RepoChangeList_Output 54 + 55 + params := map[string]interface{}{} 56 + if channel != "" { 57 + params["channel"] = channel 58 + } 59 + if cursor != "" { 60 + params["cursor"] = cursor 61 + } 62 + if limit != 0 { 63 + params["limit"] = limit 64 + } 65 + params["repo"] = repo 66 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.changeList", params, nil, &out); err != nil { 67 + return nil, err 68 + } 69 + 70 + return &out, nil 71 + }
+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
··· 26 26 Rkey string `json:"rkey" cborgen:"rkey"` 27 27 // source: A source URL to clone from, populate this when forking or importing a repository. 28 28 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 29 + // vcs: Version control system to use for the repository (git or pijul). 30 + Vcs *string `json:"vcs,omitempty" cborgen:"vcs,omitempty"` 29 31 } 30 32 31 33 // RepoCreate_Output is the output of a sh.tangled.repo.create call.
+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 + }
+46
api/tangled/repounrecordChanges.go
··· 1 + package tangled 2 + 3 + // schema: sh.tangled.repo.unrecordChanges 4 + 5 + import ( 6 + "context" 7 + 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoUnrecordChangesNSID = "sh.tangled.repo.unrecordChanges" 13 + ) 14 + 15 + // RepoUnrecordChanges_Input is the input argument to a sh.tangled.repo.unrecordChanges call. 16 + type RepoUnrecordChanges_Input struct { 17 + // changes: List of change hashes to unrecord 18 + Changes []string `json:"changes" cborgen:"changes"` 19 + // channel: Target channel to unrecord changes from (optional) 20 + Channel string `json:"channel,omitempty" cborgen:"channel,omitempty"` 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + Repo string `json:"repo" cborgen:"repo"` 23 + } 24 + 25 + // RepoUnrecordChanges_Output is the output of a sh.tangled.repo.unrecordChanges call. 26 + type RepoUnrecordChanges_Output struct { 27 + // unrecorded: List of successfully unrecorded change hashes 28 + Unrecorded []string `json:"unrecorded" cborgen:"unrecorded"` 29 + // failed: List of changes that failed to unrecord 30 + Failed []*RepoUnrecordChanges_Output_Failed_Elem `json:"failed,omitempty" cborgen:"failed,omitempty"` 31 + } 32 + 33 + type RepoUnrecordChanges_Output_Failed_Elem struct { 34 + Error string `json:"error" cborgen:"error"` 35 + Hash string `json:"hash" cborgen:"hash"` 36 + } 37 + 38 + // RepoUnrecordChanges calls the XRPC method "sh.tangled.repo.unrecordChanges". 39 + func RepoUnrecordChanges(ctx context.Context, c util.LexClient, input *RepoUnrecordChanges_Input) (*RepoUnrecordChanges_Output, error) { 40 + var out RepoUnrecordChanges_Output 41 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.unrecordChanges", nil, input, &out); err != nil { 42 + return nil, err 43 + } 44 + 45 + return &out, nil 46 + }
+147
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) { ··· 76 90 rkey text not null, 77 91 at_uri text not null unique, 78 92 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 93 + vcs text not null default 'git' check (vcs in ('git', 'pijul')), 79 94 unique(did, name, knot, rkey) 80 95 ); 81 96 create table if not exists collaborators ( ··· 1258 1273 }) 1259 1274 1260 1275 orm.RunMigration(conn, logger, "add-avatar-to-profile", func(tx *sql.Tx) error { 1276 + colExists, colErr := columnExists(tx, "profile", "avatar") 1277 + if colErr != nil { 1278 + return colErr 1279 + } 1280 + if colExists { 1281 + return nil 1282 + } 1261 1283 _, err := tx.Exec(` 1262 1284 alter table profile add column avatar text; 1263 1285 `) ··· 1288 1310 return err 1289 1311 }) 1290 1312 1313 + // Add vcs column to repos table for pijul support 1314 + orm.RunMigration(conn, logger, "add-vcs-to-repos", func(tx *sql.Tx) error { 1315 + colExists, colErr := columnExists(tx, "repos", "vcs") 1316 + if colErr != nil { 1317 + return colErr 1318 + } 1319 + if colExists { 1320 + return nil 1321 + } 1322 + _, err := tx.Exec(` 1323 + alter table repos add column vcs text not null default 'git' check (vcs in ('git', 'pijul')); 1324 + `) 1325 + return err 1326 + }) 1327 + 1291 1328 orm.RunMigration(conn, logger, "add-preferred-handle-profile", func(tx *sql.Tx) error { 1292 1329 _, err := tx.Exec(` 1293 1330 alter table profile add column preferred_handle text; ··· 1321 1358 return err 1322 1359 }) 1323 1360 1361 + // Create discussions tables for Pijul repos 1362 + orm.RunMigration(conn, logger, "create-discussions-tables", func(tx *sql.Tx) error { 1363 + _, err := tx.Exec(` 1364 + -- Discussions: unified discussion model for Pijul repos 1365 + create table if not exists discussions ( 1366 + -- identifiers 1367 + id integer primary key autoincrement, 1368 + did text not null, 1369 + rkey text not null, 1370 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.discussion' || '/' || rkey) stored, 1371 + 1372 + -- repo reference 1373 + repo_at text not null, 1374 + 1375 + -- sequential ID per repo 1376 + discussion_id integer not null, 1377 + 1378 + -- content 1379 + title text not null, 1380 + body text not null, 1381 + target_channel text not null default 'main', 1382 + 1383 + -- state: 0=closed, 1=open, 2=merged 1384 + state integer not null default 1 check (state in (0, 1, 2)), 1385 + 1386 + -- meta 1387 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1388 + edited text, 1389 + 1390 + -- constraints 1391 + unique(did, rkey), 1392 + unique(repo_at, discussion_id), 1393 + unique(at_uri), 1394 + foreign key (repo_at) references repos(at_uri) on delete cascade 1395 + ); 1396 + 1397 + -- Discussion patches: anyone can add patches to a discussion 1398 + create table if not exists discussion_patches ( 1399 + -- identifiers 1400 + id integer primary key autoincrement, 1401 + discussion_at text not null, 1402 + 1403 + -- who pushed this patch 1404 + pushed_by_did text not null, 1405 + 1406 + -- patch content 1407 + patch_hash text not null, 1408 + patch text not null, 1409 + 1410 + -- meta 1411 + added text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1412 + removed text, -- NULL means active, timestamp means removed 1413 + 1414 + -- constraints 1415 + unique(discussion_at, patch_hash), 1416 + foreign key (discussion_at) references discussions(at_uri) on delete cascade 1417 + ); 1418 + 1419 + -- Discussion comments 1420 + create table if not exists discussion_comments ( 1421 + -- identifiers 1422 + id integer primary key autoincrement, 1423 + did text not null, 1424 + rkey text, 1425 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.discussion.comment' || '/' || rkey) stored, 1426 + 1427 + -- parent references 1428 + discussion_at text not null, 1429 + reply_to text, -- at_uri of parent comment for threading 1430 + 1431 + -- content 1432 + body text not null, 1433 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1434 + edited text, 1435 + deleted text, 1436 + 1437 + -- constraints 1438 + unique(did, rkey), 1439 + unique(at_uri), 1440 + foreign key (discussion_at) references discussions(at_uri) on delete cascade 1441 + ); 1442 + 1443 + -- Discussion subscriptions: who is watching a discussion 1444 + create table if not exists discussion_subscriptions ( 1445 + id integer primary key autoincrement, 1446 + discussion_at text not null, 1447 + subscriber_did text not null, 1448 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1449 + 1450 + unique(discussion_at, subscriber_did), 1451 + foreign key (discussion_at) references discussions(at_uri) on delete cascade 1452 + ); 1453 + 1454 + -- Sequential ID generator for discussions per repo 1455 + create table if not exists repo_discussion_seqs ( 1456 + repo_at text primary key, 1457 + next_discussion_id integer not null default 1 1458 + ); 1459 + 1460 + -- indexes for discussions 1461 + create index if not exists idx_discussions_repo_at on discussions(repo_at); 1462 + create index if not exists idx_discussions_state on discussions(state); 1463 + create index if not exists idx_discussion_patches_discussion_at on discussion_patches(discussion_at); 1464 + create index if not exists idx_discussion_patches_pushed_by on discussion_patches(pushed_by_did); 1465 + create index if not exists idx_discussion_comments_discussion_at on discussion_comments(discussion_at); 1466 + `) 1467 + return err 1468 + }) 1469 + 1324 1470 orm.RunMigration(conn, logger, "add-pipelines-repo-did", func(tx *sql.Tx) error { 1325 1471 _, err := tx.Exec(` 1326 1472 alter table pipelines add column repo_did text; ··· 1333 1479 _, err := tx.Exec(`update registrations set needs_upgrade = 1`) 1334 1480 return err 1335 1481 }) 1482 + 1336 1483 1337 1484 return &DB{ 1338 1485 db,
+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, 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 + }
+26 -8
appview/db/repos.go
··· 51 51 topics, 52 52 source, 53 53 spindle, 54 - repo_did 55 - from repos 54 + repo_did, 55 + vcs 56 + from 57 + repos 56 58 %s 57 59 order by created desc 58 60 %s ··· 68 70 for rows.Next() { 69 71 var repo models.Repo 70 72 var createdAt string 71 - var description, website, topicStr, source, spindle, repoDid sql.NullString 73 + var description, website, topicStr, source, spindle, repoDid, vcs sql.NullString 72 74 73 75 err := rows.Scan( 74 76 &repo.Id, ··· 83 85 &source, 84 86 &spindle, 85 87 &repoDid, 88 + &vcs, 86 89 ) 87 90 if err != nil { 88 91 return nil, err ··· 111 114 } 112 115 if repoDid.Valid { 113 116 repo.RepoDid = repoDid.String 117 + } 118 + if vcs.Valid { 119 + repo.Vcs = vcs.String 120 + } else { 121 + repo.Vcs = "git" // default 114 122 } 115 123 116 124 repo.RepoStats = &models.RepoStats{} ··· 365 373 var nullableRepoDid sql.NullString 366 374 var nullableSource sql.NullString 367 375 var nullableSpindle sql.NullString 376 + var nullableVcs sql.NullString 368 377 369 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics, source, spindle, repo_did from repos where at_uri = ?`, atUri) 378 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics, source, spindle, repo_did, vcs from repos where at_uri = ?`, atUri) 370 379 371 380 var createdAt string 372 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &nullableSource, &nullableSpindle, &nullableRepoDid); err != nil { 381 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &nullableSource, &nullableSpindle, &nullableRepoDid, &nullableVcs); err != nil { 373 382 return nil, err 374 383 } 375 384 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 392 401 } 393 402 if nullableRepoDid.Valid { 394 403 repo.RepoDid = nullableRepoDid.String 404 + } 405 + if nullableVcs.Valid { 406 + repo.Vcs = nullableVcs.String 407 + } else { 408 + repo.Vcs = "git" 395 409 } 396 410 397 411 return &repo, nil ··· 417 431 if repo.RepoDid != "" { 418 432 repoDid = &repo.RepoDid 419 433 } 434 + vcs := repo.Vcs 435 + if vcs == "" { 436 + vcs = "git" 437 + } 420 438 _, err := tx.Exec( 421 439 `insert into repos 422 - (did, name, knot, rkey, at_uri, description, website, topics, source, repo_did) 423 - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 424 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, repoDid, 440 + (did, name, knot, rkey, at_uri, description, website, topics, source, repo_did, vcs) 441 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 442 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, repoDid, vcs, 425 443 ) 426 444 if err != nil { 427 445 return fmt.Errorf("failed to insert repo: %w", err)
+800
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 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + "github.com/go-chi/chi/v5" 14 + lexutil "github.com/bluesky-social/indigo/lex/util" 15 + 16 + tangled "tangled.org/core/api/tangled" 17 + "tangled.org/core/appview/config" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/mentions" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/notify" 22 + "tangled.org/core/appview/oauth" 23 + "tangled.org/core/appview/pages" 24 + "tangled.org/core/appview/pages/repoinfo" 25 + "tangled.org/core/appview/pagination" 26 + "tangled.org/core/appview/reporesolver" 27 + "tangled.org/core/appview/validator" 28 + "tangled.org/core/idresolver" 29 + "tangled.org/core/orm" 30 + "tangled.org/core/rbac" 31 + "tangled.org/core/tid" 32 + ) 33 + 34 + // Discussions handles the discussions feature for Pijul repositories 35 + type Discussions struct { 36 + oauth *oauth.OAuth 37 + repoResolver *reporesolver.RepoResolver 38 + enforcer *rbac.Enforcer 39 + pages *pages.Pages 40 + idResolver *idresolver.Resolver 41 + mentionsResolver *mentions.Resolver 42 + db *db.DB 43 + config *config.Config 44 + notifier notify.Notifier 45 + logger *slog.Logger 46 + validator *validator.Validator 47 + } 48 + 49 + func New( 50 + oauth *oauth.OAuth, 51 + repoResolver *reporesolver.RepoResolver, 52 + enforcer *rbac.Enforcer, 53 + pages *pages.Pages, 54 + idResolver *idresolver.Resolver, 55 + mentionsResolver *mentions.Resolver, 56 + db *db.DB, 57 + config *config.Config, 58 + notifier notify.Notifier, 59 + validator *validator.Validator, 60 + logger *slog.Logger, 61 + ) *Discussions { 62 + return &Discussions{ 63 + oauth: oauth, 64 + repoResolver: repoResolver, 65 + enforcer: enforcer, 66 + pages: pages, 67 + idResolver: idResolver, 68 + mentionsResolver: mentionsResolver, 69 + db: db, 70 + config: config, 71 + notifier: notifier, 72 + logger: logger, 73 + validator: validator, 74 + } 75 + } 76 + 77 + // rolesFor returns the RolesInRepo for the given user in the repo described by repoInfo. 78 + // rolesFor returns the RolesInRepo for the given user in the repo described by repoInfo. 79 + func (d *Discussions) rolesFor(userDid string, ri repoinfo.RepoInfo) repoinfo.RolesInRepo { 80 + return repoinfo.RolesInRepo{ 81 + Roles: d.enforcer.GetPermissionsInRepo(userDid, ri.Knot, ri.OwnerDid+"/"+ri.Name), 82 + } 83 + } 84 + 85 + // RepoDiscussionsList shows all discussions for a Pijul repository 86 + func (d *Discussions) RepoDiscussionsList(w http.ResponseWriter, r *http.Request) { 87 + l := d.logger.With("handler", "RepoDiscussionsList") 88 + user := d.oauth.GetMultiAccountUser(r) 89 + 90 + repo, err := d.repoResolver.Resolve(r) 91 + if err != nil { 92 + l.Error("failed to get repo", "err", err) 93 + d.pages.Error404(w) 94 + return 95 + } 96 + 97 + // Only allow discussions for Pijul repos 98 + if !repo.IsPijul() { 99 + l.Info("discussions only available for pijul repos") 100 + d.pages.Error404(w) 101 + return 102 + } 103 + 104 + repoAt := repo.RepoAt() 105 + page := pagination.Page{Limit: 50} 106 + 107 + // Filter by state 108 + filter := r.URL.Query().Get("filter") 109 + filters := []orm.Filter{orm.FilterEq("repo_at", repoAt)} 110 + switch filter { 111 + case "closed": 112 + filters = append(filters, orm.FilterEq("state", models.DiscussionClosed)) 113 + case "merged": 114 + filters = append(filters, orm.FilterEq("state", models.DiscussionMerged)) 115 + default: 116 + // Default to open 117 + filters = append(filters, orm.FilterEq("state", models.DiscussionOpen)) 118 + filter = "open" 119 + } 120 + 121 + discussions, err := db.GetDiscussionsPaginated(d.db, page, filters...) 122 + if err != nil { 123 + l.Error("failed to fetch discussions", "err", err) 124 + d.pages.Error503(w) 125 + return 126 + } 127 + 128 + count, err := db.GetDiscussionCount(d.db, repoAt) 129 + if err != nil { 130 + l.Error("failed to get discussion count", "err", err) 131 + } 132 + 133 + d.pages.RepoDiscussionsList(w, pages.RepoDiscussionsListParams{ 134 + LoggedInUser: user, 135 + RepoInfo: d.repoResolver.GetRepoInfo(r, user), 136 + Discussions: discussions, 137 + Filter: filter, 138 + DiscussionCount: count, 139 + }) 140 + } 141 + 142 + // NewDiscussion creates a new discussion 143 + func (d *Discussions) NewDiscussion(w http.ResponseWriter, r *http.Request) { 144 + l := d.logger.With("handler", "NewDiscussion") 145 + user := d.oauth.GetMultiAccountUser(r) 146 + 147 + repo, err := d.repoResolver.Resolve(r) 148 + if err != nil { 149 + l.Error("failed to get repo", "err", err) 150 + d.pages.Error404(w) 151 + return 152 + } 153 + 154 + if !repo.IsPijul() { 155 + l.Info("discussions only available for pijul repos") 156 + d.pages.Error404(w) 157 + return 158 + } 159 + 160 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 161 + 162 + switch r.Method { 163 + case http.MethodGet: 164 + d.pages.NewDiscussion(w, pages.NewDiscussionParams{ 165 + LoggedInUser: user, 166 + RepoInfo: repoInfo, 167 + }) 168 + 169 + case http.MethodPost: 170 + noticeId := "discussion" 171 + 172 + title := r.FormValue("title") 173 + body := r.FormValue("body") 174 + targetChannel := r.FormValue("target_channel") 175 + if targetChannel == "" { 176 + targetChannel = "main" 177 + } 178 + 179 + if title == "" { 180 + d.pages.Notice(w, noticeId, "Title is required") 181 + return 182 + } 183 + 184 + discussion := &models.Discussion{ 185 + Did: user.Active.Did, 186 + Rkey: tid.TID(), 187 + RepoAt: repo.RepoAt(), 188 + Title: title, 189 + Body: body, 190 + TargetChannel: targetChannel, 191 + State: models.DiscussionOpen, 192 + Created: time.Now(), 193 + } 194 + 195 + tx, err := d.db.BeginTx(r.Context(), nil) 196 + if err != nil { 197 + l.Error("failed to begin transaction", "err", err) 198 + d.pages.Notice(w, noticeId, "Failed to create discussion") 199 + return 200 + } 201 + defer tx.Rollback() 202 + 203 + if err := db.NewDiscussion(tx, discussion); err != nil { 204 + l.Error("failed to create discussion", "err", err) 205 + d.pages.Notice(w, noticeId, "Failed to create discussion") 206 + return 207 + } 208 + 209 + if err := tx.Commit(); err != nil { 210 + l.Error("failed to commit transaction", "err", err) 211 + d.pages.Notice(w, noticeId, "Failed to create discussion") 212 + return 213 + } 214 + 215 + // Subscribe the creator to the discussion 216 + db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 217 + 218 + l.Info("discussion created", "discussion_id", discussion.DiscussionId) 219 + 220 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 221 + repo.Did, repo.Name, discussion.DiscussionId)) 222 + } 223 + } 224 + 225 + // RepoSingleDiscussion shows a single discussion 226 + func (d *Discussions) RepoSingleDiscussion(w http.ResponseWriter, r *http.Request) { 227 + l := d.logger.With("handler", "RepoSingleDiscussion") 228 + user := d.oauth.GetMultiAccountUser(r) 229 + 230 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 231 + if !ok { 232 + l.Error("failed to get discussion from context") 233 + d.pages.Error404(w) 234 + return 235 + } 236 + 237 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 238 + 239 + canManage := user != nil && d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() 240 + 241 + d.pages.RepoSingleDiscussion(w, pages.RepoSingleDiscussionParams{ 242 + LoggedInUser: user, 243 + RepoInfo: repoInfo, 244 + Discussion: discussion, 245 + CommentList: discussion.CommentList(), 246 + CanManage: canManage, 247 + ActivePatches: discussion.ActivePatches(), 248 + }) 249 + } 250 + 251 + // AddPatch allows anyone to add a patch to a discussion 252 + func (d *Discussions) AddPatch(w http.ResponseWriter, r *http.Request) { 253 + l := d.logger.With("handler", "AddPatch") 254 + user := d.oauth.GetMultiAccountUser(r) 255 + noticeId := "patch" 256 + 257 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 258 + if !ok { 259 + l.Error("failed to get discussion from context") 260 + d.pages.Notice(w, noticeId, "Discussion not found") 261 + return 262 + } 263 + 264 + if discussion.State != models.DiscussionOpen { 265 + d.pages.Notice(w, noticeId, "Cannot add patches to a closed or merged discussion") 266 + return 267 + } 268 + 269 + patchHash := r.FormValue("patch_hash") 270 + patch := r.FormValue("patch") 271 + 272 + if patchHash == "" || patch == "" { 273 + d.pages.Notice(w, noticeId, "Patch hash and content are required") 274 + return 275 + } 276 + 277 + // Check if patch already exists 278 + exists, err := db.PatchExists(d.db, discussion.AtUri(), patchHash) 279 + if err != nil { 280 + l.Error("failed to check patch existence", "err", err) 281 + d.pages.Notice(w, noticeId, "Failed to add patch") 282 + return 283 + } 284 + if exists { 285 + d.pages.Notice(w, noticeId, "This patch has already been added to the discussion") 286 + return 287 + } 288 + 289 + // Get repo info for verification and dependency checking 290 + repo, err := d.repoResolver.Resolve(r) 291 + if err != nil { 292 + l.Error("failed to resolve repo", "err", err) 293 + d.pages.Notice(w, noticeId, "Failed to add patch") 294 + return 295 + } 296 + 297 + repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 298 + 299 + // Verify the change exists in the Pijul repository 300 + change, err := d.getChangeFromKnot(r.Context(), repo.Knot, repoIdentifier, patchHash) 301 + if err != nil { 302 + l.Info("change verification failed", "hash", patchHash, "err", err) 303 + d.pages.Notice(w, noticeId, "Change not found in repository. Please ensure the change hash is correct and exists in the repo.") 304 + return 305 + } 306 + 307 + l.Debug("change verified", "hash", patchHash, "message", change.Message) 308 + 309 + // Check dependencies - ensure the patch doesn't depend on removed patches 310 + if err := d.canAddPatchWithChange(discussion, change); err != nil { 311 + l.Info("dependency check failed", "err", err) 312 + d.pages.Notice(w, noticeId, err.Error()) 313 + return 314 + } 315 + 316 + discussionPatch := &models.DiscussionPatch{ 317 + DiscussionAt: discussion.AtUri(), 318 + PushedByDid: user.Active.Did, 319 + PatchHash: patchHash, 320 + Patch: patch, 321 + Added: time.Now(), 322 + } 323 + 324 + tx, err := d.db.BeginTx(r.Context(), nil) 325 + if err != nil { 326 + l.Error("failed to begin transaction", "err", err) 327 + d.pages.Notice(w, noticeId, "Failed to add patch") 328 + return 329 + } 330 + defer tx.Rollback() 331 + 332 + if err := db.AddDiscussionPatch(tx, discussionPatch); err != nil { 333 + l.Error("failed to add patch", "err", err) 334 + d.pages.Notice(w, noticeId, "Failed to add patch") 335 + return 336 + } 337 + 338 + if err := tx.Commit(); err != nil { 339 + l.Error("failed to commit transaction", "err", err) 340 + d.pages.Notice(w, noticeId, "Failed to add patch") 341 + return 342 + } 343 + 344 + // Subscribe the patch contributor to the discussion 345 + db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 346 + 347 + l.Info("patch added", "patch_hash", patchHash, "pushed_by", user.Active.Did) 348 + 349 + // Reload the page to show the new patch 350 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 351 + repo.Did, repo.Name, discussion.DiscussionId)) 352 + } 353 + 354 + // RemovePatch removes a patch from a discussion (soft delete) 355 + func (d *Discussions) RemovePatch(w http.ResponseWriter, r *http.Request) { 356 + l := d.logger.With("handler", "RemovePatch") 357 + user := d.oauth.GetMultiAccountUser(r) 358 + noticeId := "patch" 359 + 360 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 361 + if !ok { 362 + l.Error("failed to get discussion from context") 363 + d.pages.Notice(w, noticeId, "Discussion not found") 364 + return 365 + } 366 + 367 + patchIdStr := chi.URLParam(r, "patchId") 368 + patchId, err := strconv.ParseInt(patchIdStr, 10, 64) 369 + if err != nil { 370 + d.pages.Notice(w, noticeId, "Invalid patch ID") 371 + return 372 + } 373 + 374 + patch, err := db.GetDiscussionPatch(d.db, patchId) 375 + if err != nil { 376 + l.Error("failed to get patch", "err", err) 377 + d.pages.Notice(w, noticeId, "Patch not found") 378 + return 379 + } 380 + 381 + // Check permission: patch pusher or repo collaborator 382 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 383 + if patch.PushedByDid != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 384 + d.pages.Notice(w, noticeId, "You don't have permission to remove this patch") 385 + return 386 + } 387 + 388 + // Get repo for dependency checking 389 + repo, err := d.repoResolver.Resolve(r) 390 + if err != nil { 391 + l.Error("failed to resolve repo", "err", err) 392 + d.pages.Notice(w, noticeId, "Failed to remove patch") 393 + return 394 + } 395 + 396 + // Check if other active patches depend on this one 397 + repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 398 + if err := d.canRemovePatch(r.Context(), discussion, repo.Knot, repoIdentifier, patch.PatchHash); err != nil { 399 + l.Info("dependency check failed", "err", err) 400 + d.pages.Notice(w, noticeId, err.Error()) 401 + return 402 + } 403 + 404 + if err := db.RemovePatch(d.db, patchId); err != nil { 405 + l.Error("failed to remove patch", "err", err) 406 + d.pages.Notice(w, noticeId, "Failed to remove patch") 407 + return 408 + } 409 + 410 + l.Info("patch removed", "patch_id", patchId) 411 + 412 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 413 + repo.Did, repo.Name, discussion.DiscussionId)) 414 + } 415 + 416 + // ReaddPatch re-adds a previously removed patch 417 + func (d *Discussions) ReaddPatch(w http.ResponseWriter, r *http.Request) { 418 + l := d.logger.With("handler", "ReaddPatch") 419 + user := d.oauth.GetMultiAccountUser(r) 420 + noticeId := "patch" 421 + 422 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 423 + if !ok { 424 + l.Error("failed to get discussion from context") 425 + d.pages.Notice(w, noticeId, "Discussion not found") 426 + return 427 + } 428 + 429 + patchIdStr := chi.URLParam(r, "patchId") 430 + patchId, err := strconv.ParseInt(patchIdStr, 10, 64) 431 + if err != nil { 432 + d.pages.Notice(w, noticeId, "Invalid patch ID") 433 + return 434 + } 435 + 436 + patch, err := db.GetDiscussionPatch(d.db, patchId) 437 + if err != nil { 438 + l.Error("failed to get patch", "err", err) 439 + d.pages.Notice(w, noticeId, "Patch not found") 440 + return 441 + } 442 + 443 + // Check permission 444 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 445 + if patch.PushedByDid != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 446 + d.pages.Notice(w, noticeId, "You don't have permission to re-add this patch") 447 + return 448 + } 449 + 450 + if err := db.ReaddPatch(d.db, patchId); err != nil { 451 + l.Error("failed to re-add patch", "err", err) 452 + d.pages.Notice(w, noticeId, "Failed to re-add patch") 453 + return 454 + } 455 + 456 + l.Info("patch re-added", "patch_id", patchId) 457 + 458 + repo, _ := d.repoResolver.Resolve(r) 459 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 460 + repo.Did, repo.Name, discussion.DiscussionId)) 461 + } 462 + 463 + // NewComment adds a comment to a discussion 464 + func (d *Discussions) NewComment(w http.ResponseWriter, r *http.Request) { 465 + l := d.logger.With("handler", "NewComment") 466 + user := d.oauth.GetMultiAccountUser(r) 467 + noticeId := "comment" 468 + 469 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 470 + if !ok { 471 + l.Error("failed to get discussion from context") 472 + d.pages.Notice(w, noticeId, "Discussion not found") 473 + return 474 + } 475 + 476 + body := r.FormValue("body") 477 + replyTo := r.FormValue("reply_to") 478 + 479 + if body == "" { 480 + d.pages.Notice(w, noticeId, "Comment body is required") 481 + return 482 + } 483 + 484 + comment := models.DiscussionComment{ 485 + Did: user.Active.Did, 486 + Rkey: tid.TID(), 487 + DiscussionAt: discussion.AtUri().String(), 488 + Body: body, 489 + Created: time.Now(), 490 + } 491 + 492 + if replyTo != "" { 493 + comment.ReplyTo = &replyTo 494 + } 495 + 496 + tx, err := d.db.BeginTx(r.Context(), nil) 497 + if err != nil { 498 + l.Error("failed to begin transaction", "err", err) 499 + d.pages.Notice(w, noticeId, "Failed to add comment") 500 + return 501 + } 502 + defer tx.Rollback() 503 + 504 + if _, err := db.AddDiscussionComment(tx, comment); err != nil { 505 + l.Error("failed to add comment", "err", err) 506 + d.pages.Notice(w, noticeId, "Failed to add comment") 507 + return 508 + } 509 + 510 + if err := tx.Commit(); err != nil { 511 + l.Error("failed to commit transaction", "err", err) 512 + d.pages.Notice(w, noticeId, "Failed to add comment") 513 + return 514 + } 515 + 516 + // Subscribe the commenter to the discussion 517 + db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 518 + 519 + l.Info("comment added", "discussion_id", discussion.DiscussionId) 520 + 521 + repo, _ := d.repoResolver.Resolve(r) 522 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 523 + repo.Did, repo.Name, discussion.DiscussionId)) 524 + } 525 + 526 + // CloseDiscussion closes a discussion 527 + func (d *Discussions) CloseDiscussion(w http.ResponseWriter, r *http.Request) { 528 + l := d.logger.With("handler", "CloseDiscussion") 529 + user := d.oauth.GetMultiAccountUser(r) 530 + noticeId := "discussion" 531 + 532 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 533 + if !ok { 534 + l.Error("failed to get discussion from context") 535 + d.pages.Notice(w, noticeId, "Discussion not found") 536 + return 537 + } 538 + 539 + // Check permission: discussion creator or repo manager 540 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 541 + if discussion.Did != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 542 + d.pages.Notice(w, noticeId, "You don't have permission to close this discussion") 543 + return 544 + } 545 + 546 + if err := db.CloseDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 547 + l.Error("failed to close discussion", "err", err) 548 + d.pages.Notice(w, noticeId, "Failed to close discussion") 549 + return 550 + } 551 + 552 + l.Info("discussion closed", "discussion_id", discussion.DiscussionId) 553 + 554 + repo, _ := d.repoResolver.Resolve(r) 555 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 556 + repo.Did, repo.Name, discussion.DiscussionId)) 557 + } 558 + 559 + // ReopenDiscussion reopens a discussion 560 + func (d *Discussions) ReopenDiscussion(w http.ResponseWriter, r *http.Request) { 561 + l := d.logger.With("handler", "ReopenDiscussion") 562 + user := d.oauth.GetMultiAccountUser(r) 563 + noticeId := "discussion" 564 + 565 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 566 + if !ok { 567 + l.Error("failed to get discussion from context") 568 + d.pages.Notice(w, noticeId, "Discussion not found") 569 + return 570 + } 571 + 572 + // Check permission: discussion creator or repo manager 573 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 574 + if discussion.Did != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 575 + d.pages.Notice(w, noticeId, "You don't have permission to reopen this discussion") 576 + return 577 + } 578 + 579 + if err := db.ReopenDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 580 + l.Error("failed to reopen discussion", "err", err) 581 + d.pages.Notice(w, noticeId, "Failed to reopen discussion") 582 + return 583 + } 584 + 585 + l.Info("discussion reopened", "discussion_id", discussion.DiscussionId) 586 + 587 + repo, _ := d.repoResolver.Resolve(r) 588 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 589 + repo.Did, repo.Name, discussion.DiscussionId)) 590 + } 591 + 592 + // MergeDiscussion applies patches and marks a discussion as merged 593 + func (d *Discussions) MergeDiscussion(w http.ResponseWriter, r *http.Request) { 594 + l := d.logger.With("handler", "MergeDiscussion") 595 + user := d.oauth.GetMultiAccountUser(r) 596 + noticeId := "discussion" 597 + 598 + discussion, ok := r.Context().Value("discussion").(*models.Discussion) 599 + if !ok { 600 + l.Error("failed to get discussion from context") 601 + d.pages.Notice(w, noticeId, "Discussion not found") 602 + return 603 + } 604 + 605 + // Only collaborators can merge 606 + repoInfo := d.repoResolver.GetRepoInfo(r, user) 607 + if !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 608 + d.pages.Notice(w, noticeId, "You don't have permission to merge this discussion") 609 + return 610 + } 611 + 612 + // Get all active patches to apply 613 + activePatches := discussion.ActivePatches() 614 + if len(activePatches) == 0 { 615 + d.pages.Notice(w, noticeId, "No patches to merge") 616 + return 617 + } 618 + 619 + // Get repo for API call 620 + repo, err := d.repoResolver.Resolve(r) 621 + if err != nil { 622 + l.Error("failed to resolve repo", "err", err) 623 + d.pages.Notice(w, noticeId, "Failed to merge discussion") 624 + return 625 + } 626 + 627 + // Apply patches via knotserver (needs authenticated client since endpoint requires service auth) 628 + xrpcc, err := d.oauth.ServiceClient( 629 + r, 630 + oauth.WithService(repo.Knot), 631 + oauth.WithLxm(tangled.RepoApplyChangesNSID), 632 + oauth.WithDev(d.config.Core.Dev), 633 + ) 634 + if err != nil { 635 + l.Error("failed to create service client", "err", err) 636 + d.pages.Notice(w, noticeId, "Failed to authenticate with knotserver") 637 + return 638 + } 639 + 640 + // Collect patch hashes in order 641 + changeHashes := make([]string, len(activePatches)) 642 + for i, patch := range activePatches { 643 + changeHashes[i] = patch.PatchHash 644 + } 645 + 646 + repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 647 + applyInput := &tangled.RepoApplyChanges_Input{ 648 + Repo: repoIdentifier, 649 + Channel: discussion.TargetChannel, 650 + Changes: changeHashes, 651 + } 652 + 653 + applyResult, err := tangled.RepoApplyChanges(r.Context(), xrpcc, applyInput) 654 + if err != nil { 655 + l.Error("failed to apply changes", "err", err) 656 + d.pages.Notice(w, noticeId, "Failed to apply patches: "+err.Error()) 657 + return 658 + } 659 + 660 + // Check if all patches were applied 661 + if len(applyResult.Failed) > 0 { 662 + failedHashes := make([]string, len(applyResult.Failed)) 663 + for i, f := range applyResult.Failed { 664 + failedHashes[i] = f.Hash[:12] 665 + } 666 + l.Warn("some patches failed to apply", "failed", failedHashes) 667 + d.pages.Notice(w, noticeId, fmt.Sprintf("Some patches failed to apply: %v", failedHashes)) 668 + return 669 + } 670 + 671 + l.Info("patches applied successfully", "count", len(applyResult.Applied)) 672 + 673 + // Publish sh.tangled.pijul.refUpdate to committer's PDS. 674 + // The appview has the user's active OAuth session here — publish directly 675 + // instead of routing through the event queue. 676 + // Non-fatal: log and continue if PDS write fails. 677 + go d.publishPijulRefUpdate(r.Context(), r, repo, discussion.TargetChannel, applyResult.Applied, applyResult.NewState, user.Did()) 678 + 679 + // Mark discussion as merged 680 + if err := db.MergeDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 681 + l.Error("failed to merge discussion", "err", err) 682 + d.pages.Notice(w, noticeId, "Failed to merge discussion") 683 + return 684 + } 685 + 686 + l.Info("discussion merged", "discussion_id", discussion.DiscussionId) 687 + 688 + repo, _ = d.repoResolver.Resolve(r) 689 + d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 690 + repo.Did, repo.Name, discussion.DiscussionId)) 691 + } 692 + 693 + // publishPijulRefUpdate publishes a sh.tangled.pijul.refUpdate record to the 694 + // committer's PDS. The appview has an active OAuth session at merge time, so we 695 + // publish directly without routing through the event queue. 696 + func (d *Discussions) publishPijulRefUpdate(ctx context.Context, r *http.Request, repo *models.Repo, channel string, appliedHashes []string, newState string, committerDid string) { 697 + l := d.logger.With("fn", "publishPijulRefUpdate", "committer", committerDid) 698 + 699 + atpClient, err := d.oauth.AuthorizedClient(r) 700 + if err != nil { 701 + l.Warn("no AT session, skipping PDS publish", "err", err) 702 + return 703 + } 704 + 705 + repoAt := fmt.Sprintf("at://%s/%s/%s", repo.Did, tangled.RepoNSID, repo.Name) 706 + record := &tangled.PijulRefUpdate{ 707 + Repo: repoAt, 708 + Channel: channel, 709 + Changes: appliedHashes, 710 + CommitterDid: committerDid, 711 + NewState: newState, 712 + } 713 + 714 + rkey := tid.TID() 715 + _, err = comatproto.RepoCreateRecord(ctx, atpClient, &comatproto.RepoCreateRecord_Input{ 716 + Collection: tangled.PijulRefUpdateNSID, 717 + Repo: committerDid, 718 + Rkey: &rkey, 719 + Record: &lexutil.LexiconTypeDecoder{Val: record}, 720 + }) 721 + if err != nil { 722 + l.Warn("failed to publish pijulRefUpdate to PDS", "err", err) 723 + return 724 + } 725 + 726 + l.Info("published pijulRefUpdate to PDS", "repo", repoAt, "changes", len(appliedHashes)) 727 + } 728 + 729 + // knotXrpcc builds an XRPC client that points directly at the given knot. 730 + func (d *Discussions) knotXrpcc(knot string) *xrpc.Client { 731 + scheme := "http" 732 + if !d.config.Core.Dev { 733 + scheme = "https" 734 + } 735 + return &xrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, knot)} 736 + } 737 + 738 + // getChangeFromKnot fetches change details (including dependencies) from knotserver 739 + func (d *Discussions) getChangeFromKnot(ctx context.Context, knot, repo, hash string) (*tangled.RepoChangeGet_Output, error) { 740 + return tangled.RepoChangeGet(ctx, d.knotXrpcc(knot), hash, repo) 741 + } 742 + 743 + // canAddPatchWithChange checks if a patch can be added to the discussion 744 + // Uses the already-fetched change object to avoid duplicate API calls 745 + // Returns error if the patch depends on a removed patch 746 + func (d *Discussions) canAddPatchWithChange(discussion *models.Discussion, change *tangled.RepoChangeGet_Output) error { 747 + 748 + if len(change.Dependencies) == 0 { 749 + return nil // No dependencies, can always add 750 + } 751 + 752 + // Get all patches in this discussion 753 + patches, err := db.GetDiscussionPatches(d.db, orm.FilterEq("discussion_at", discussion.AtUri())) 754 + if err != nil { 755 + return fmt.Errorf("failed to get discussion patches: %w", err) 756 + } 757 + 758 + // Check if any dependency is a removed patch in this discussion 759 + for _, dep := range change.Dependencies { 760 + for _, patch := range patches { 761 + if patch.PatchHash == dep && !patch.IsActive() { 762 + return fmt.Errorf("cannot add patch: it depends on removed patch %s", dep[:12]) 763 + } 764 + } 765 + } 766 + 767 + return nil 768 + } 769 + 770 + // canRemovePatch checks if a patch can be removed from the discussion 771 + // Returns error if other active patches depend on this patch 772 + func (d *Discussions) canRemovePatch(ctx context.Context, discussion *models.Discussion, knot, repo, patchHashToRemove string) error { 773 + // Get all active patches in this discussion 774 + patches, err := db.GetDiscussionPatches(d.db, orm.FilterEq("discussion_at", discussion.AtUri())) 775 + if err != nil { 776 + return fmt.Errorf("failed to get discussion patches: %w", err) 777 + } 778 + 779 + // For each active patch, check if it depends on the patch we want to remove 780 + for _, patch := range patches { 781 + if !patch.IsActive() || patch.PatchHash == patchHashToRemove { 782 + continue 783 + } 784 + 785 + // Get the change details to check its dependencies 786 + change, err := d.getChangeFromKnot(ctx, knot, repo, patch.PatchHash) 787 + if err != nil { 788 + d.logger.Warn("failed to get change dependencies", "hash", patch.PatchHash, "err", err) 789 + continue // Skip if we can't get the change, but don't block removal 790 + } 791 + 792 + for _, dep := range change.Dependencies { 793 + if dep == patchHashToRemove { 794 + return fmt.Errorf("cannot remove patch: patch %s depends on it", patch.PatchHash[:12]) 795 + } 796 + } 797 + } 798 + 799 + return nil 800 + }
+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 + }
+39
appview/ingester.go
··· 87 87 err = i.ingestLabelDefinition(e) 88 88 case tangled.LabelOpNSID: 89 89 err = i.ingestLabelOp(e) 90 + case tangled.PijulRefUpdateNSID: 91 + err = i.ingestPijulRefUpdate(e) 90 92 } 91 93 l = i.Logger.With("nsid", e.Commit.Collection) 92 94 } ··· 1131 1133 1132 1134 return nil 1133 1135 } 1136 + 1137 + // ingestPijulRefUpdate handles sh.tangled.pijul.refUpdate records published to 1138 + // the committer's PDS after a pijul push or merge. It updates the contributor 1139 + // punchcard using the number of changes pushed. 1140 + func (i *Ingester) ingestPijulRefUpdate(e *jmodels.Event) error { 1141 + if e.Commit.Operation != jmodels.CommitOperationCreate { 1142 + return nil 1143 + } 1144 + 1145 + committerDid := e.Did 1146 + 1147 + var record tangled.PijulRefUpdate 1148 + if err := json.Unmarshal(e.Commit.Record, &record); err != nil { 1149 + return fmt.Errorf("invalid pijulRefUpdate record: %w", err) 1150 + } 1151 + 1152 + if record.Repo == "" || len(record.Changes) == 0 { 1153 + return nil 1154 + } 1155 + 1156 + repoAt, err := syntax.ParseATURI(record.Repo) 1157 + if err != nil { 1158 + return fmt.Errorf("invalid repo AT URI %q: %w", record.Repo, err) 1159 + } 1160 + 1161 + if _, err := db.GetRepoByAtUri(i.Db, repoAt.String()); err != nil { 1162 + // Unknown repo — ignore rather than error; the repo may not be 1163 + // registered on this appview instance. 1164 + return nil 1165 + } 1166 + 1167 + return db.AddPunch(i.Db, models.Punch{ 1168 + Did: committerDid, 1169 + Date: time.Now(), 1170 + Count: len(record.Changes), 1171 + }) 1172 + }
+31
appview/middleware/middleware.go
··· 321 321 }) 322 322 } 323 323 324 + // middleware that is tacked on top of /{user}/{repo}/discussions/{discussion} 325 + func (mw Middleware) ResolveDiscussion(next http.Handler) http.Handler { 326 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 327 + f, err := mw.repoResolver.Resolve(r) 328 + if err != nil { 329 + log.Println("failed to fully resolve repo", err) 330 + w.WriteHeader(http.StatusNotFound) 331 + mw.pages.ErrorKnot404(w) 332 + return 333 + } 334 + 335 + discussionIdStr := chi.URLParam(r, "discussion") 336 + discussionId, err := strconv.Atoi(discussionIdStr) 337 + if err != nil { 338 + log.Println("failed to fully resolve discussion ID", err) 339 + mw.pages.Error404(w) 340 + return 341 + } 342 + 343 + discussion, err := db.GetDiscussion(mw.db, f.RepoAt(), discussionId) 344 + if err != nil { 345 + log.Println("failed to get discussion", "err", err) 346 + mw.pages.Error404(w) 347 + return 348 + } 349 + 350 + ctx := context.WithValue(r.Context(), "discussion", discussion) 351 + next.ServeHTTP(w, r.WithContext(ctx)) 352 + }) 353 + } 354 + 324 355 // this should serve the go-import meta tag even if the path is technically 325 356 // a 404 like tangled.sh/oppi.li/go-git/v5 326 357 //
+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
··· 23 23 Spindle string 24 24 Labels []string 25 25 RepoDid string 26 + Vcs string // "git" or "pijul" 26 27 27 28 // optionally, populate this when querying for reverse mappings 28 29 RepoStats *RepoStats 29 30 30 31 // optional 31 32 Source string 33 + } 34 + 35 + func (r *Repo) IsGit() bool { 36 + return r.Vcs == "" || r.Vcs == "git" 37 + } 38 + 39 + func (r *Repo) IsPijul() bool { 40 + return r.Vcs == "pijul" 32 41 } 33 42 34 43 func (r *Repo) AsRecord() tangled.Repo { ··· 86 95 } 87 96 88 97 type RepoStats struct { 89 - Language string 90 - StarCount int 91 - IssueCount IssueCount 92 - PullCount PullCount 98 + Language string 99 + StarCount int 100 + IssueCount IssueCount 101 + PullCount PullCount 102 + DiscussionCount DiscussionCount 93 103 } 94 104 95 105 type IssueCount struct {
+5 -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 ··· 270 271 return session.APIClient(), nil 271 272 } 272 273 274 + 273 275 // this is a higher level abstraction on ServerGetServiceAuth 274 276 type ServiceClientOpts struct { 275 277 service string ··· 321 323 } 322 324 323 325 func (s *ServiceClientOpts) Audience() string { 324 - return fmt.Sprintf("did:web:%s", s.service) 326 + // did:web spec requires colons to be encoded as %3A 327 + encoded := strings.ReplaceAll(s.service, ":", "%3A") 328 + return fmt.Sprintf("did:web:%s", encoded) 325 329 } 326 330 327 331 func (s *ServiceClientOpts) Host() string {
+2
appview/oauth/scopes.go
··· 38 38 "rpc:sh.tangled.repo.addSecret?aud=*", 39 39 "rpc:sh.tangled.repo.removeSecret?aud=*", 40 40 "rpc:sh.tangled.repo.listSecrets?aud=*", 41 + "rpc:sh.tangled.repo.applyChanges?aud=*", 42 + "rpc:sh.tangled.repo.unrecordChanges?aud=*", 41 43 }
+1
appview/oauth/store.go
··· 88 88 return fmt.Sprintf("oauth:auth_request:%s", state) 89 89 } 90 90 91 + 91 92 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 92 93 key := sessionKey(did, sessionID) 93 94 metaKey := sessionMetadataKey(did, sessionID)
+99
appview/pages/pages.go
··· 837 837 return p.executeRepo("repo/log", w, params) 838 838 } 839 839 840 + type PijulChangeView struct { 841 + Hash string 842 + Authors []*tangled.RepoChangeList_Author 843 + Message string 844 + Dependencies []string 845 + Timestamp time.Time 846 + HasTimestamp bool 847 + } 848 + 849 + type RepoChangesParams struct { 850 + LoggedInUser *oauth.MultiAccountUser 851 + RepoInfo repoinfo.RepoInfo 852 + Active string 853 + Page int 854 + Changes []PijulChangeView 855 + } 856 + 857 + func (p *Pages) RepoChanges(w io.Writer, params RepoChangesParams) error { 858 + params.Active = "changes" 859 + return p.executeRepo("repo/changes", w, params) 860 + } 861 + 862 + type RepoChangeParams struct { 863 + LoggedInUser *oauth.MultiAccountUser 864 + RepoInfo repoinfo.RepoInfo 865 + Active string 866 + Change PijulChangeDetail 867 + } 868 + 869 + func (p *Pages) RepoChange(w io.Writer, params RepoChangeParams) error { 870 + params.Active = "changes" 871 + return p.executeRepo("repo/change", w, params) 872 + } 873 + 874 + type PijulChangeDetail struct { 875 + Hash string 876 + Authors []*tangled.RepoChangeGet_Author 877 + Message string 878 + Dependencies []string 879 + Diff string 880 + HasDiff bool 881 + DiffLines []PijulDiffLine 882 + Timestamp time.Time 883 + HasTimestamp bool 884 + } 885 + 886 + type PijulDiffLine struct { 887 + Kind string 888 + Op string 889 + Body string 890 + Text string 891 + OldLine int64 892 + NewLine int64 893 + HasOld bool 894 + HasNew bool 895 + } 896 + 840 897 type RepoCommitParams struct { 841 898 LoggedInUser *oauth.MultiAccountUser 842 899 RepoInfo repoinfo.RepoInfo ··· 1666 1723 func (p *Pages) Error503(w io.Writer) error { 1667 1724 return p.execute("errors/503", w, nil) 1668 1725 } 1726 + 1727 + // Pijul Discussion pages - these are different from Git's issues/PRs 1728 + 1729 + type RepoDiscussionsListParams struct { 1730 + LoggedInUser *oauth.MultiAccountUser 1731 + RepoInfo repoinfo.RepoInfo 1732 + Active string 1733 + Discussions []models.Discussion 1734 + Filter string 1735 + DiscussionCount models.DiscussionCount 1736 + } 1737 + 1738 + func (p *Pages) RepoDiscussionsList(w io.Writer, params RepoDiscussionsListParams) error { 1739 + params.Active = "discussions" 1740 + return p.executeRepo("repo/pijul/discussions/list", w, params) 1741 + } 1742 + 1743 + type NewDiscussionParams struct { 1744 + LoggedInUser *oauth.MultiAccountUser 1745 + RepoInfo repoinfo.RepoInfo 1746 + Active string 1747 + } 1748 + 1749 + func (p *Pages) NewDiscussion(w io.Writer, params NewDiscussionParams) error { 1750 + params.Active = "discussions" 1751 + return p.executeRepo("repo/pijul/discussions/new", w, params) 1752 + } 1753 + 1754 + type RepoSingleDiscussionParams struct { 1755 + LoggedInUser *oauth.MultiAccountUser 1756 + RepoInfo repoinfo.RepoInfo 1757 + Active string 1758 + Discussion *models.Discussion 1759 + CommentList []models.DiscussionCommentListItem 1760 + CanManage bool 1761 + ActivePatches []*models.DiscussionPatch 1762 + } 1763 + 1764 + func (p *Pages) RepoSingleDiscussion(w io.Writer, params RepoSingleDiscussionParams) error { 1765 + params.Active = "discussions" 1766 + return p.executeRepo("repo/pijul/discussions/single", w, params) 1767 + }
+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 + }
+110
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 + {{ if $author.Did }} 14 + <a href="/{{ resolve (deref $author.Did) }}"> 15 + {{ template "user/fragments/pic" (list (deref $author.Did) "h-10 w-10") }} 16 + </a> 17 + {{ else }} 18 + {{ placeholderAvatar "md" }} 19 + {{ end }} 20 + <div class="flex flex-col"> 21 + <div> 22 + {{ range $idx, $a := $change.Authors }} 23 + {{ if gt $idx 0 }}, {{ end }} 24 + {{ if $a.Did }} 25 + <a href="/{{ resolve (deref $a.Did) }}" class="no-underline hover:underline text-gray-700 dark:text-gray-300">{{ deref $a.Did | resolve | truncateAt30 }}</a> 26 + {{ else }} 27 + {{ $a.Name }}{{ if $a.Email }} &lt;{{ $a.Email }}&gt;{{ end }} 28 + {{ end }} 29 + {{ end }} 30 + </div> 31 + {{ if $change.HasTimestamp }} 32 + <div class="text-sm text-gray-500 dark:text-gray-400"> 33 + {{ template "repo/fragments/time" $change.Timestamp }} 34 + </div> 35 + {{ end }} 36 + <div class="font-mono text-sm text-gray-500 dark:text-gray-400 break-all">{{ $change.Hash }}</div> 37 + </div> 38 + {{ else }} 39 + {{ placeholderAvatar "md" }} 40 + <div class="text-gray-500">unknown</div> 41 + {{ end }} 42 + </div> 43 + 44 + {{ if .RepoInfo.Roles.CanManageRepo }} 45 + <div class="mt-4" id="change"> 46 + <button 47 + class="px-3 py-1.5 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded hover:bg-red-50 dark:hover:bg-red-900/30 cursor-pointer" 48 + hx-post="/{{ $repo }}/change/{{ $change.Hash }}/unrecord" 49 + hx-confirm="Are you sure you want to unrecord this change? This will remove it from the current channel." 50 + hx-swap="none" 51 + > 52 + Unrecord 53 + </button> 54 + </div> 55 + {{ end }} 56 + 57 + <h2 class="mt-6 text-sm uppercase text-gray-600 dark:text-gray-400">Dependencies</h2> 58 + <ul class="mt-2"> 59 + {{ if $change.Dependencies }} 60 + {{ range $change.Dependencies }} 61 + <li class="font-mono text-sm break-all"> 62 + <a class="no-underline hover:underline" href="/{{ $repo }}/change/{{ . }}">{{ . }}</a> 63 + </li> 64 + {{ end }} 65 + {{ else }} 66 + <li class="text-sm text-gray-500">none</li> 67 + {{ end }} 68 + </ul> 69 + 70 + <h2 class="mt-8 text-sm uppercase text-gray-600 dark:text-gray-400">Change contents</h2> 71 + {{ if $change.HasDiff }} 72 + {{ if $change.DiffLines }} 73 + <div class="overflow-x-auto text-sm bg-gray-50 dark:bg-gray-900 p-3 rounded mt-2 font-mono"> 74 + {{ $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" }} 75 + {{ $lineNrSepStyle1 := "" }} 76 + {{ $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" }} 77 + {{ $containerStyle := "inline-flex w-full items-center" }} 78 + {{ range $change.DiffLines }} 79 + {{ if eq .Kind "section" }} 80 + <div class="whitespace-pre text-xs uppercase tracking-wide text-gray-600 dark:text-gray-300 mt-3">{{ .Text }}</div> 81 + {{ else if eq .Kind "meta" }} 82 + <div class="whitespace-pre text-gray-500 dark:text-gray-400">{{ .Text }}</div> 83 + {{ else }} 84 + {{ $lineClass := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" }} 85 + {{ if eq .Kind "add" }} 86 + {{ $lineClass = "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" }} 87 + {{ else if eq .Kind "del" }} 88 + {{ $lineClass = "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400" }} 89 + {{ end }} 90 + <div class="{{ $containerStyle }} {{ $lineClass }}"> 91 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}"> 92 + {{ if .HasOld }}{{ .OldLine }}{{ else }}<span aria-hidden="true" class="invisible">0</span>{{ end }} 93 + </div> 94 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}"> 95 + {{ if .HasNew }}{{ .NewLine }}{{ else }}<span aria-hidden="true" class="invisible">0</span>{{ end }} 96 + </div> 97 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op }}</div> 98 + <div class="px-2 whitespace-pre">{{ .Body }}</div> 99 + </div> 100 + {{ end }} 101 + {{ end }} 102 + </div> 103 + {{ else }} 104 + <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> 105 + {{ end }} 106 + {{ else }} 107 + <div class="text-sm text-gray-500 mt-2">no diff available</div> 108 + {{ end }} 109 + </section> 110 + {{ end }}
+153
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 + {{ if $author.Did }} 132 + {{ template "user/fragments/picHandleLink" (deref $author.Did) }} 133 + {{ else }} 134 + {{ placeholderAvatar "tiny" }} 135 + <span class="text-gray-700 dark:text-gray-300"> 136 + {{ $author.Name }} 137 + </span> 138 + {{ end }} 139 + {{ if gt (len .Authors) 1 }} <span class="text-gray-500">+{{ sub (len .Authors) 1 }}</span>{{ end }} 140 + {{ else }} 141 + {{ placeholderAvatar "tiny" }} 142 + <span class="text-gray-500">unknown</span> 143 + {{ end }} 144 + </span> 145 + {{ end }} 146 + 147 + {{ define "repoAfter" }} 148 + {{ $changes_len := len .Changes }} 149 + <div class="flex justify-end mt-4 gap-2"> 150 + {{ 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 }} 151 + {{ 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 }} 152 + </div> 153 + {{ 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 }} change{{ if ne $patchCount 1 }}s{{ 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 -
+40 -40
appview/repo/blob.go
··· 11 11 "strings" 12 12 "time" 13 13 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + "github.com/go-chi/chi/v5" 16 + "github.com/go-git/go-git/v5/plumbing" 14 17 "tangled.org/core/api/tangled" 15 18 "tangled.org/core/appview/config" 16 19 "tangled.org/core/appview/db" ··· 18 21 "tangled.org/core/appview/pages" 19 22 "tangled.org/core/appview/pages/markup" 20 23 "tangled.org/core/appview/reporesolver" 21 - xrpcclient "tangled.org/core/appview/xrpcclient" 22 24 "tangled.org/core/types" 25 + ) 23 26 24 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 - "github.com/go-chi/chi/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 - ) 27 + // knotXrpcc builds an XRPC client that points directly at the knot hosting f. 28 + func (rp *Repo) knotXrpcc(knot string) *indigoxrpc.Client { 29 + scheme := "http" 30 + if !rp.config.Core.Dev { 31 + scheme = "https" 32 + } 33 + return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, knot)} 34 + } 28 35 29 36 // the content can be one of the following: 30 37 // ··· 50 57 filePath := chi.URLParam(r, "*") 51 58 filePath, _ = url.PathUnescape(filePath) 52 59 53 - scheme := "http" 54 - if !rp.config.Core.Dev { 55 - scheme = "https" 56 - } 57 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 58 - xrpcc := &indigoxrpc.Client{ 59 - Host: host, 60 - } 61 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoIdentifier()) 62 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 60 + provider := newVcsProvider(f, rp.knotXrpcc(f.Knot)) 61 + 62 + resp, err := provider.GetBlob(r.Context(), ref, filePath) 63 + if err != nil { 64 + l.Error("failed to fetch blob", "err", err) 64 65 rp.pages.Error503(w) 65 66 return 66 67 } 67 68 68 69 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 69 70 70 - // Use XRPC response directly instead of converting to internal types 71 71 var breadcrumbs [][]string 72 72 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 73 73 if filePath != "" { ··· 76 76 } 77 77 } 78 78 79 - // Create the blob view 80 79 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 81 80 82 81 user := rp.oauth.GetMultiAccountUser(r) 83 82 84 - // Get email to DID mapping for commit author 85 83 var emails []string 86 84 if resp.LastCommit != nil && resp.LastCommit.Author != nil { 87 85 emails = append(emails, resp.LastCommit.Author.Email) ··· 138 136 if !rp.config.Core.Dev { 139 137 scheme = "https" 140 138 } 141 - repo := f.RepoIdentifier() 139 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 140 + 141 + provider := newVcsProvider(f, rp.knotXrpcc(f.Knot)) 142 142 baseURL := &url.URL{ 143 143 Scheme: scheme, 144 144 Host: f.Knot, 145 - Path: "/xrpc/sh.tangled.repo.blob", 145 + Path: provider.RawBlobXrpcPath(), 146 146 } 147 147 query := baseURL.Query() 148 - query.Set("repo", repo) 149 - query.Set("ref", ref) 150 - query.Set("path", filePath) 151 - query.Set("raw", "true") 148 + provider.SetRawBlobQuery(query, ref, filePath, repo) 152 149 baseURL.RawQuery = query.Encode() 150 + 153 151 blobURL := baseURL.String() 154 152 req, err := http.NewRequest("GET", blobURL, nil) 155 153 if err != nil { ··· 157 155 return 158 156 } 159 157 160 - // forward the If-None-Match header 161 158 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 162 159 req.Header.Set("If-None-Match", clientETag) 163 160 } ··· 169 166 rp.pages.Error503(w) 170 167 return 171 168 } 172 - 173 169 defer resp.Body.Close() 174 170 175 - // forward 304 not modified 176 171 if resp.StatusCode == http.StatusNotModified { 177 172 w.WriteHeader(http.StatusNotModified) 178 173 return ··· 194 189 } 195 190 196 191 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 197 - // serve all textual content as text/plain 198 192 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 199 193 w.Write(body) 200 194 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 201 - // serve images and videos with their original content type 202 195 w.Header().Set("Content-Type", contentType) 203 196 w.Write(body) 204 197 } else { 205 198 w.WriteHeader(http.StatusUnsupportedMediaType) 206 199 w.Write([]byte("unsupported content type")) 207 - return 208 200 } 209 201 } 210 202 ··· 215 207 Lines: 0, 216 208 } 217 209 218 - // Set size 219 210 if resp.Size != nil { 220 211 view.SizeHint = uint64(*resp.Size) 221 212 } else if resp.Content != nil { ··· 229 220 return view 230 221 } 231 222 232 - // Determine if binary 233 223 if resp.IsBinary != nil && *resp.IsBinary { 234 224 view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 235 225 ext := strings.ToLower(filepath.Ext(resp.Path)) ··· 263 253 return view 264 254 } 265 255 266 - // otherwise, we are dealing with text content 267 256 view.HasRawView = true 268 257 view.HasTextView = true 269 258 ··· 272 261 view.Lines = countLines(view.Contents) 273 262 } 274 263 275 - // with text, we may be dealing with markdown 276 264 format := markup.GetFormat(resp.Path) 277 265 if format == markup.FormatMarkdown { 278 266 view.ContentType = models.BlobContentTypeMarkup ··· 289 277 scheme = "https" 290 278 } 291 279 292 - repoName := repo.RepoIdentifier() 280 + var xrpcPath, refParam string 281 + repoId := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 282 + if repo.IsPijul() { 283 + xrpcPath = "/xrpc/sh.tangled.repo.pijulBlob" 284 + refParam = "channel" 285 + } else { 286 + xrpcPath = "/xrpc/sh.tangled.repo.blob" 287 + refParam = "ref" 288 + repoId = repo.RepoIdentifier() 289 + } 290 + 293 291 baseURL := &url.URL{ 294 292 Scheme: scheme, 295 293 Host: repo.Knot, 296 - Path: "/xrpc/sh.tangled.repo.blob", 294 + Path: xrpcPath, 297 295 } 298 296 query := baseURL.Query() 299 - query.Set("repo", repoName) 300 - query.Set("ref", ref) 297 + query.Set("repo", repoId) 298 + query.Set(refParam, ref) 301 299 query.Set("path", filePath) 302 - query.Set("raw", "true") 300 + if !repo.IsPijul() { 301 + query.Set("raw", "true") 302 + } 303 303 baseURL.RawQuery = query.Encode() 304 304 blobURL := baseURL.String() 305 305
+351
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/oauth" 13 + "tangled.org/core/appview/pages" 14 + xrpcclient "tangled.org/core/appview/xrpcclient" 15 + 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 + xrpcc := rp.knotXrpcc(f.Knot) 44 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 45 + 46 + if ref == "" { 47 + channels, err := tangled.RepoChannelList(r.Context(), xrpcc, "", 0, repo) 48 + if err != nil { 49 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 50 + l.Error("failed to call XRPC repo.channelList", "err", xrpcerr) 51 + rp.pages.Error503(w) 52 + return 53 + } 54 + rp.pages.Error503(w) 55 + return 56 + } 57 + for _, ch := range channels.Channels { 58 + if ch.Is_current != nil && *ch.Is_current { 59 + ref = ch.Name 60 + break 61 + } 62 + } 63 + if ref == "" && len(channels.Channels) > 0 { 64 + ref = channels.Channels[0].Name 65 + } 66 + } 67 + 68 + limit := int64(60) 69 + cursor := "" 70 + if page > 1 { 71 + offset := (page - 1) * int(limit) 72 + cursor = strconv.Itoa(offset) 73 + } 74 + 75 + resp, err := tangled.RepoChangeList(r.Context(), xrpcc, ref, cursor, limit, repo) 76 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 77 + l.Error("failed to call XRPC repo.changeList", "err", xrpcerr) 78 + rp.pages.Error503(w) 79 + return 80 + } 81 + 82 + changes := make([]pages.PijulChangeView, 0, len(resp.Changes)) 83 + for _, change := range resp.Changes { 84 + view := pages.PijulChangeView{ 85 + Hash: change.Hash, 86 + Authors: change.Authors, 87 + Message: change.Message, 88 + Dependencies: change.Dependencies, 89 + } 90 + if change.Timestamp != nil { 91 + if parsed, err := time.Parse(time.RFC3339, *change.Timestamp); err == nil { 92 + view.Timestamp = parsed 93 + view.HasTimestamp = true 94 + } 95 + } 96 + changes = append(changes, view) 97 + } 98 + 99 + user := rp.oauth.GetMultiAccountUser(r) 100 + rp.pages.RepoChanges(w, pages.RepoChangesParams{ 101 + LoggedInUser: user, 102 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 103 + Page: page, 104 + Changes: changes, 105 + }) 106 + } 107 + 108 + func (rp *Repo) Change(w http.ResponseWriter, r *http.Request) { 109 + l := rp.logger.With("handler", "RepoChange") 110 + 111 + f, err := rp.repoResolver.Resolve(r) 112 + if err != nil { 113 + l.Error("failed to fully resolve repo", "err", err) 114 + return 115 + } 116 + if !f.IsPijul() { 117 + rp.pages.Error404(w) 118 + return 119 + } 120 + 121 + hash := chi.URLParam(r, "hash") 122 + if hash == "" { 123 + rp.pages.Error404(w) 124 + return 125 + } 126 + 127 + xrpcc := rp.knotXrpcc(f.Knot) 128 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 129 + resp, err := tangled.RepoChangeGet(r.Context(), xrpcc, hash, repo) 130 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 131 + l.Error("failed to call XRPC repo.changeGet", "err", xrpcerr) 132 + rp.pages.Error503(w) 133 + return 134 + } 135 + 136 + change := pages.PijulChangeDetail{ 137 + Hash: resp.Hash, 138 + Authors: resp.Authors, 139 + Message: resp.Message, 140 + Dependencies: resp.Dependencies, 141 + } 142 + if resp.Diff != nil { 143 + change.Diff = *resp.Diff 144 + change.HasDiff = true 145 + change.DiffLines = parsePijulDiffLines(change.Diff) 146 + } 147 + if resp.Timestamp != nil { 148 + if parsed, err := time.Parse(time.RFC3339, *resp.Timestamp); err == nil { 149 + change.Timestamp = parsed 150 + change.HasTimestamp = true 151 + } 152 + } 153 + 154 + user := rp.oauth.GetMultiAccountUser(r) 155 + rp.pages.RepoChange(w, pages.RepoChangeParams{ 156 + LoggedInUser: user, 157 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 158 + Change: change, 159 + }) 160 + } 161 + 162 + func parsePijulDiffLines(diff string) []pages.PijulDiffLine { 163 + if diff == "" { 164 + return nil 165 + } 166 + lines := strings.Split(diff, "\n") 167 + out := make([]pages.PijulDiffLine, 0, len(lines)) 168 + var oldLine int64 169 + var newLine int64 170 + var hasOld bool 171 + var hasNew bool 172 + for _, line := range lines { 173 + kind := "context" 174 + op := " " 175 + body := line 176 + switch { 177 + case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "): 178 + kind = "meta" 179 + op = "" 180 + hasOld = false 181 + hasNew = false 182 + case strings.HasPrefix(line, "@@"): 183 + kind = "meta" 184 + op = "" 185 + if o, n, ok := parseUnifiedHunkHeader(line); ok { 186 + oldLine = o 187 + newLine = n 188 + hasOld = true 189 + hasNew = true 190 + } else { 191 + hasOld = false 192 + hasNew = false 193 + } 194 + case strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "index "): 195 + kind = "meta" 196 + op = "" 197 + hasOld = false 198 + hasNew = false 199 + case strings.HasPrefix(line, "#"): 200 + kind = "section" 201 + op = "" 202 + hasOld = false 203 + hasNew = false 204 + case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): 205 + kind = "add" 206 + op = "+" 207 + body = line[1:] 208 + case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): 209 + kind = "del" 210 + op = "-" 211 + body = line[1:] 212 + case strings.HasPrefix(line, " "): 213 + body = line[1:] 214 + } 215 + diffLine := pages.PijulDiffLine{ 216 + Kind: kind, 217 + Op: op, 218 + Body: body, 219 + Text: line, 220 + } 221 + if kind != "meta" { 222 + if kind == "del" { 223 + if hasOld { 224 + diffLine.OldLine = oldLine 225 + diffLine.HasOld = true 226 + oldLine++ 227 + } 228 + } else if kind == "add" { 229 + if hasNew { 230 + diffLine.NewLine = newLine 231 + diffLine.HasNew = true 232 + newLine++ 233 + } 234 + } else { 235 + if hasOld { 236 + diffLine.OldLine = oldLine 237 + diffLine.HasOld = true 238 + oldLine++ 239 + } 240 + if hasNew { 241 + diffLine.NewLine = newLine 242 + diffLine.HasNew = true 243 + newLine++ 244 + } 245 + } 246 + } 247 + out = append(out, diffLine) 248 + } 249 + return out 250 + } 251 + 252 + func parseUnifiedHunkHeader(line string) (int64, int64, bool) { 253 + start := strings.Index(line, "@@") 254 + if start == -1 { 255 + return 0, 0, false 256 + } 257 + trimmed := strings.TrimSpace(line[start+2:]) 258 + end := strings.Index(trimmed, "@@") 259 + if end == -1 { 260 + return 0, 0, false 261 + } 262 + fields := strings.Fields(strings.TrimSpace(trimmed[:end])) 263 + if len(fields) < 2 { 264 + return 0, 0, false 265 + } 266 + oldStart, okOld := parseUnifiedRange(fields[0], "-") 267 + newStart, okNew := parseUnifiedRange(fields[1], "+") 268 + if !okOld || !okNew { 269 + return 0, 0, false 270 + } 271 + return oldStart, newStart, true 272 + } 273 + 274 + func parseUnifiedRange(value, prefix string) (int64, bool) { 275 + if !strings.HasPrefix(value, prefix) { 276 + return 0, false 277 + } 278 + value = strings.TrimPrefix(value, prefix) 279 + if value == "" { 280 + return 0, false 281 + } 282 + if idx := strings.Index(value, ","); idx >= 0 { 283 + value = value[:idx] 284 + } 285 + out, err := strconv.ParseInt(value, 10, 64) 286 + if err != nil { 287 + return 0, false 288 + } 289 + return out, true 290 + } 291 + 292 + func (rp *Repo) UnrecordChange(w http.ResponseWriter, r *http.Request) { 293 + l := rp.logger.With("handler", "UnrecordChange") 294 + 295 + f, err := rp.repoResolver.Resolve(r) 296 + if err != nil { 297 + l.Error("failed to resolve repo", "err", err) 298 + rp.pages.Notice(w, "change", "Failed to resolve repository") 299 + return 300 + } 301 + if !f.IsPijul() { 302 + rp.pages.Notice(w, "change", "Unrecord is only available for Pijul repositories") 303 + return 304 + } 305 + 306 + hash := chi.URLParam(r, "hash") 307 + if hash == "" { 308 + rp.pages.Notice(w, "change", "Missing change hash") 309 + return 310 + } 311 + 312 + user := rp.oauth.GetMultiAccountUser(r) 313 + repoInfo := rp.repoResolver.GetRepoInfo(r, user) 314 + if !repoInfo.Roles.CanManageRepo() { 315 + rp.pages.Notice(w, "change", "You don't have permission to unrecord changes") 316 + return 317 + } 318 + 319 + xrpcc, err := rp.oauth.ServiceClient( 320 + r, 321 + oauth.WithService(f.Knot), 322 + oauth.WithLxm(tangled.RepoUnrecordChangesNSID), 323 + oauth.WithDev(rp.config.Core.Dev), 324 + ) 325 + if err != nil { 326 + l.Error("failed to create service client", "err", err) 327 + rp.pages.Notice(w, "change", "Failed to authenticate with knotserver") 328 + return 329 + } 330 + 331 + repoIdentifier := fmt.Sprintf("%s/%s", f.Did, f.Name) 332 + input := &tangled.RepoUnrecordChanges_Input{ 333 + Repo: repoIdentifier, 334 + Changes: []string{hash}, 335 + } 336 + 337 + result, err := tangled.RepoUnrecordChanges(r.Context(), xrpcc, input) 338 + if err != nil { 339 + l.Error("failed to unrecord change", "err", err) 340 + rp.pages.Notice(w, "change", "Failed to unrecord change: "+err.Error()) 341 + return 342 + } 343 + 344 + if len(result.Failed) > 0 { 345 + rp.pages.Notice(w, "change", "Failed to unrecord change: "+result.Failed[0].Error) 346 + return 347 + } 348 + 349 + l.Info("change unrecorded", "hash", hash) 350 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s/changes", f.Did, f.Name)) 351 + }
+71 -2
appview/repo/index.go
··· 43 43 44 44 user := rp.oauth.GetMultiAccountUser(r) 45 45 46 - // Build index response from multiple XRPC calls 47 - result, err := rp.buildIndexResponse(r.Context(), f, ref) 46 + var result *types.RepoIndexResponse 47 + if f.IsPijul() { 48 + result, err = rp.buildPijulIndexResponse(r.Context(), f, ref) 49 + } else { 50 + result, err = rp.buildIndexResponse(r.Context(), f, ref) 51 + } 48 52 if err != nil { 49 53 l.Error("failed to build index response", "err", err) 50 54 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ ··· 155 159 currentRef string, 156 160 isDefaultRef bool, 157 161 ) ([]types.RepoLanguageDetails, error) { 162 + if repo.IsPijul() { 163 + return nil, nil 164 + } 158 165 // first attempt to fetch from db 159 166 langs, err := db.GetRepoLanguages( 160 167 rp.db, ··· 362 369 363 370 return result, nil 364 371 } 372 + 373 + // buildPijulIndexResponse builds a RepoIndexResponse for a pijul repository. 374 + func (rp *Repo) buildPijulIndexResponse(ctx context.Context, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 375 + xrpcc := rp.knotXrpcc(repo.Knot) 376 + repoId := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 377 + 378 + // Fetch channels to determine the active ref and populate the branch selector. 379 + channels, err := tangled.RepoChannelList(ctx, xrpcc, "", 0, repoId) 380 + if err != nil { 381 + return nil, fmt.Errorf("calling repo.channelList: %w", err) 382 + } 383 + 384 + if len(channels.Channels) == 0 { 385 + return &types.RepoIndexResponse{IsEmpty: true}, nil 386 + } 387 + 388 + // Resolve ref: use provided, or current channel, or first available. 389 + if ref == "" { 390 + for _, ch := range channels.Channels { 391 + if ch.Is_current != nil && *ch.Is_current { 392 + ref = ch.Name 393 + break 394 + } 395 + } 396 + if ref == "" { 397 + ref = channels.Channels[0].Name 398 + } 399 + } 400 + 401 + branches := make([]types.Branch, 0, len(channels.Channels)) 402 + for _, ch := range channels.Channels { 403 + isCurrent := ch.Is_current != nil && *ch.Is_current 404 + branches = append(branches, types.Branch{ 405 + Reference: types.Reference{Name: ch.Name}, 406 + IsDefault: isCurrent, 407 + }) 408 + } 409 + 410 + provider := newVcsProvider(repo, xrpcc) 411 + treeResult, _, err := provider.GetTree(ctx, ref, "") 412 + if err != nil { 413 + return nil, fmt.Errorf("fetching pijul tree: %w", err) 414 + } 415 + 416 + // Fetch total change count for the active channel. 417 + changeResp, err := tangled.RepoChangeList(ctx, xrpcc, ref, "", 1, repoId) 418 + var totalChanges int 419 + if err == nil { 420 + totalChanges = int(changeResp.Total) 421 + } 422 + 423 + return &types.RepoIndexResponse{ 424 + IsEmpty: false, 425 + Ref: ref, 426 + Files: treeResult.Files, 427 + Readme: treeResult.Readme, 428 + ReadmeFileName: treeResult.ReadmeFileName, 429 + Branches: branches, 430 + TotalCommits: totalChanges, 431 + }, nil 432 + } 433 +
+2 -3
appview/repo/repo.go
··· 799 799 fail("Failed to add collaborator permissions.", err) 800 800 return 801 801 } 802 - 803 802 err = db.AddCollaborator(tx, models.Collaborator{ 804 803 Did: syntax.DID(currentUser.Active.Did), 805 804 Rkey: rkey, ··· 917 916 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 918 917 return 919 918 } 920 - 921 919 // remove repo from db 922 920 err = db.RemoveRepo(tx, f.Did, f.Name) 923 921 if err != nil { ··· 1090 1088 Rkey: rkey, 1091 1089 Name: forkName, 1092 1090 Source: &forkSourceUrl, 1091 + Vcs: &f.Vcs, 1093 1092 } 1094 1093 createResp, createErr := tangled.RepoCreate( 1095 1094 r.Context(), ··· 1126 1125 Created: time.Now(), 1127 1126 Labels: rp.config.Label.DefaultLabelDefs, 1128 1127 RepoDid: repoDid, 1128 + Vcs: f.Vcs, 1129 1129 } 1130 1130 record := repo.AsRecord() 1131 1131 ··· 1230 1230 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1231 1231 return 1232 1232 } 1233 - 1234 1233 err = tx.Commit() 1235 1234 if err != nil { 1236 1235 l.Error("failed to commit changes", "err", err)
+8
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) 24 + r.Group(func(r chi.Router) { 25 + r.Use(middleware.AuthMiddleware(rp.oauth)) 26 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 27 + r.Post("/change/{hash}/unrecord", rp.UnrecordChange) 28 + }) 21 29 r.Get("/branches", rp.Branches) 22 30 r.Delete("/branches", rp.DeleteBranch) 23 31 r.Route("/tags", func(r chi.Router) {
+9 -4
appview/repo/tags.go
··· 27 27 l.Error("failed to get repo and knot", "err", err) 28 28 return 29 29 } 30 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 31 - xrpcBytes, err := tangled.GitTempListTags(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 32 - if err != nil { 33 - l.Error("failed to call XRPC repo.tags", "err", err) 30 + if f.IsPijul() { 31 + rp.pages.Error404(w) 32 + return 33 + } 34 + xrpcc := rp.knotXrpcc(f.Knot) 35 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 36 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 37 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 34 39 rp.pages.Error503(w) 35 40 return 36 41 }
+28 -72
appview/repo/tree.go
··· 5 5 "net/http" 6 6 "net/url" 7 7 "strings" 8 - "time" 9 8 10 - "tangled.org/core/api/tangled" 9 + "github.com/go-chi/chi/v5" 11 10 "tangled.org/core/appview/db" 12 11 "tangled.org/core/appview/pages" 13 12 "tangled.org/core/appview/reporesolver" 14 - xrpcclient "tangled.org/core/appview/xrpcclient" 15 - "tangled.org/core/types" 16 - 17 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/go-git/go-git/v5/plumbing" 20 13 ) 21 14 22 15 func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { ··· 34 27 treePath, _ = url.PathUnescape(treePath) 35 28 treePath = strings.TrimSuffix(treePath, "/") 36 29 37 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 38 - xrpcResp, err := tangled.GitTempGetTree(r.Context(), xrpcc, treePath, ref, f.RepoAt().String()) 39 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 40 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 30 + provider := newVcsProvider(f, rp.knotXrpcc(f.Knot)) 31 + 32 + result, lastCommit, err := provider.GetTree(r.Context(), ref, treePath) 33 + if err != nil { 34 + l.Error("failed to fetch tree", "err", err) 41 35 rp.pages.Error503(w) 42 36 return 43 37 } 44 - // Convert XRPC response to internal types.RepoTreeResponse 45 - files := make([]types.NiceTree, len(xrpcResp.Files)) 46 - for i, xrpcFile := range xrpcResp.Files { 47 - file := types.NiceTree{ 48 - Name: xrpcFile.Name, 49 - Mode: xrpcFile.Mode, 50 - Size: int64(xrpcFile.Size), 51 - } 52 - // Convert last commit info if present 53 - if xrpcFile.Last_commit != nil { 54 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 55 - file.LastCommit = &types.LastCommitInfo{ 56 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 57 - Message: xrpcFile.Last_commit.Message, 58 - When: commitWhen, 59 - } 60 - } 61 - files[i] = file 62 - } 63 - result := types.RepoTreeResponse{ 64 - Ref: xrpcResp.Ref, 65 - Files: files, 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 - result.ReadmeFileName = xrpcResp.Readme.Filename 75 - result.Readme = xrpcResp.Readme.Contents 38 + 39 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 40 + 41 + displayRef := ref 42 + if displayRef == "" { 43 + displayRef = result.Ref 76 44 } 77 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 78 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 45 + 46 + // redirects tree paths trying to access a blob; in this case result.Files is unpopulated, 79 47 // so we can safely redirect to the "parent" (which is the same file). 80 48 if len(result.Files) == 0 && result.Parent == treePath { 81 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(ref), result.Parent) 49 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(displayRef), result.Parent) 82 50 http.Redirect(w, r, redirectTo, http.StatusFound) 83 51 return 84 52 } 53 + 85 54 user := rp.oauth.GetMultiAccountUser(r) 55 + 86 56 var breadcrumbs [][]string 87 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 57 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(displayRef))}) 88 58 if treePath != "" { 89 59 for idx, elem := range strings.Split(treePath, "/") { 90 60 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 91 61 } 92 62 } 93 - sortFiles(result.Files) 94 63 95 - // Get email to DID mapping for commit author 96 - var emails []string 97 - if xrpcResp.LastCommit != nil && xrpcResp.LastCommit.Author != nil { 98 - emails = append(emails, xrpcResp.LastCommit.Author.Email) 99 - } 100 - emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 101 - if err != nil { 102 - l.Error("failed to get email to did mapping", "err", err) 103 - emailToDidMap = make(map[string]string) 104 - } 64 + sortFiles(result.Files) 105 65 106 - var lastCommitInfo *types.LastCommitInfo 107 - if xrpcResp.LastCommit != nil { 108 - when, _ := time.Parse(time.RFC3339, xrpcResp.LastCommit.When) 109 - lastCommitInfo = &types.LastCommitInfo{ 110 - Hash: plumbing.NewHash(xrpcResp.LastCommit.Hash), 111 - Message: xrpcResp.LastCommit.Message, 112 - When: when, 66 + var emailToDidMap map[string]string 67 + if lastCommit != nil && lastCommit.Author.Email != "" { 68 + emailToDidMap, err = db.GetEmailToDid(rp.db, []string{lastCommit.Author.Email}, true) 69 + if err != nil { 70 + l.Error("failed to get email to did mapping", "err", err) 71 + emailToDidMap = make(map[string]string) 113 72 } 114 - if xrpcResp.LastCommit.Author != nil { 115 - lastCommitInfo.Author.Name = xrpcResp.LastCommit.Author.Name 116 - lastCommitInfo.Author.Email = xrpcResp.LastCommit.Author.Email 117 - lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, xrpcResp.LastCommit.Author.When) 118 - } 73 + } else { 74 + emailToDidMap = make(map[string]string) 119 75 } 120 76 121 77 rp.pages.RepoTree(w, pages.RepoTreeParams{ ··· 124 80 Path: treePath, 125 81 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 126 82 EmailToDid: emailToDidMap, 127 - LastCommitInfo: lastCommitInfo, 83 + LastCommitInfo: lastCommit, 128 84 RepoTreeResponse: result, 129 85 }) 130 86 }
+194
appview/repo/vcsprovider.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/url" 7 + "time" 8 + 9 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/models" 13 + xrpcclient "tangled.org/core/appview/xrpcclient" 14 + "tangled.org/core/types" 15 + ) 16 + 17 + // vcsProvider abstracts VCS-specific XRPC calls for repo handlers, 18 + // returning normalised types regardless of whether the repo is git or pijul. 19 + type vcsProvider interface { 20 + // GetBlob fetches file contents and returns a normalised RepoBlob_Output. 21 + GetBlob(ctx context.Context, ref, path string) (*tangled.RepoBlob_Output, error) 22 + 23 + // GetTree fetches a directory listing and returns a normalised RepoTreeResponse. 24 + // For git, the directory-level last-commit info is also returned (nil for pijul). 25 + GetTree(ctx context.Context, ref, path string) (types.RepoTreeResponse, *types.LastCommitInfo, error) 26 + 27 + // RawBlobXrpcPath returns the XRPC endpoint path for raw-blob proxying. 28 + RawBlobXrpcPath() string 29 + 30 + // SetRawBlobQuery populates query parameters for a raw-blob proxy request. 31 + SetRawBlobQuery(q url.Values, ref, path, repo string) 32 + } 33 + 34 + // newVcsProvider returns the correct vcsProvider for the given repo's VCS type. 35 + func newVcsProvider(repo *models.Repo, xrpcc *indigoxrpc.Client) vcsProvider { 36 + repoId := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 37 + if repo.IsPijul() { 38 + return &pijulVcsProvider{xrpcc: xrpcc, repoId: repoId} 39 + } 40 + return &gitVcsProvider{xrpcc: xrpcc, repoId: repo.RepoIdentifier()} 41 + } 42 + 43 + // --- git provider --- 44 + 45 + type gitVcsProvider struct { 46 + xrpcc *indigoxrpc.Client 47 + repoId string 48 + } 49 + 50 + func (g *gitVcsProvider) GetBlob(ctx context.Context, ref, path string) (*tangled.RepoBlob_Output, error) { 51 + resp, err := tangled.RepoBlob(ctx, g.xrpcc, path, false, ref, g.repoId) 52 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 53 + return nil, xrpcerr 54 + } 55 + return resp, nil 56 + } 57 + 58 + func (g *gitVcsProvider) GetTree(ctx context.Context, ref, path string) (types.RepoTreeResponse, *types.LastCommitInfo, error) { 59 + xrpcResp, err := tangled.RepoTree(ctx, g.xrpcc, path, ref, g.repoId) 60 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 61 + return types.RepoTreeResponse{}, nil, xrpcerr 62 + } 63 + 64 + files := make([]types.NiceTree, len(xrpcResp.Files)) 65 + for i, f := range xrpcResp.Files { 66 + nf := types.NiceTree{ 67 + Name: f.Name, 68 + Mode: f.Mode, 69 + Size: int64(f.Size), 70 + } 71 + if f.Last_commit != nil { 72 + when, _ := time.Parse(time.RFC3339, f.Last_commit.When) 73 + nf.LastCommit = &types.LastCommitInfo{ 74 + Hash: plumbing.NewHash(f.Last_commit.Hash), 75 + Message: f.Last_commit.Message, 76 + When: when, 77 + } 78 + } 79 + files[i] = nf 80 + } 81 + 82 + result := types.RepoTreeResponse{ 83 + Ref: xrpcResp.Ref, 84 + Files: files, 85 + } 86 + if xrpcResp.Parent != nil { 87 + result.Parent = *xrpcResp.Parent 88 + } 89 + if xrpcResp.Dotdot != nil { 90 + result.DotDot = *xrpcResp.Dotdot 91 + } 92 + if xrpcResp.Readme != nil { 93 + result.ReadmeFileName = xrpcResp.Readme.Filename 94 + result.Readme = xrpcResp.Readme.Contents 95 + } 96 + 97 + var lastCommit *types.LastCommitInfo 98 + if xrpcResp.LastCommit != nil { 99 + when, _ := time.Parse(time.RFC3339, xrpcResp.LastCommit.When) 100 + lastCommit = &types.LastCommitInfo{ 101 + Hash: plumbing.NewHash(xrpcResp.LastCommit.Hash), 102 + Message: xrpcResp.LastCommit.Message, 103 + When: when, 104 + } 105 + if xrpcResp.LastCommit.Author != nil { 106 + lastCommit.Author.Name = xrpcResp.LastCommit.Author.Name 107 + lastCommit.Author.Email = xrpcResp.LastCommit.Author.Email 108 + lastCommit.Author.When, _ = time.Parse(time.RFC3339, xrpcResp.LastCommit.Author.When) 109 + } 110 + } 111 + 112 + return result, lastCommit, nil 113 + } 114 + 115 + func (g *gitVcsProvider) RawBlobXrpcPath() string { 116 + return "/xrpc/sh.tangled.repo.blob" 117 + } 118 + 119 + func (g *gitVcsProvider) SetRawBlobQuery(q url.Values, ref, path, repo string) { 120 + q.Set("repo", repo) 121 + q.Set("ref", ref) 122 + q.Set("path", path) 123 + q.Set("raw", "true") 124 + } 125 + 126 + // --- pijul provider --- 127 + 128 + type pijulVcsProvider struct { 129 + xrpcc *indigoxrpc.Client 130 + repoId string 131 + } 132 + 133 + func (p *pijulVcsProvider) GetBlob(ctx context.Context, ref, path string) (*tangled.RepoBlob_Output, error) { 134 + pResp, err := tangled.RepoPijulBlob(ctx, p.xrpcc, ref, path, p.repoId) 135 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 136 + return nil, xrpcerr 137 + } 138 + return &tangled.RepoBlob_Output{ 139 + Path: pResp.Path, 140 + Content: pResp.Contents, 141 + IsBinary: &pResp.Is_binary, 142 + }, nil 143 + } 144 + 145 + func (p *pijulVcsProvider) GetTree(ctx context.Context, ref, path string) (types.RepoTreeResponse, *types.LastCommitInfo, error) { 146 + xrpcResp, err := tangled.RepoPijulTree(ctx, p.xrpcc, ref, path, p.repoId) 147 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 148 + return types.RepoTreeResponse{}, nil, xrpcerr 149 + } 150 + 151 + files := make([]types.NiceTree, len(xrpcResp.Files)) 152 + for i, f := range xrpcResp.Files { 153 + files[i] = types.NiceTree{ 154 + Name: f.Name, 155 + Mode: f.Mode, 156 + Size: f.Size, 157 + } 158 + } 159 + 160 + result := types.RepoTreeResponse{ 161 + Ref: ref, 162 + Files: files, 163 + } 164 + if xrpcResp.Ref != nil { 165 + result.Ref = *xrpcResp.Ref 166 + } 167 + if xrpcResp.Parent != nil { 168 + result.Parent = *xrpcResp.Parent 169 + } 170 + if xrpcResp.Dotdot != nil { 171 + result.DotDot = *xrpcResp.Dotdot 172 + } 173 + if xrpcResp.Readme != nil { 174 + if xrpcResp.Readme.Filename != nil { 175 + result.ReadmeFileName = *xrpcResp.Readme.Filename 176 + } 177 + if xrpcResp.Readme.Contents != nil { 178 + result.Readme = *xrpcResp.Readme.Contents 179 + } 180 + } 181 + 182 + return result, nil, nil // pijul has no directory-level last-commit info 183 + } 184 + 185 + func (p *pijulVcsProvider) RawBlobXrpcPath() string { 186 + return "/xrpc/sh.tangled.repo.pijulBlob" 187 + } 188 + 189 + func (p *pijulVcsProvider) SetRawBlobQuery(q url.Values, ref, path, repo string) { 190 + q.Set("repo", repo) 191 + q.Set("channel", ref) 192 + q.Set("path", path) 193 + // pijul does not use raw=true 194 + }
+1
appview/reporesolver/resolver.go
··· 128 128 Topics: repo.Topics, 129 129 Knot: repo.Knot, 130 130 Spindle: repo.Spindle, 131 + Vcs: repo.Vcs, 131 132 Stats: *stats, 132 133 133 134 // fork repo upstream
+36
appview/state/knotstream.go
··· 87 87 switch msg.Nsid { 88 88 case tangled.GitRefUpdateNSID: 89 89 return ingestRefUpdate(ctx, d, enforcer, posthog, notifier, dev, c, cfClient, source, msg) 90 + case tangled.PijulRefUpdateNSID: 91 + return ingestPijulRefUpdate(d, source, msg) 90 92 case tangled.PipelineNSID: 91 93 return ingestPipeline(d, source, msg) 92 94 case knotdb.RepoDIDAssignNSID: ··· 303 305 } 304 306 305 307 return tx.Commit() 308 + } 309 + 310 + func ingestPijulRefUpdate(d *db.DB, source ec.Source, msg ec.Message) error { 311 + var record tangled.PijulRefUpdate 312 + if err := json.Unmarshal(msg.EventJson, &record); err != nil { 313 + return fmt.Errorf("unmarshal pijulRefUpdate: %w", err) 314 + } 315 + 316 + if record.CommitterDid == "" || len(record.Changes) == 0 { 317 + return nil 318 + } 319 + 320 + // Parse the AT URI to find the repo. 321 + repoAtURI, err := syntax.ParseATURI(record.Repo) 322 + if err != nil { 323 + return fmt.Errorf("invalid repo AT URI %q: %w", record.Repo, err) 324 + } 325 + 326 + repoDid := repoAtURI.Authority().String() 327 + repo, err := db.GetRepoByDid(d, repoDid) 328 + if err != nil { 329 + // Repo may be unknown to this appview; skip silently. 330 + return nil 331 + } 332 + 333 + if repo.Knot != source.Key() { 334 + return fmt.Errorf("pijulRefUpdate from %s for repo on %s, rejecting", source.Key(), repo.Knot) 335 + } 336 + 337 + return db.AddPunch(d, models.Punch{ 338 + Did: record.CommitterDid, 339 + Date: time.Now(), 340 + Count: len(record.Changes), 341 + }) 306 342 } 307 343 308 344 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+19
appview/state/router.go
··· 8 8 9 9 "github.com/go-chi/chi/v5" 10 10 "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/discussions" 11 12 "tangled.org/core/appview/issues" 12 13 "tangled.org/core/appview/knots" 13 14 "tangled.org/core/appview/labels" ··· 117 118 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 118 119 r.Use(mw.GoImport()) 119 120 r.Mount("/", s.RepoRouter(mw)) 121 + r.Mount("/discussions", s.DiscussionsRouter(mw)) 120 122 r.Mount("/issues", s.IssuesRouter(mw)) 121 123 r.Mount("/pulls", s.PullsRouter(mw)) 122 124 r.Mount("/pipelines", s.PipelinesRouter(mw)) ··· 310 312 log.SubLogger(s.logger, "issues"), 311 313 ) 312 314 return issues.Router(mw) 315 + } 316 + 317 + func (s *State) DiscussionsRouter(mw *middleware.Middleware) http.Handler { 318 + discussions := discussions.New( 319 + s.oauth, 320 + s.repoResolver, 321 + s.enforcer, 322 + s.pages, 323 + s.idResolver, 324 + s.mentionsResolver, 325 + s.db, 326 + s.config, 327 + s.notifier, 328 + s.validator, 329 + log.SubLogger(s.logger, "discussions"), 330 + ) 331 + return discussions.Router(mw) 313 332 } 314 333 315 334 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
+14 -1
appview/state/state.go
··· 431 431 } 432 432 l = l.With("defaultBranch", defaultBranch) 433 433 434 + vcs := strings.ToLower(strings.TrimSpace(r.FormValue("vcs"))) 435 + if vcs == "" { 436 + vcs = "git" 437 + } 438 + switch vcs { 439 + case "git", "pijul": 440 + default: 441 + s.pages.Notice(w, "repo", "Invalid repository type.") 442 + return 443 + } 444 + l = l.With("vcs", vcs) 445 + 434 446 description := r.FormValue("description") 435 447 if len([]rune(description)) > 140 { 436 448 s.pages.Notice(w, "repo", "Description must be 140 characters or fewer.") ··· 475 487 Rkey: rkey, 476 488 Name: repoName, 477 489 DefaultBranch: &defaultBranch, 490 + Vcs: &vcs, 478 491 } 479 492 createResp, xe := tangled.RepoCreate( 480 493 r.Context(), ··· 506 519 Created: time.Now(), 507 520 Labels: s.config.Label.DefaultLabelDefs, 508 521 RepoDid: repoDid, 522 + Vcs: vcs, 509 523 } 510 524 record := repo.AsRecord() 511 525 ··· 610 624 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 611 625 return 612 626 } 613 - 614 627 err = tx.Commit() 615 628 if err != nil { 616 629 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
··· 331 331 332 332 rm -f api/tangled/* 333 333 lexgen --build-file lexicon-build-config.json lexicons 334 - sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 335 334 # lexgen generates incomplete Marshaler/Unmarshaler for union types 336 335 find api/tangled/*.go -not -name "cbor_gen.go" -exec \ 337 336 sed -i '/^func.*\(MarshalCBOR\|UnmarshalCBOR\)/,/^}/ s/^/\/\/ /' {} +
+105
guard/guard.go
··· 1 1 package guard 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "errors" 6 7 "fmt" ··· 105 106 os.Exit(-1) 106 107 } 107 108 109 + if cmdParts[0] == "pijul" { 110 + if cmdParts[1] != "protocol" { 111 + l.Error("access denied: invalid pijul command", "command", sshCommand) 112 + fmt.Fprintln(os.Stderr, "access denied: invalid pijul command") 113 + return fmt.Errorf("access denied: invalid pijul command") 114 + } 115 + 116 + repoPath, version, _ := parsePijulProtocolArgs(cmdParts[2:]) 117 + if version != "" && version != "3" { 118 + l.Error("unsupported pijul protocol version", "version", version) 119 + fmt.Fprintln(os.Stderr, "unsupported pijul protocol version") 120 + return fmt.Errorf("unsupported pijul protocol version") 121 + } 122 + 123 + // If no repository is specified, this is a prove-only session. 124 + // (pijul identity prove connects without a repository path) 125 + if repoPath == "" { 126 + l.Info("pijul prove session (no repo)", 127 + "user", incomingUser, 128 + "client", clientIP) 129 + 130 + stdinReader := bufio.NewReader(os.Stdin) 131 + if err := handlePijulProve(l, endpoint, incomingUser, stdinReader); err != nil { 132 + l.Error("prove failed", "error", err) 133 + fmt.Fprintf(os.Stderr, "prove failed: %v\n", err) 134 + return fmt.Errorf("prove failed: %v", err) 135 + } 136 + 137 + l.Info("prove completed", 138 + "user", incomingUser, 139 + "success", true) 140 + return nil 141 + } 142 + 143 + qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, "pijul-protocol") 144 + if err != nil { 145 + l.Error("failed to run guard", "err", err) 146 + fmt.Fprintln(os.Stderr, err) 147 + os.Exit(1) 148 + } 149 + 150 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 151 + args := []string{"protocol", "--repository", fullPath} 152 + if version != "" { 153 + args = append(args, "--version", version) 154 + } 155 + 156 + l.Info("processing command", 157 + "user", incomingUser, 158 + "command", "pijul protocol", 159 + "repo", repoPath, 160 + "fullPath", fullPath, 161 + "client", clientIP) 162 + 163 + pijulCmd := exec.Command("pijul", args...) 164 + pijulCmd.Stdout = os.Stdout 165 + pijulCmd.Stderr = os.Stderr 166 + pijulCmd.Stdin = os.Stdin 167 + 168 + if err := pijulCmd.Run(); err != nil { 169 + l.Error("command failed", "error", err) 170 + fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 171 + return fmt.Errorf("command failed: %v", err) 172 + } 173 + 174 + l.Info("command completed", 175 + "user", incomingUser, 176 + "command", "pijul protocol", 177 + "repo", repoPath, 178 + "success", true) 179 + 180 + return nil 181 + } 182 + 108 183 gitCommand := cmdParts[0] 109 184 repoPath := cmdParts[1] 110 185 ··· 171 246 "success", true) 172 247 173 248 return nil 249 + } 250 + 251 + func parsePijulProtocolArgs(args []string) (string, string, error) { 252 + var repo string 253 + var version string 254 + for i := 0; i < len(args); i++ { 255 + arg := args[i] 256 + switch { 257 + case arg == "--repository" || arg == "-r": 258 + if i+1 >= len(args) { 259 + return "", "", fmt.Errorf("missing --repository value") 260 + } 261 + repo = args[i+1] 262 + i++ 263 + case strings.HasPrefix(arg, "--repository="): 264 + repo = strings.TrimPrefix(arg, "--repository=") 265 + case arg == "--version": 266 + if i+1 >= len(args) { 267 + return "", "", fmt.Errorf("missing --version value") 268 + } 269 + version = args[i+1] 270 + i++ 271 + case strings.HasPrefix(arg, "--version="): 272 + version = strings.TrimPrefix(arg, "--version=") 273 + } 274 + } 275 + if repo == "" { 276 + return "", "", fmt.Errorf("missing --repository") 277 + } 278 + return repo, version, nil 174 279 } 175 280 176 281 // runs guardAndQualifyRepo logic
+174
guard/prove.go
··· 1 + package guard 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "crypto/ed25519" 7 + "crypto/rand" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "log/slog" 12 + "math/big" 13 + "net/http" 14 + "os" 15 + "strings" 16 + ) 17 + 18 + // pijulPublicKey matches the JSON format sent by pijul's prove protocol. 19 + // Example: {"version":1,"algorithm":"Ed25519","key":"<base58>","signature":"<base58>"} 20 + type pijulPublicKey struct { 21 + Version int `json:"version"` 22 + Algorithm string `json:"algorithm"` 23 + Key string `json:"key"` 24 + Signature string `json:"signature"` 25 + Expires string `json:"expires,omitempty"` 26 + } 27 + 28 + // handlePijulProve handles the pijul identity prove protocol over stdin/stdout. 29 + // Protocol: 30 + // 1. Client sends: challenge <json_public_key>\n 31 + // 2. Server sends: <random_challenge>\n 32 + // 3. Client sends: prove <base58_signature>\n 33 + // 4. Server sends: \n (success) 34 + func handlePijulProve(l *slog.Logger, endpoint string, did string, reader *bufio.Reader) error { 35 + // Read the challenge request 36 + line, err := reader.ReadString('\n') 37 + if err != nil { 38 + return fmt.Errorf("failed to read from stdin: %w", err) 39 + } 40 + line = strings.TrimRight(line, "\n\r") 41 + 42 + if !strings.HasPrefix(line, "challenge ") { 43 + return fmt.Errorf("expected 'challenge' message, got: %q", line) 44 + } 45 + 46 + pubKeyJSON := strings.TrimPrefix(line, "challenge ") 47 + 48 + var pubKey pijulPublicKey 49 + if err := json.Unmarshal([]byte(pubKeyJSON), &pubKey); err != nil { 50 + return fmt.Errorf("failed to parse public key JSON: %w", err) 51 + } 52 + 53 + if pubKey.Algorithm != "Ed25519" { 54 + return fmt.Errorf("unsupported algorithm: %s", pubKey.Algorithm) 55 + } 56 + 57 + // Decode the base58 public key 58 + rawPubKey, err := base58Decode(pubKey.Key) 59 + if err != nil { 60 + return fmt.Errorf("failed to decode public key: %w", err) 61 + } 62 + if len(rawPubKey) != ed25519.PublicKeySize { 63 + return fmt.Errorf("invalid public key size: %d", len(rawPubKey)) 64 + } 65 + 66 + // Generate random challenge (30 alphanumeric characters, matching Nest) 67 + challenge := generateChallenge(30) 68 + 69 + l.Info("prove: sending challenge", "did", did, "pubkey", pubKey.Key) 70 + 71 + // Send the challenge 72 + fmt.Fprintf(os.Stdout, "%s\n", challenge) 73 + 74 + // Read the prove response 75 + line, err = reader.ReadString('\n') 76 + if err != nil { 77 + return fmt.Errorf("failed to read prove response: %w", err) 78 + } 79 + line = strings.TrimRight(line, "\n\r") 80 + 81 + if !strings.HasPrefix(line, "prove ") { 82 + return fmt.Errorf("expected 'prove' message, got: %q", line) 83 + } 84 + 85 + signatureB58 := strings.TrimPrefix(line, "prove ") 86 + 87 + // Decode the base58 signature 88 + signature, err := base58Decode(signatureB58) 89 + if err != nil { 90 + return fmt.Errorf("failed to decode signature: %w", err) 91 + } 92 + 93 + // Verify the Ed25519 signature 94 + if !ed25519.Verify(ed25519.PublicKey(rawPubKey), []byte(challenge), signature) { 95 + l.Warn("prove: signature verification failed", "did", did) 96 + fmt.Fprintf(os.Stderr, "Signature verification failed\n") 97 + return fmt.Errorf("signature verification failed") 98 + } 99 + 100 + l.Info("prove: signature verified", "did", did, "pubkey", pubKey.Key) 101 + 102 + // Store the pubkey → DID mapping via knotserver internal API 103 + if err := storePijulProve(endpoint, pubKey.Key, did); err != nil { 104 + l.Error("prove: failed to store mapping", "err", err) 105 + return fmt.Errorf("failed to store prove result: %w", err) 106 + } 107 + 108 + // Send success (empty line) 109 + fmt.Fprintf(os.Stdout, "\n") 110 + 111 + l.Info("prove: completed successfully", "did", did, "pubkey", pubKey.Key) 112 + return nil 113 + } 114 + 115 + func storePijulProve(endpoint, publicKey, did string) error { 116 + body, _ := json.Marshal(map[string]string{ 117 + "public_key": publicKey, 118 + "did": did, 119 + }) 120 + 121 + resp, err := http.Post(endpoint+"/pijul-prove", "application/json", bytes.NewReader(body)) 122 + if err != nil { 123 + return err 124 + } 125 + defer resp.Body.Close() 126 + 127 + if resp.StatusCode != http.StatusOK { 128 + respBody, _ := io.ReadAll(resp.Body) 129 + return fmt.Errorf("knotserver returned %d: %s", resp.StatusCode, string(respBody)) 130 + } 131 + return nil 132 + } 133 + 134 + func generateChallenge(length int) string { 135 + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 136 + b := make([]byte, length) 137 + for i := range b { 138 + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) 139 + b[i] = charset[n.Int64()] 140 + } 141 + return string(b) 142 + } 143 + 144 + // base58Decode decodes a base58-encoded string (Bitcoin alphabet). 145 + func base58Decode(input string) ([]byte, error) { 146 + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 147 + 148 + result := big.NewInt(0) 149 + base := big.NewInt(58) 150 + 151 + for _, c := range input { 152 + idx := strings.IndexRune(alphabet, c) 153 + if idx < 0 { 154 + return nil, fmt.Errorf("invalid base58 character: %c", c) 155 + } 156 + result.Mul(result, base) 157 + result.Add(result, big.NewInt(int64(idx))) 158 + } 159 + 160 + // Count leading '1's (zero bytes) 161 + leadingZeros := 0 162 + for _, c := range input { 163 + if c != '1' { 164 + break 165 + } 166 + leadingZeros++ 167 + } 168 + 169 + resultBytes := result.Bytes() 170 + decoded := make([]byte, leadingZeros+len(resultBytes)) 171 + copy(decoded[leadingZeros:], resultBytes) 172 + 173 + return decoded, nil 174 + }
+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 {
+7
knotserver/db/db.go
··· 80 80 id integer primary key autoincrement, 81 81 name text unique 82 82 ); 83 + 84 + create table if not exists pijul_signing_keys ( 85 + public_key text primary key, 86 + did text not null, 87 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 88 + foreign key (did) references known_dids(did) on delete cascade 89 + ); 83 90 `) 84 91 if err != nil { 85 92 return nil, err
+54
knotserver/db/pijulkeys.go
··· 1 + package db 2 + 3 + import "strings" 4 + 5 + // StorePijulSigningKey stores a verified pijul public key → DID mapping. 6 + func (d *DB) StorePijulSigningKey(publicKey, did string) error { 7 + _, err := d.db.Exec(` 8 + insert or replace into pijul_signing_keys (public_key, did) 9 + values (?, ?) 10 + `, publicKey, did) 11 + return err 12 + } 13 + 14 + // GetDidForPijulKey returns the DID associated with a pijul public key. 15 + func (d *DB) GetDidForPijulKey(publicKey string) (string, error) { 16 + var did string 17 + err := d.db.QueryRow(` 18 + select did from pijul_signing_keys where public_key = ? 19 + `, publicKey).Scan(&did) 20 + return did, err 21 + } 22 + 23 + // GetPijulKeyToDid returns a map of pijul public key → DID for the given keys. 24 + func (d *DB) GetPijulKeyToDid(keys []string) (map[string]string, error) { 25 + if len(keys) == 0 { 26 + return make(map[string]string), nil 27 + } 28 + 29 + placeholders := make([]string, len(keys)) 30 + args := make([]any, len(keys)) 31 + for i, k := range keys { 32 + placeholders[i] = "?" 33 + args[i] = k 34 + } 35 + 36 + rows, err := d.db.Query(` 37 + select public_key, did from pijul_signing_keys 38 + where public_key in (`+strings.Join(placeholders, ",")+`) 39 + `, args...) 40 + if err != nil { 41 + return nil, err 42 + } 43 + defer rows.Close() 44 + 45 + result := make(map[string]string) 46 + for rows.Next() { 47 + var key, did string 48 + if err := rows.Scan(&key, &did); err != nil { 49 + return nil, err 50 + } 51 + result[key] = did 52 + } 53 + return result, rows.Err() 54 + }
+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
··· 17 17 "tangled.org/core/api/tangled" 18 18 "tangled.org/core/knotserver/db" 19 19 "tangled.org/core/knotserver/git" 20 + 20 21 "tangled.org/core/log" 21 22 "tangled.org/core/rbac" 22 23 "tangled.org/core/workflow"
+46 -1
knotserver/internal.go
··· 87 87 return 88 88 } 89 89 90 - components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 90 + // did:foo/repo-name or 91 + // handle/repo-name or 92 + // any of the above with a leading slash (/) 93 + components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'\""), "/"), "/") 91 94 l.Info("command components", "components", components) 92 95 93 96 var rbacResource string ··· 170 173 } 171 174 172 175 if gitCommand == "git-receive-pack" { 176 + ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 177 + if err != nil || !ok { 178 + w.WriteHeader(http.StatusForbidden) 179 + fmt.Fprint(w, repo) 180 + return 181 + } 182 + } 183 + if gitCommand == "pijul-protocol" { 173 184 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 174 185 if err != nil || !ok { 175 186 w.WriteHeader(http.StatusForbidden) ··· 476 487 return nil 477 488 } 478 489 490 + // PijulProve stores a verified pijul public key → DID mapping. 491 + // Called by the guard after a successful prove challenge-response. 492 + func (h *InternalHandle) PijulProve(w http.ResponseWriter, r *http.Request) { 493 + l := h.l.With("handler", "PijulProve") 494 + 495 + var req struct { 496 + PublicKey string `json:"public_key"` 497 + Did string `json:"did"` 498 + } 499 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 500 + l.Error("failed to decode request", "err", err) 501 + w.WriteHeader(http.StatusBadRequest) 502 + fmt.Fprintln(w, "invalid request body") 503 + return 504 + } 505 + 506 + if req.PublicKey == "" || req.Did == "" { 507 + w.WriteHeader(http.StatusBadRequest) 508 + fmt.Fprintln(w, "missing public_key or did") 509 + return 510 + } 511 + 512 + if err := h.db.StorePijulSigningKey(req.PublicKey, req.Did); err != nil { 513 + l.Error("failed to store pijul signing key", "err", err, "did", req.Did) 514 + w.WriteHeader(http.StatusInternalServerError) 515 + fmt.Fprintln(w, "failed to store signing key") 516 + return 517 + } 518 + 519 + l.Info("stored pijul signing key", "did", req.Did, "public_key", req.PublicKey) 520 + w.WriteHeader(http.StatusOK) 521 + } 522 + 479 523 func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 480 524 r := chi.NewRouter() 481 525 l := log.FromContext(ctx) ··· 495 539 r.Get("/keys", h.InternalKeys) 496 540 r.Get("/guard", h.Guard) 497 541 r.Post("/hooks/post-receive", h.PostReceiveHook) 542 + r.Post("/pijul-prove", h.PijulProve) 498 543 r.Mount("/debug", middleware.Profiler()) 499 544 500 545 return r
+518
knotserver/pijul/change.go
··· 1 + package pijul 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "strconv" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // Change represents a Pijul change (analogous to a Git commit) 15 + type Change struct { 16 + // Hash is the unique identifier for this change (base32 encoded) 17 + Hash string `json:"hash"` 18 + 19 + // Authors who created this change 20 + Authors []Author `json:"authors"` 21 + 22 + // Message is the change description 23 + Message string `json:"message"` 24 + 25 + // Timestamp when the change was recorded 26 + Timestamp time.Time `json:"timestamp"` 27 + 28 + // Dependencies are hashes of changes this change depends on 29 + Dependencies []string `json:"dependencies,omitempty"` 30 + 31 + // Channel where this change exists 32 + Channel string `json:"channel,omitempty"` 33 + 34 + // State is the Pijul Merkle state hash of the channel after this change 35 + // was applied. Populated when running pijul log --state. 36 + State string `json:"state,omitempty"` 37 + } 38 + 39 + // Author represents a change author 40 + type Author struct { 41 + Name string `json:"name"` 42 + Email string `json:"email,omitempty"` 43 + // DID is the AT Protocol decentralized identifier of the author. 44 + // Populated from the change's unhashed identity section when present. 45 + DID string `json:"did,omitempty"` 46 + } 47 + 48 + // Changes returns a list of changes in the repository 49 + // offset and limit control pagination 50 + func (p *PijulRepo) Changes(offset, limit int) ([]Change, error) { 51 + args := []string{"--offset", strconv.Itoa(offset), "--limit", strconv.Itoa(limit)} 52 + 53 + if p.channelName != "" { 54 + args = append(args, "--channel", p.channelName) 55 + } 56 + 57 + output, err := p.log(args...) 58 + if err != nil { 59 + if isNoChangesError(err) { 60 + return []Change{}, nil 61 + } 62 + return nil, fmt.Errorf("pijul log: %w", err) 63 + } 64 + 65 + return parseLogOutput(output) 66 + } 67 + 68 + // TotalChanges returns the total number of changes in the current channel 69 + func (p *PijulRepo) TotalChanges() (int, error) { 70 + // pijul log doesn't have a --count option, so we need to count 71 + // We can use pijul log with a large limit or iterate 72 + args := []string{"--hash-only"} 73 + 74 + if p.channelName != "" { 75 + args = append(args, "--channel", p.channelName) 76 + } 77 + 78 + output, err := p.log(args...) 79 + if err != nil { 80 + if isNoChangesError(err) { 81 + return 0, nil 82 + } 83 + return 0, fmt.Errorf("pijul log: %w", err) 84 + } 85 + 86 + // Count lines (each line is a change hash) 87 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 88 + if len(lines) == 1 && lines[0] == "" { 89 + return 0, nil 90 + } 91 + 92 + return len(lines), nil 93 + } 94 + 95 + func isNoChangesError(err error) bool { 96 + if err == nil { 97 + return false 98 + } 99 + lower := strings.ToLower(err.Error()) 100 + return strings.Contains(lower, "no changes") || strings.Contains(lower, "no change") 101 + } 102 + 103 + // GetChange retrieves details for a specific change by hash 104 + func (p *PijulRepo) GetChange(hash string) (*Change, error) { 105 + // Use pijul change to get change details 106 + output, err := p.change(hash) 107 + if err != nil { 108 + return nil, fmt.Errorf("pijul change %s: %w", hash, err) 109 + } 110 + 111 + return parseChangeOutput(hash, output) 112 + } 113 + 114 + // parseLogOutput parses the output of pijul log 115 + // Expected format (default output): 116 + // 117 + // Hash: XXXXX 118 + // Author: Name <email> 119 + // Date: 2024-01-01 12:00:00 120 + // 121 + // Message line 1 122 + // Message line 2 123 + func parseLogOutput(output []byte) ([]Change, error) { 124 + var changes []Change 125 + scanner := bufio.NewScanner(bytes.NewReader(output)) 126 + 127 + var current *Change 128 + var messageLines []string 129 + inMessage := false 130 + 131 + for scanner.Scan() { 132 + line := scanner.Text() 133 + 134 + if strings.HasPrefix(line, "Hash: ") || strings.HasPrefix(line, "Change ") { 135 + // Save previous change if exists 136 + if current != nil { 137 + current.Message = strings.TrimSpace(strings.Join(messageLines, "\n")) 138 + changes = append(changes, *current) 139 + } 140 + 141 + hashLine := line 142 + if strings.HasPrefix(hashLine, "Change ") { 143 + hashLine = strings.Replace(hashLine, "Change ", "Hash: ", 1) 144 + } 145 + current = &Change{ 146 + Hash: strings.TrimPrefix(hashLine, "Hash: "), 147 + } 148 + messageLines = nil 149 + inMessage = false 150 + continue 151 + } 152 + 153 + if current == nil { 154 + continue 155 + } 156 + 157 + if strings.HasPrefix(line, "Author: ") { 158 + authorStr := strings.TrimPrefix(line, "Author: ") 159 + author := parseAuthor(authorStr) 160 + current.Authors = append(current.Authors, author) 161 + continue 162 + } 163 + 164 + if strings.HasPrefix(line, "State: ") { 165 + current.State = strings.TrimPrefix(line, "State: ") 166 + continue 167 + } 168 + 169 + if strings.HasPrefix(line, "Date: ") { 170 + dateStr := strings.TrimPrefix(line, "Date: ") 171 + if t, err := parseTimestamp(dateStr); err == nil { 172 + current.Timestamp = t 173 + } else { 174 + slog.Warn("failed to parse pijul log timestamp", "value", dateStr, "error", err) 175 + } 176 + continue 177 + } 178 + 179 + // Empty line before message 180 + if line == "" && !inMessage { 181 + inMessage = true 182 + continue 183 + } 184 + 185 + if inMessage { 186 + messageLines = append(messageLines, strings.TrimPrefix(line, " ")) 187 + } 188 + } 189 + 190 + // Don't forget the last change 191 + if current != nil { 192 + current.Message = strings.TrimSpace(strings.Join(messageLines, "\n")) 193 + changes = append(changes, *current) 194 + } 195 + 196 + return changes, scanner.Err() 197 + } 198 + 199 + // parseChangeOutput parses the output of pijul change <hash> 200 + // 201 + // Actual pijul output format: 202 + // 203 + // message = 'some message' 204 + // timestamp = '2026-02-13T10:48:48.153366436Z' 205 + // 206 + // [[authors]] 207 + // key = '51vRw7nuTkQZMxpJ8CtV1bmLNFyPngpPee8WQt1bZnJM' 208 + // 209 + // # Dependencies 210 + // [2] HASH # 211 + // 212 + // # Hunks 213 + // ... 214 + func parseChangeOutput(hash string, output []byte) (*Change, error) { 215 + change := &Change{Hash: hash} 216 + 217 + scanner := bufio.NewScanner(bytes.NewReader(output)) 218 + inDeps := false 219 + inIdentity := false // inside [identity] unhashed section 220 + 221 + for scanner.Scan() { 222 + line := scanner.Text() 223 + 224 + // TOML-style key = 'value' lines at the top level 225 + if strings.HasPrefix(line, "message = ") { 226 + change.Message = unquoteTOMLValue(strings.TrimPrefix(line, "message = ")) 227 + inDeps = false 228 + inIdentity = false 229 + continue 230 + } 231 + 232 + if strings.HasPrefix(line, "timestamp = ") { 233 + ts := unquoteTOMLValue(strings.TrimPrefix(line, "timestamp = ")) 234 + if t, err := parseTimestamp(ts); err == nil { 235 + change.Timestamp = t 236 + } else { 237 + slog.Warn("failed to parse pijul change timestamp", "hash", hash, "value", ts, "error", err) 238 + } 239 + inDeps = false 240 + inIdentity = false 241 + continue 242 + } 243 + 244 + // Author key lines: key = '...' 245 + if strings.HasPrefix(line, "key = ") { 246 + name := unquoteTOMLValue(strings.TrimPrefix(line, "key = ")) 247 + change.Authors = append(change.Authors, Author{Name: name}) 248 + continue 249 + } 250 + 251 + // DID in hashed [[authors]]: did = 'did:plc:...' 252 + if !inIdentity && strings.HasPrefix(line, "did = ") { 253 + did := unquoteTOMLValue(strings.TrimPrefix(line, "did = ")) 254 + change.Authors = append(change.Authors, Author{DID: did}) 255 + continue 256 + } 257 + 258 + // Unhashed [identity] section — written by knot server at push time 259 + if line == "[identity]" { 260 + inIdentity = true 261 + inDeps = false 262 + continue 263 + } 264 + 265 + // did = 'did:plc:...' inside [identity] 266 + if inIdentity && strings.HasPrefix(line, "did = ") { 267 + did := unquoteTOMLValue(strings.TrimPrefix(line, "did = ")) 268 + // Attach DID to the last author, or create a synthetic author entry 269 + if len(change.Authors) > 0 { 270 + change.Authors[len(change.Authors)-1].DID = did 271 + } else { 272 + change.Authors = append(change.Authors, Author{DID: did}) 273 + } 274 + continue 275 + } 276 + 277 + // Section headers 278 + if strings.HasPrefix(line, "# Dependencies") { 279 + inDeps = true 280 + inIdentity = false 281 + continue 282 + } 283 + if strings.HasPrefix(line, "# ") { 284 + inDeps = false 285 + inIdentity = false 286 + continue 287 + } 288 + if strings.HasPrefix(line, "[") && line != "[identity]" { 289 + inIdentity = false 290 + } 291 + 292 + // Dependency lines: "[2] HASH # optional comment" 293 + if inDeps && strings.TrimSpace(line) != "" { 294 + depLine := strings.TrimSpace(line) 295 + // Strip leading "[N] " prefix 296 + if idx := strings.Index(depLine, "] "); idx != -1 { 297 + depLine = depLine[idx+2:] 298 + } 299 + // Strip trailing " # comment" 300 + if idx := strings.Index(depLine, " #"); idx != -1 { 301 + depLine = depLine[:idx] 302 + } 303 + depLine = strings.TrimSpace(depLine) 304 + if depLine != "" { 305 + change.Dependencies = append(change.Dependencies, depLine) 306 + } 307 + continue 308 + } 309 + } 310 + 311 + return change, scanner.Err() 312 + } 313 + 314 + // unquoteTOMLValue strips surrounding single or double quotes from a TOML value. 315 + func unquoteTOMLValue(s string) string { 316 + s = strings.TrimSpace(s) 317 + if len(s) >= 2 { 318 + if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') { 319 + return s[1 : len(s)-1] 320 + } 321 + } 322 + return s 323 + } 324 + 325 + // parseAuthor parses an author string like "Name <email>" 326 + func parseAuthor(s string) Author { 327 + s = strings.TrimSpace(s) 328 + 329 + // Try to extract email from angle brackets 330 + if start := strings.Index(s, "<"); start != -1 { 331 + if end := strings.Index(s, ">"); end > start { 332 + return Author{ 333 + Name: strings.TrimSpace(s[:start]), 334 + Email: strings.TrimSpace(s[start+1 : end]), 335 + } 336 + } 337 + } 338 + 339 + return Author{Name: s} 340 + } 341 + 342 + // parseTimestamp parses pijul timestamp formats 343 + func parseTimestamp(s string) (time.Time, error) { 344 + s = strings.TrimSpace(s) 345 + 346 + // Try RFC3339 first (pijul's primary format) 347 + if t, err := time.Parse(time.RFC3339, s); err == nil { 348 + return t, nil 349 + } 350 + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { 351 + return t, nil 352 + } 353 + 354 + // RFC 1123Z / RFC 2822 style: "Fri, 13 Feb 2026 10:48:48 +0000" 355 + // This is the actual format pijul log outputs. 356 + if t, err := time.Parse(time.RFC1123Z, s); err == nil { 357 + return t, nil 358 + } 359 + if t, err := time.Parse(time.RFC1123, s); err == nil { 360 + return t, nil 361 + } 362 + 363 + // Fallback: space-separated with timezone offset 364 + if t, err := time.Parse("2006-01-02 15:04:05 -0700", s); err == nil { 365 + return t, nil 366 + } 367 + 368 + // Fallback: no timezone, assume UTC 369 + if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC); err == nil { 370 + return t, nil 371 + } 372 + 373 + return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s) 374 + } 375 + 376 + // LatestChangeInChannel returns the most recent change on the given channel, 377 + // or nil if the channel has no changes. The channel name overrides whatever 378 + // channelName is set on the PijulRepo instance. 379 + func (p *PijulRepo) LatestChangeInChannel(channelName string) (*Change, error) { 380 + args := []string{"--offset", "0", "--limit", "1", "--channel", channelName} 381 + output, err := p.log(args...) 382 + if err != nil { 383 + if isNoChangesError(err) { 384 + return nil, nil 385 + } 386 + return nil, fmt.Errorf("pijul log --channel %s: %w", channelName, err) 387 + } 388 + changes, err := parseLogOutput(output) 389 + if err != nil { 390 + return nil, err 391 + } 392 + if len(changes) == 0 { 393 + return nil, nil 394 + } 395 + return &changes[0], nil 396 + } 397 + 398 + // LogEntry is a single change with its sequence position (oldest = 0). 399 + type LogEntry struct { 400 + Pos uint64 401 + Hash string 402 + State string 403 + } 404 + 405 + // LogWithState returns all changes in the channel in oldest-first order with 406 + // sequence positions (0 = oldest) and Merkle state hashes. Used by the pijul 407 + // HTTP remote protocol to serve ?changelist and ?state endpoints. 408 + func (p *PijulRepo) LogWithState(channelName string) ([]LogEntry, error) { 409 + args := []string{"--state"} 410 + if channelName != "" { 411 + args = append(args, "--channel", channelName) 412 + } 413 + output, err := p.log(args...) 414 + if err != nil { 415 + if isNoChangesError(err) { 416 + return nil, nil 417 + } 418 + return nil, fmt.Errorf("pijul log --state --channel %s: %w", channelName, err) 419 + } 420 + 421 + changes, err := parseLogOutput(output) 422 + if err != nil { 423 + return nil, err 424 + } 425 + 426 + // parseLogOutput returns newest-first; reverse to oldest-first and assign positions. 427 + entries := make([]LogEntry, len(changes)) 428 + for i, c := range changes { 429 + entries[len(changes)-1-i] = LogEntry{ 430 + Pos: uint64(len(changes) - 1 - i), 431 + Hash: c.Hash, 432 + State: c.State, 433 + } 434 + } 435 + return entries, nil 436 + } 437 + 438 + // ChannelState returns the Merkle state hash of the given channel after the 439 + // most recent change. It runs `pijul log --state --limit 1 --channel <channel>` 440 + // and parses the "State: HASH" line. Returns "" if the channel has no changes. 441 + func (p *PijulRepo) ChannelState(channelName string) (string, error) { 442 + args := []string{"--state", "--limit", "1", "--channel", channelName} 443 + output, err := p.log(args...) 444 + if err != nil { 445 + if isNoChangesError(err) { 446 + return "", nil 447 + } 448 + return "", fmt.Errorf("pijul log --state --channel %s: %w", channelName, err) 449 + } 450 + return parseStateFromLog(output), nil 451 + } 452 + 453 + // parseStateFromLog extracts the "State: HASH" value from pijul log --state output. 454 + func parseStateFromLog(output []byte) string { 455 + scanner := bufio.NewScanner(bytes.NewReader(output)) 456 + for scanner.Scan() { 457 + line := scanner.Text() 458 + if strings.HasPrefix(line, "State: ") { 459 + return strings.TrimPrefix(line, "State: ") 460 + } 461 + } 462 + return "" 463 + } 464 + 465 + // ChangeJSON represents the JSON output format for pijul log --json 466 + type ChangeJSON struct { 467 + Hash string `json:"hash"` 468 + Authors []string `json:"authors"` 469 + Message string `json:"message"` 470 + Timestamp string `json:"timestamp"` 471 + Dependencies []string `json:"dependencies,omitempty"` 472 + } 473 + 474 + // ChangesJSON returns changes using JSON output format (if available in pijul version) 475 + func (p *PijulRepo) ChangesJSON(offset, limit int) ([]Change, error) { 476 + args := []string{ 477 + "--offset", strconv.Itoa(offset), 478 + "-n", strconv.Itoa(limit), 479 + "--json", 480 + } 481 + 482 + if p.channelName != "" { 483 + args = append(args, "--channel", p.channelName) 484 + } 485 + 486 + output, err := p.log(args...) 487 + if err != nil { 488 + // Fall back to text parsing if JSON not supported 489 + return p.Changes(offset, limit) 490 + } 491 + 492 + var jsonChanges []ChangeJSON 493 + if err := json.Unmarshal(output, &jsonChanges); err != nil { 494 + // Fall back to text parsing if JSON parsing fails 495 + return p.Changes(offset, limit) 496 + } 497 + 498 + changes := make([]Change, len(jsonChanges)) 499 + for i, jc := range jsonChanges { 500 + changes[i] = Change{ 501 + Hash: jc.Hash, 502 + Message: jc.Message, 503 + Dependencies: jc.Dependencies, 504 + } 505 + 506 + for _, authorStr := range jc.Authors { 507 + changes[i].Authors = append(changes[i].Authors, parseAuthor(authorStr)) 508 + } 509 + 510 + if t, err := parseTimestamp(jc.Timestamp); err == nil { 511 + changes[i].Timestamp = t 512 + } else if jc.Timestamp != "" { 513 + slog.Warn("failed to parse pijul JSON timestamp", "hash", jc.Hash, "value", jc.Timestamp, "error", err) 514 + } 515 + } 516 + 517 + return changes, nil 518 + }
+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 + }
+131
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 + args := []string{changeHash} 72 + if p.channelName != "" { 73 + args = append(args, "--channel", p.channelName) 74 + } 75 + _, err := p.runPijulCmd("apply", args...) 76 + return err 77 + } 78 + 79 + // Unrecord removes a change from the channel and resets the working copy 80 + // to match (like git reset --hard). 81 + func (p *PijulRepo) Unrecord(changeHash string) error { 82 + args := []string{changeHash} 83 + if p.channelName != "" { 84 + args = append(args, "--channel", p.channelName) 85 + } 86 + if _, err := p.runPijulCmd("unrecord", args...); err != nil { 87 + return err 88 + } 89 + 90 + // Reset working copy to match the channel state 91 + resetArgs := []string{} 92 + if p.channelName != "" { 93 + resetArgs = append(resetArgs, "--channel", p.channelName) 94 + } 95 + _, err := p.runPijulCmd("reset", resetArgs...) 96 + return err 97 + } 98 + 99 + // Record creates a new change (like git commit) 100 + func (p *PijulRepo) Record(message string, authors []Author) error { 101 + args := []string{"-m", message} 102 + 103 + for _, author := range authors { 104 + authorStr := author.Name 105 + if author.Email != "" { 106 + authorStr = fmt.Sprintf("%s <%s>", author.Name, author.Email) 107 + } 108 + args = append(args, "--author", authorStr) 109 + } 110 + 111 + if p.channelName != "" { 112 + args = append(args, "--channel", p.channelName) 113 + } 114 + 115 + _, err := p.runPijulCmd("record", args...) 116 + return err 117 + } 118 + 119 + // Add adds files to be tracked 120 + func (p *PijulRepo) Add(paths ...string) error { 121 + args := append([]string{}, paths...) 122 + _, err := p.runPijulCmd("add", args...) 123 + return err 124 + } 125 + 126 + // Remove removes files from tracking 127 + func (p *PijulRepo) Remove(paths ...string) error { 128 + args := append([]string{}, paths...) 129 + _, err := p.runPijulCmd("remove", args...) 130 + return err 131 + }
+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 + }
+349
knotserver/pijul_http.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "os" 11 + "path/filepath" 12 + "strconv" 13 + "strings" 14 + 15 + "github.com/go-chi/chi/v5" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/knotserver/db" 18 + "tangled.org/core/knotserver/pijul" 19 + "tangled.org/core/rbac" 20 + ) 21 + 22 + // PijulHttpGet handles GET /{did}/{name}/.pijul — the pijul HTTP remote read protocol. 23 + // Dispatches on query string: ?id, ?state, ?changelist, ?change, ?tag. 24 + func (h *Knot) PijulHttpGet(w http.ResponseWriter, r *http.Request) { 25 + repoPath, ok := repoPathFromcontext(r.Context()) 26 + if !ok { 27 + http.Error(w, "repository not found", http.StatusNotFound) 28 + return 29 + } 30 + 31 + q := r.URL.Query() 32 + switch { 33 + case q.Has("id"): 34 + h.pijulGetId(w, repoPath, q.Get("channel")) 35 + case q.Has("state"): 36 + h.pijulGetState(w, r, repoPath, q.Get("channel"), q.Get("state")) 37 + case q.Has("changelist"): 38 + h.pijulGetChangelist(w, r, repoPath, q.Get("channel"), q.Get("changelist")) 39 + case q.Has("change"): 40 + h.pijulGetChange(w, r, repoPath, q.Get("change")) 41 + case q.Has("tag"): 42 + h.pijulGetTag(w, r, repoPath, q.Get("tag")) 43 + default: 44 + http.NotFound(w, r) 45 + } 46 + } 47 + 48 + // PijulHttpPost handles POST /{did}/{name}/.pijul — the pijul HTTP remote write protocol. 49 + // Dispatches on query string: ?apply (upload + apply a change), ?tagup (upload a tag). 50 + func (h *Knot) PijulHttpPost(w http.ResponseWriter, r *http.Request) { 51 + repoPath, ok := repoPathFromcontext(r.Context()) 52 + if !ok { 53 + http.Error(w, "repository not found", http.StatusNotFound) 54 + return 55 + } 56 + 57 + q := r.URL.Query() 58 + if hash := q.Get("apply"); hash != "" { 59 + h.pijulApplyChange(w, r, repoPath, hash, q.Get("to_channel")) 60 + } else if hash := q.Get("tagup"); hash != "" { 61 + h.pijulTagUp(w, r, repoPath, hash, q.Get("to_channel")) 62 + } else { 63 + http.Error(w, "unknown pijul operation", http.StatusBadRequest) 64 + } 65 + } 66 + 67 + // pijulGetId returns a stable 16-byte remote identifier for the given repo+channel. 68 + // The pijul client uses this to detect remote identity changes and invalidate its cache. 69 + func (h *Knot) pijulGetId(w http.ResponseWriter, repoPath, channel string) { 70 + sum := sha256.Sum256([]byte(repoPath + ":" + channel)) 71 + w.Header().Set("Content-Type", "application/octet-stream") 72 + w.Write(sum[:16]) 73 + } 74 + 75 + // pijulGetState returns the Merkle state at position posStr. 76 + // Response format: "{pos} {merkle1} {merkle2}\n" (space-separated). 77 + // When posStr is empty, returns the latest (newest) state. 78 + func (h *Knot) pijulGetState(w http.ResponseWriter, r *http.Request, repoPath, channel, posStr string) { 79 + pr, err := pijul.Open(repoPath, channel) 80 + if err != nil { 81 + http.Error(w, "failed to open repository", http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + entries, err := pr.LogWithState(channel) 86 + if err != nil { 87 + h.l.Error("pijul log --state failed", "err", err) 88 + http.Error(w, "failed to read change log", http.StatusInternalServerError) 89 + return 90 + } 91 + 92 + if len(entries) == 0 { 93 + // Empty channel: no state yet. 94 + http.Error(w, "no changes in channel", http.StatusNotFound) 95 + return 96 + } 97 + 98 + var pos uint64 99 + if posStr == "" { 100 + pos = entries[len(entries)-1].Pos // latest 101 + } else { 102 + pos, err = strconv.ParseUint(posStr, 10, 64) 103 + if err != nil { 104 + http.Error(w, "invalid position", http.StatusBadRequest) 105 + return 106 + } 107 + if pos >= uint64(len(entries)) { 108 + pos = uint64(len(entries) - 1) 109 + } 110 + } 111 + 112 + entry := entries[pos] 113 + // Return "{pos} {merkle} {merkle}\n" 114 + // We use the same Merkle for both channel state and tag state (no tag support yet). 115 + fmt.Fprintf(w, "%d %s %s\n", entry.Pos, entry.State, entry.State) 116 + } 117 + 118 + // pijulGetChangelist returns all changes from position fromStr onwards (oldest-first). 119 + // Response format: one line per change: "{pos}.{hash}.{merkle}\n", terminated by an empty line. 120 + func (h *Knot) pijulGetChangelist(w http.ResponseWriter, r *http.Request, repoPath, channel, fromStr string) { 121 + pr, err := pijul.Open(repoPath, channel) 122 + if err != nil { 123 + http.Error(w, "failed to open repository", http.StatusInternalServerError) 124 + return 125 + } 126 + 127 + entries, err := pr.LogWithState(channel) 128 + if err != nil { 129 + h.l.Error("pijul log --state failed", "err", err) 130 + http.Error(w, "failed to read change log", http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + var from uint64 135 + if fromStr != "" { 136 + from, err = strconv.ParseUint(fromStr, 10, 64) 137 + if err != nil { 138 + http.Error(w, "invalid from position", http.StatusBadRequest) 139 + return 140 + } 141 + } 142 + 143 + w.Header().Set("Content-Type", "text/plain") 144 + for _, e := range entries { 145 + if e.Pos < from { 146 + continue 147 + } 148 + fmt.Fprintf(w, "%d.%s.%s\n", e.Pos, e.Hash, e.State) 149 + } 150 + // Empty line signals end of changelist. 151 + fmt.Fprintln(w) 152 + } 153 + 154 + // pijulGetChange serves a single change file as raw bytes. 155 + // The change is read from .pijul/changes/{hash[:2]}/{hash[2:]}.change 156 + func (h *Knot) pijulGetChange(w http.ResponseWriter, r *http.Request, repoPath, hash string) { 157 + if len(hash) < 3 { 158 + http.Error(w, "invalid change hash", http.StatusBadRequest) 159 + return 160 + } 161 + changePath := filepath.Join(repoPath, ".pijul", "changes", hash[:2], hash[2:]+".change") 162 + data, err := os.ReadFile(changePath) 163 + if err != nil { 164 + if os.IsNotExist(err) { 165 + http.Error(w, "change not found", http.StatusNotFound) 166 + } else { 167 + http.Error(w, "failed to read change", http.StatusInternalServerError) 168 + } 169 + return 170 + } 171 + w.Header().Set("Content-Type", "application/octet-stream") 172 + w.Header().Set("Content-Length", strconv.Itoa(len(data))) 173 + w.Write(data) 174 + } 175 + 176 + // pijulGetTag serves a tag file (for tagged states). We don't support tags yet; 177 + // return 404 so the client falls back gracefully. 178 + func (h *Knot) pijulGetTag(w http.ResponseWriter, r *http.Request, repoPath, hash string) { 179 + http.Error(w, "tags not supported", http.StatusNotFound) 180 + } 181 + 182 + // pijulApplyChange handles POST ?.apply=HASH&to_channel=CHANNEL. 183 + // It saves the uploaded change file, applies it, and emits an SSE event. 184 + func (h *Knot) pijulApplyChange(w http.ResponseWriter, r *http.Request, repoPath, hash, channel string) { 185 + // 1. Authenticate the pusher. 186 + pusherDid, err := parseBearerDID(r) 187 + if err != nil { 188 + http.Error(w, "unauthorized: "+err.Error(), http.StatusForbidden) 189 + return 190 + } 191 + 192 + // 2. RBAC: verify push permission. 193 + ownerDid := chi.URLParam(r, "did") 194 + repoName := chi.URLParam(r, "name") 195 + repoDid, dbErr := h.db.GetRepoDid(ownerDid, repoName) 196 + if dbErr != nil { 197 + repoDid = ownerDid + "/" + repoName 198 + } 199 + ok, rbacErr := h.e.IsPushAllowed(pusherDid, rbac.ThisServer, repoDid) 200 + if rbacErr != nil || !ok { 201 + http.Error(w, "forbidden", http.StatusForbidden) 202 + return 203 + } 204 + 205 + // 3. Save the change file to disk. 206 + if len(hash) < 3 { 207 + http.Error(w, "invalid change hash", http.StatusBadRequest) 208 + return 209 + } 210 + changeDir := filepath.Join(repoPath, ".pijul", "changes", hash[:2]) 211 + if err := os.MkdirAll(changeDir, 0755); err != nil { 212 + http.Error(w, "failed to create change directory", http.StatusInternalServerError) 213 + return 214 + } 215 + changePath := filepath.Join(changeDir, hash[2:]+".change") 216 + 217 + body, err := io.ReadAll(r.Body) 218 + if err != nil { 219 + http.Error(w, "failed to read request body", http.StatusBadRequest) 220 + return 221 + } 222 + if err := os.WriteFile(changePath, body, 0644); err != nil { 223 + http.Error(w, "failed to write change file", http.StatusInternalServerError) 224 + return 225 + } 226 + 227 + // 4. Apply the change to the channel. 228 + if channel == "" { 229 + channel = "main" 230 + } 231 + pr, err := pijul.Open(repoPath, channel) 232 + if err != nil { 233 + os.Remove(changePath) 234 + http.Error(w, "failed to open repository", http.StatusInternalServerError) 235 + return 236 + } 237 + if err := pr.Apply(hash); err != nil { 238 + os.Remove(changePath) 239 + h.l.Error("pijul apply failed", "hash", hash, "channel", channel, "err", err) 240 + http.Error(w, "failed to apply change: "+err.Error(), http.StatusInternalServerError) 241 + return 242 + } 243 + 244 + // 5. Get channel state and emit SSE event (best-effort). 245 + newState, _ := pr.ChannelState(channel) 246 + go h.emitPijulRefUpdate(pusherDid, ownerDid, repoName, repoDid, channel, hash, newState) 247 + 248 + w.WriteHeader(http.StatusOK) 249 + } 250 + 251 + // pijulTagUp handles POST ?tagup=HASH&to_channel=CHANNEL. 252 + // Tags (state snapshots) are saved but not broadcast — they're used for pinning. 253 + func (h *Knot) pijulTagUp(w http.ResponseWriter, r *http.Request, repoPath, hash, channel string) { 254 + if _, err := parseBearerDID(r); err != nil { 255 + http.Error(w, "unauthorized", http.StatusForbidden) 256 + return 257 + } 258 + 259 + if len(hash) < 3 { 260 + http.Error(w, "invalid tag hash", http.StatusBadRequest) 261 + return 262 + } 263 + tagDir := filepath.Join(repoPath, ".pijul", "tags", hash[:2]) 264 + if err := os.MkdirAll(tagDir, 0755); err != nil { 265 + http.Error(w, "failed to create tag directory", http.StatusInternalServerError) 266 + return 267 + } 268 + tagPath := filepath.Join(tagDir, hash[2:]+".tag") 269 + body, err := io.ReadAll(r.Body) 270 + if err != nil { 271 + http.Error(w, "failed to read request body", http.StatusBadRequest) 272 + return 273 + } 274 + if err := os.WriteFile(tagPath, body, 0644); err != nil { 275 + http.Error(w, "failed to write tag file", http.StatusInternalServerError) 276 + return 277 + } 278 + w.WriteHeader(http.StatusOK) 279 + } 280 + 281 + // emitPijulRefUpdate inserts a PijulRefUpdate event into the knotserver event log, 282 + // which is streamed to the appview via the /events WebSocket. 283 + func (h *Knot) emitPijulRefUpdate(committerDid, ownerDid, repoName, repoDid, channel, changeHash, newState string) { 284 + repoAt := fmt.Sprintf("at://%s/%s/%s", repoDid, tangled.RepoNSID, repoName) 285 + 286 + record := tangled.PijulRefUpdate{ 287 + Repo: repoAt, 288 + Channel: channel, 289 + Changes: []string{changeHash}, 290 + CommitterDid: committerDid, 291 + NewState: newState, 292 + } 293 + 294 + eventJson, err := json.Marshal(record) 295 + if err != nil { 296 + h.l.Error("failed to marshal pijulRefUpdate event", "err", err) 297 + return 298 + } 299 + 300 + event := db.Event{ 301 + Rkey: TID(), 302 + Nsid: tangled.PijulRefUpdateNSID, 303 + EventJson: string(eventJson), 304 + } 305 + 306 + if err := h.db.InsertEvent(event, h.n); err != nil { 307 + h.l.Error("failed to insert pijulRefUpdate event", "err", err) 308 + } 309 + } 310 + 311 + // parseBearerDID extracts the DID from an AT Protocol Bearer JWT in the 312 + // Authorization header. It decodes the JWT payload without verifying the 313 + // signature — full verification is a future TODO. 314 + func parseBearerDID(r *http.Request) (string, error) { 315 + auth := strings.TrimSpace(r.Header.Get("Authorization")) 316 + token := strings.TrimPrefix(auth, "Bearer ") 317 + if token == "" || token == auth { 318 + return "", fmt.Errorf("missing Bearer token") 319 + } 320 + 321 + parts := strings.Split(token, ".") 322 + if len(parts) != 3 { 323 + return "", fmt.Errorf("malformed JWT") 324 + } 325 + 326 + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) 327 + if err != nil { 328 + return "", fmt.Errorf("invalid JWT payload encoding") 329 + } 330 + 331 + var claims struct { 332 + Sub string `json:"sub"` 333 + Iss string `json:"iss"` 334 + } 335 + if err := json.Unmarshal(payload, &claims); err != nil { 336 + return "", fmt.Errorf("invalid JWT payload JSON") 337 + } 338 + 339 + // AT Protocol user JWTs use "sub" for the DID. 340 + did := claims.Sub 341 + if did == "" { 342 + did = claims.Iss 343 + } 344 + if did == "" || !strings.HasPrefix(did, "did:") { 345 + return "", fmt.Errorf("JWT does not contain a valid DID") 346 + } 347 + 348 + return did, nil 349 + }
+45 -6
knotserver/router.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 + "path/filepath" 8 9 "strings" 9 10 11 + securejoin "github.com/cyphar/filepath-securejoin" 10 12 "github.com/go-chi/chi/v5" 11 13 "tangled.org/core/idresolver" 12 14 "tangled.org/core/jetstream" ··· 81 83 82 84 r.Route("/{did}", func(r chi.Router) { 83 85 r.Use(h.resolveDidRedirect) 84 - 85 - r.Get("/info/refs", h.InfoRefs) 86 - r.Post("/git-upload-archive", h.UploadArchive) 87 - r.Post("/git-upload-pack", h.UploadPack) 88 - r.Post("/git-receive-pack", h.ReceivePack) 89 - 90 86 r.Route("/{name}", func(r chi.Router) { 87 + r.Use(h.resolveRepo) 88 + // routes for git operations 91 89 r.Get("/info/refs", h.InfoRefs) 92 90 r.Post("/git-upload-archive", h.UploadArchive) 93 91 r.Post("/git-upload-pack", h.UploadPack) 94 92 r.Post("/git-receive-pack", h.ReceivePack) 93 + // pijul HTTP remote protocol 94 + r.Get("/.pijul", h.PijulHttpGet) 95 + r.Post("/.pijul", h.PijulHttpPost) 95 96 }) 96 97 }) 97 98 ··· 148 149 http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 149 150 }) 150 151 } 152 + 153 + type ctxRepoPathKey struct{} 154 + 155 + func repoPathFromcontext(ctx context.Context) (string, bool) { 156 + v, ok := ctx.Value(ctxRepoPathKey{}).(string) 157 + return v, ok 158 + } 159 + 160 + // resolveRepo is a http middleware that constructs git repo path from given did & name pair. 161 + // It will reject the requests to unknown repos (when dir doesn't exist) 162 + func (h *Knot) resolveRepo(next http.Handler) http.Handler { 163 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 + did := chi.URLParam(r, "did") 165 + name := chi.URLParam(r, "name") 166 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 167 + if err != nil { 168 + w.WriteHeader(http.StatusNotFound) 169 + w.Write([]byte("Repository not found")) 170 + return 171 + } 172 + 173 + exist, err := isDir(repoPath) 174 + if err != nil { 175 + w.WriteHeader(http.StatusInternalServerError) 176 + w.Write([]byte("Failed to check repository path")) 177 + return 178 + } 179 + if !exist { 180 + w.WriteHeader(http.StatusNotFound) 181 + w.Write([]byte("Repository not found")) 182 + return 183 + } 184 + 185 + ctx := context.WithValue(r.Context(), ctxRepoPathKey{}, repoPath) 186 + next.ServeHTTP(w, r.WithContext(ctx)) 187 + }) 188 + } 189 + 151 190 152 191 func (h *Knot) configureOwner() error { 153 192 cfgOwner := h.c.Server.Owner
+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 + }
+180
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 + DID: c.Authors[0].DID, 36 + } 37 + } 38 + entries = append(entries, HistoryEntry{ 39 + Hash: c.Hash, 40 + Author: author, 41 + Committer: author, 42 + Message: c.Message, 43 + Timestamp: c.Timestamp, 44 + Parents: c.Dependencies, 45 + }) 46 + } 47 + return entries, nil 48 + } 49 + 50 + func (a *pijulReadAdapter) TotalHistoryEntries() (int, error) { 51 + return a.p.TotalChanges() 52 + } 53 + 54 + func (a *pijulReadAdapter) HistoryEntry(hash string) (*HistoryEntry, error) { 55 + c, err := a.p.GetChange(hash) 56 + if err != nil { 57 + return nil, err 58 + } 59 + 60 + var author Author 61 + if len(c.Authors) > 0 { 62 + author = Author{ 63 + Name: c.Authors[0].Name, 64 + Email: c.Authors[0].Email, 65 + DID: c.Authors[0].DID, 66 + } 67 + } 68 + 69 + return &HistoryEntry{ 70 + Hash: c.Hash, 71 + Author: author, 72 + Committer: author, 73 + Message: c.Message, 74 + Timestamp: c.Timestamp, 75 + Parents: c.Dependencies, 76 + }, nil 77 + } 78 + 79 + func (a *pijulReadAdapter) Branches(opts *PaginationOpts) ([]BranchInfo, error) { 80 + var pijulOpts *pijul.ChannelOptions 81 + if opts != nil { 82 + pijulOpts = &pijul.ChannelOptions{ 83 + Limit: opts.Limit, 84 + Offset: opts.Offset, 85 + } 86 + } 87 + 88 + channels, err := a.p.ChannelsWithOptions(pijulOpts) 89 + if err != nil { 90 + return nil, err 91 + } 92 + 93 + infos := make([]BranchInfo, 0, len(channels)) 94 + for _, ch := range channels { 95 + info := BranchInfo{ 96 + Name: ch.Name, 97 + IsDefault: ch.IsCurrent, 98 + } 99 + if latest, err := a.p.LatestChangeInChannel(ch.Name); err == nil && latest != nil { 100 + info.Hash = latest.Hash 101 + entry := &HistoryEntry{ 102 + Hash: latest.Hash, 103 + Message: latest.Message, 104 + Timestamp: latest.Timestamp, 105 + } 106 + if len(latest.Authors) > 0 { 107 + entry.Author = Author{ 108 + Name: latest.Authors[0].Name, 109 + Email: latest.Authors[0].Email, 110 + DID: latest.Authors[0].DID, 111 + } 112 + entry.Committer = entry.Author 113 + } 114 + info.LatestEntry = entry 115 + } 116 + infos = append(infos, info) 117 + } 118 + return infos, nil 119 + } 120 + 121 + func (a *pijulReadAdapter) DefaultBranch() (string, error) { 122 + return a.p.FindDefaultChannel() 123 + } 124 + 125 + func (a *pijulReadAdapter) FileTree(ctx context.Context, path string) ([]TreeEntry, error) { 126 + files, err := a.p.FileTree(ctx, path) 127 + if err != nil { 128 + return nil, err 129 + } 130 + 131 + entries := make([]TreeEntry, 0, len(files)) 132 + for _, f := range files { 133 + entries = append(entries, TreeEntry{ 134 + Name: f.Name, 135 + Mode: f.Mode, 136 + Size: f.Size, 137 + }) 138 + } 139 + return entries, nil 140 + } 141 + 142 + func (a *pijulReadAdapter) FileContentN(path string, cap int64) ([]byte, error) { 143 + return a.p.FileContentN(path, cap) 144 + } 145 + 146 + func (a *pijulReadAdapter) RawContent(path string) ([]byte, error) { 147 + return a.p.RawContent(path) 148 + } 149 + 150 + func (a *pijulReadAdapter) WriteTar(w io.Writer, prefix string) error { 151 + return a.p.WriteTar(w, prefix) 152 + } 153 + 154 + func (a *pijulReadAdapter) Tags(_ *PaginationOpts) ([]TagInfo, error) { 155 + // Pijul doesn't have tags. 156 + return nil, nil 157 + } 158 + 159 + // pijulMutableAdapter wraps a pijul.PijulRepo to implement MutableRepo. 160 + type pijulMutableAdapter struct { 161 + *pijulReadAdapter 162 + } 163 + 164 + func newPijulMutableAdapter(p *pijul.PijulRepo) *pijulMutableAdapter { 165 + return &pijulMutableAdapter{pijulReadAdapter: newPijulReadAdapter(p)} 166 + } 167 + 168 + func (a *pijulMutableAdapter) SetDefaultBranch(name string) error { 169 + return a.p.SetDefaultChannel(name) 170 + } 171 + 172 + func (a *pijulMutableAdapter) DeleteBranch(name string) error { 173 + return a.p.DeleteChannel(name) 174 + } 175 + 176 + // Pijul returns the underlying *pijul.PijulRepo for pijul-specific operations 177 + // that don't belong in the VCS interface. 178 + func (a *pijulReadAdapter) Pijul() *pijul.PijulRepo { 179 + return a.p 180 + }
+65
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 + // DID is the AT Protocol decentralized identifier of the author. 11 + // Populated for pijul changes that carry identity metadata. 12 + DID string 13 + } 14 + 15 + // HistoryEntry is a VCS-agnostic representation of a commit or change. 16 + type HistoryEntry struct { 17 + Hash string 18 + Author Author 19 + Committer Author 20 + Message string 21 + Timestamp time.Time 22 + // Parents lists parent hashes (git) or dependency hashes (pijul). 23 + Parents []string 24 + } 25 + 26 + // BranchInfo is a VCS-agnostic branch/channel. 27 + type BranchInfo struct { 28 + Name string 29 + Hash string 30 + IsDefault bool 31 + // LatestEntry is the most recent commit/change on this branch, if available. 32 + LatestEntry *HistoryEntry 33 + } 34 + 35 + // TreeEntry is a VCS-agnostic file/directory entry. 36 + type TreeEntry struct { 37 + Name string 38 + Mode string 39 + Size int64 40 + LastCommit *LastCommitInfo 41 + } 42 + 43 + // LastCommitInfo holds metadata about the last commit that touched a file. 44 + type LastCommitInfo struct { 45 + Hash string 46 + Message string 47 + When time.Time 48 + Author *Author 49 + } 50 + 51 + // TagInfo is a VCS-agnostic tag. 52 + type TagInfo struct { 53 + Name string 54 + Hash string 55 + Message string 56 + Tagger *Author 57 + // Target is the hash of the tagged object (for annotated tags). 58 + Target string 59 + } 60 + 61 + // PaginationOpts controls pagination for list operations. 62 + type PaginationOpts struct { 63 + Offset int 64 + Limit int 65 + }
+22 -3
knotserver/xrpc/create_repo.go
··· 17 17 "tangled.org/core/api/tangled" 18 18 "tangled.org/core/hook" 19 19 "tangled.org/core/knotserver/git" 20 + "tangled.org/core/knotserver/pijul" 20 21 "tangled.org/core/knotserver/repodid" 21 22 "tangled.org/core/rbac" 22 23 xrpcerr "tangled.org/core/xrpc/errors" ··· 52 53 } 53 54 54 55 repoName := data.Name 56 + 57 + vcs := "git" 58 + if data.Vcs != nil && strings.TrimSpace(*data.Vcs) != "" { 59 + vcs = strings.ToLower(strings.TrimSpace(*data.Vcs)) 60 + } 61 + switch vcs { 62 + case "git", "pijul": 63 + default: 64 + fail(xrpcerr.GenericError(fmt.Errorf("unsupported vcs: %s", vcs))) 65 + return 66 + } 55 67 56 68 if repoName == "" { 57 69 fail(xrpcerr.GenericError(fmt.Errorf("repository name is required"))) ··· 160 172 } 161 173 162 174 if data.Source != nil && *data.Source != "" { 163 - err = git.Fork(repoPath, *data.Source, h.Config) 175 + if vcs == "pijul" { 176 + err = pijul.Clone(*data.Source, repoPath, "") 177 + } else { 178 + err = git.Fork(repoPath, *data.Source, h.Config) 179 + } 164 180 if err != nil { 165 181 l.Error("forking repo", "error", err.Error()) 166 182 cleanupAll() ··· 168 184 return 169 185 } 170 186 } else { 171 - err = git.InitBare(repoPath, defaultBranch) 187 + if vcs == "pijul" { 188 + err = pijul.InitBare(repoPath) 189 + } else { 190 + err = git.InitBare(repoPath, defaultBranch) 191 + } 172 192 if err != nil { 173 193 l.Error("initializing bare repo", "error", err.Error()) 174 194 cleanupAll() ··· 214 234 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 215 235 return 216 236 } 217 - 218 237 hook.SetupRepo( 219 238 hook.Config( 220 239 hook.WithScanPath(h.Config.Repo.ScanPath),
+166
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 + NewState string `json:"newState,omitempty"` 27 + } 28 + 29 + // ApplyChangeFailure represents a failed change application 30 + type ApplyChangeFailure struct { 31 + Hash string `json:"hash"` 32 + Error string `json:"error"` 33 + } 34 + 35 + // RepoApplyChanges handles the sh.tangled.repo.applyChanges endpoint 36 + // Applies Pijul changes to a repository channel (used for merging discussions) 37 + func (x *Xrpc) RepoApplyChanges(w http.ResponseWriter, r *http.Request) { 38 + if r.Method != http.MethodPost { 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("InvalidRequest"), 41 + xrpcerr.WithMessage("method not allowed"), 42 + ), http.StatusMethodNotAllowed) 43 + return 44 + } 45 + 46 + var req ApplyChangesRequest 47 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("InvalidRequest"), 50 + xrpcerr.WithMessage("invalid request body"), 51 + ), http.StatusBadRequest) 52 + return 53 + } 54 + 55 + if req.Repo == "" || req.Channel == "" || len(req.Changes) == 0 { 56 + writeError(w, xrpcerr.NewXrpcError( 57 + xrpcerr.WithTag("InvalidRequest"), 58 + xrpcerr.WithMessage("repo, channel, and changes are required"), 59 + ), http.StatusBadRequest) 60 + return 61 + } 62 + 63 + // Authorization: verify the caller has push permission 64 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 65 + if !ok { 66 + writeError(w, xrpcerr.MissingActorDidError, http.StatusBadRequest) 67 + return 68 + } 69 + 70 + repoPath, err := x.parseRepoParam(req.Repo) 71 + if err != nil { 72 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 73 + return 74 + } 75 + 76 + repoParts := strings.SplitN(req.Repo, "/", 2) 77 + if len(repoParts) != 2 { 78 + writeError(w, xrpcerr.NewXrpcError( 79 + xrpcerr.WithTag("InvalidRequest"), 80 + xrpcerr.WithMessage("invalid repo format"), 81 + ), http.StatusBadRequest) 82 + return 83 + } 84 + qualifiedRepo, _ := securejoin.SecureJoin(repoParts[0], repoParts[1]) 85 + pushOk, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, qualifiedRepo) 86 + if err != nil || !pushOk { 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("Forbidden"), 89 + xrpcerr.WithMessage("push permission required to apply changes"), 90 + ), http.StatusForbidden) 91 + return 92 + } 93 + 94 + // Open the repository with the target channel 95 + pr, err := pijul.Open(repoPath, req.Channel) 96 + if err != nil { 97 + writeError(w, xrpcerr.NewXrpcError( 98 + xrpcerr.WithTag("RepoNotFound"), 99 + xrpcerr.WithMessage("failed to open pijul repository"), 100 + ), http.StatusNotFound) 101 + return 102 + } 103 + 104 + // Verify the channel exists 105 + channels, err := pr.Channels() 106 + if err != nil { 107 + writeError(w, xrpcerr.NewXrpcError( 108 + xrpcerr.WithTag("InternalServerError"), 109 + xrpcerr.WithMessage("failed to list channels"), 110 + ), http.StatusInternalServerError) 111 + return 112 + } 113 + 114 + channelExists := false 115 + for _, ch := range channels { 116 + if ch.Name == req.Channel { 117 + channelExists = true 118 + break 119 + } 120 + } 121 + 122 + if !channelExists { 123 + writeError(w, xrpcerr.NewXrpcError( 124 + xrpcerr.WithTag("ChannelNotFound"), 125 + xrpcerr.WithMessage("target channel not found"), 126 + ), http.StatusNotFound) 127 + return 128 + } 129 + 130 + // Apply each change in order 131 + response := ApplyChangesResponse{ 132 + Applied: make([]string, 0), 133 + Failed: make([]ApplyChangeFailure, 0), 134 + } 135 + 136 + for _, changeHash := range req.Changes { 137 + if err := pr.Apply(changeHash); err != nil { 138 + x.Logger.Error("failed to apply change", "hash", changeHash, "error", err.Error()) 139 + response.Failed = append(response.Failed, ApplyChangeFailure{ 140 + Hash: changeHash, 141 + Error: err.Error(), 142 + }) 143 + } else { 144 + response.Applied = append(response.Applied, changeHash) 145 + x.Logger.Info("applied change", "hash", changeHash, "channel", req.Channel) 146 + } 147 + } 148 + 149 + // If any changes failed, return partial success 150 + if len(response.Failed) > 0 && len(response.Applied) == 0 { 151 + writeError(w, xrpcerr.NewXrpcError( 152 + xrpcerr.WithTag("ApplyFailed"), 153 + xrpcerr.WithMessage("all changes failed to apply"), 154 + ), http.StatusInternalServerError) 155 + return 156 + } 157 + 158 + // Populate the channel's Merkle state after applying (best-effort). 159 + if state, err := pr.ChannelState(req.Channel); err != nil { 160 + x.Logger.Warn("failed to get channel state after apply", "channel", req.Channel, "error", err) 161 + } else { 162 + response.NewState = state 163 + } 164 + 165 + writeJson(w, response) 166 + }
+219
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 + Did string `json:"did,omitempty"` 35 + } 36 + 37 + // RepoChangeList handles the sh.tangled.repo.changeList endpoint 38 + // Lists changes (Pijul equivalent of commits) in a repository. 39 + // Uses the unified VCS History interface. 40 + func (x *Xrpc) RepoChangeList(w http.ResponseWriter, r *http.Request) { 41 + repo := r.URL.Query().Get("repo") 42 + repoPath, err := x.parseRepoParam(repo) 43 + if err != nil { 44 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 45 + return 46 + } 47 + 48 + channel := r.URL.Query().Get("channel") 49 + cursor := r.URL.Query().Get("cursor") 50 + 51 + limit := 50 // default 52 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 53 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 54 + limit = l 55 + } 56 + } 57 + 58 + rv, err := vcs.Open(repoPath, channel) 59 + if err != nil { 60 + writeError(w, xrpcerr.NewXrpcError( 61 + xrpcerr.WithTag("RepoNotFound"), 62 + xrpcerr.WithMessage("failed to open repository"), 63 + ), http.StatusNotFound) 64 + return 65 + } 66 + 67 + offset := 0 68 + if cursor != "" { 69 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 70 + offset = o 71 + } 72 + } 73 + 74 + entries, err := rv.History(offset, limit) 75 + if err != nil { 76 + x.Logger.Error("fetching changes", "error", err.Error()) 77 + writeError(w, xrpcerr.NewXrpcError( 78 + xrpcerr.WithTag("InternalServerError"), 79 + xrpcerr.WithMessage("failed to read change log"), 80 + ), http.StatusInternalServerError) 81 + return 82 + } 83 + 84 + total, err := rv.TotalHistoryEntries() 85 + if err != nil { 86 + x.Logger.Error("fetching total changes", "error", err.Error()) 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("InternalServerError"), 89 + xrpcerr.WithMessage("failed to fetch total changes"), 90 + ), http.StatusInternalServerError) 91 + return 92 + } 93 + 94 + // Collect all author keys to resolve to DIDs 95 + authorKeys := make([]string, 0, len(entries)) 96 + for _, e := range entries { 97 + if e.Author.Name != "" { 98 + authorKeys = append(authorKeys, e.Author.Name) 99 + } 100 + } 101 + keyToDid, err := x.Db.GetPijulKeyToDid(authorKeys) 102 + if err != nil { 103 + x.Logger.Warn("failed to resolve pijul keys to DIDs", "error", err) 104 + keyToDid = make(map[string]string) 105 + } 106 + 107 + // Convert to response format 108 + changeEntries := make([]PijulChangeEntry, len(entries)) 109 + for i, e := range entries { 110 + author := PijulAuthor{ 111 + Name: e.Author.Name, 112 + Email: e.Author.Email, 113 + } 114 + if did, ok := keyToDid[e.Author.Name]; ok { 115 + author.Did = did 116 + } 117 + 118 + changeEntries[i] = PijulChangeEntry{ 119 + Hash: e.Hash, 120 + Authors: []PijulAuthor{author}, 121 + Message: e.Message, 122 + Dependencies: e.Parents, 123 + } 124 + 125 + if !e.Timestamp.IsZero() { 126 + changeEntries[i].Timestamp = e.Timestamp.Format(time.RFC3339) 127 + } 128 + } 129 + 130 + response := PijulChangeListResponse{ 131 + Changes: changeEntries, 132 + Channel: channel, 133 + Page: (offset / limit) + 1, 134 + PerPage: limit, 135 + Total: total, 136 + } 137 + 138 + writeJson(w, response) 139 + } 140 + 141 + // PijulChangeGetResponse is the response for getting a single change 142 + type PijulChangeGetResponse struct { 143 + Hash string `json:"hash"` 144 + Authors []PijulAuthor `json:"authors"` 145 + Message string `json:"message"` 146 + Timestamp string `json:"timestamp,omitempty"` 147 + Dependencies []string `json:"dependencies,omitempty"` 148 + Diff string `json:"diff,omitempty"` 149 + } 150 + 151 + // RepoChangeGet handles the sh.tangled.repo.changeGet endpoint 152 + // Gets details for a specific change 153 + func (x *Xrpc) RepoChangeGet(w http.ResponseWriter, r *http.Request) { 154 + repo := r.URL.Query().Get("repo") 155 + repoPath, err := x.parseRepoParam(repo) 156 + if err != nil { 157 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 158 + return 159 + } 160 + 161 + hash := r.URL.Query().Get("hash") 162 + if hash == "" { 163 + writeError(w, xrpcerr.NewXrpcError( 164 + xrpcerr.WithTag("InvalidRequest"), 165 + xrpcerr.WithMessage("missing hash parameter"), 166 + ), http.StatusBadRequest) 167 + return 168 + } 169 + 170 + rv, err := vcs.Open(repoPath, "") 171 + if err != nil { 172 + writeError(w, xrpcerr.NewXrpcError( 173 + xrpcerr.WithTag("RepoNotFound"), 174 + xrpcerr.WithMessage("failed to open repository"), 175 + ), http.StatusNotFound) 176 + return 177 + } 178 + 179 + entry, err := rv.HistoryEntry(hash) 180 + if err != nil { 181 + x.Logger.Error("fetching change", "error", err.Error(), "hash", hash) 182 + writeError(w, xrpcerr.NewXrpcError( 183 + xrpcerr.WithTag("ChangeNotFound"), 184 + xrpcerr.WithMessage("change not found"), 185 + ), http.StatusNotFound) 186 + return 187 + } 188 + 189 + author := PijulAuthor{ 190 + Name: entry.Author.Name, 191 + Email: entry.Author.Email, 192 + } 193 + if did, err := x.Db.GetDidForPijulKey(entry.Author.Name); err == nil { 194 + author.Did = did 195 + } 196 + 197 + response := PijulChangeGetResponse{ 198 + Hash: entry.Hash, 199 + Authors: []PijulAuthor{author}, 200 + Message: entry.Message, 201 + Dependencies: entry.Parents, 202 + } 203 + 204 + if !entry.Timestamp.IsZero() { 205 + response.Timestamp = entry.Timestamp.Format(time.RFC3339) 206 + } 207 + 208 + // Get diff for pijul repos 209 + if pr := vcs.AsPijul(rv); pr != nil { 210 + diff, err := pr.DiffChange(hash) 211 + if err != nil { 212 + x.Logger.Warn("failed to get diff for change", "hash", hash, "error", err) 213 + } else if diff != nil { 214 + response.Diff = diff.Raw 215 + } 216 + } 217 + 218 + writeJson(w, response) 219 + }
+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 + }
+125
knotserver/xrpc/pijul_unrecord.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 + // UnrecordChangesRequest is the request body for unrecording changes 16 + type UnrecordChangesRequest struct { 17 + Repo string `json:"repo"` 18 + Channel string `json:"channel,omitempty"` 19 + Changes []string `json:"changes"` 20 + } 21 + 22 + // UnrecordChangesResponse is the response for unrecording changes 23 + type UnrecordChangesResponse struct { 24 + Unrecorded []string `json:"unrecorded"` 25 + Failed []ApplyChangeFailure `json:"failed,omitempty"` 26 + } 27 + 28 + // RepoUnrecordChanges handles the sh.tangled.repo.unrecordChanges endpoint 29 + // Unrecords (reverts) Pijul changes from a repository channel 30 + func (x *Xrpc) RepoUnrecordChanges(w http.ResponseWriter, r *http.Request) { 31 + if r.Method != http.MethodPost { 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("method not allowed"), 35 + ), http.StatusMethodNotAllowed) 36 + return 37 + } 38 + 39 + var req UnrecordChangesRequest 40 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("InvalidRequest"), 43 + xrpcerr.WithMessage("invalid request body"), 44 + ), http.StatusBadRequest) 45 + return 46 + } 47 + 48 + if req.Repo == "" || len(req.Changes) == 0 { 49 + writeError(w, xrpcerr.NewXrpcError( 50 + xrpcerr.WithTag("InvalidRequest"), 51 + xrpcerr.WithMessage("repo and changes are required"), 52 + ), http.StatusBadRequest) 53 + return 54 + } 55 + 56 + // Authorization: verify the caller has push permission 57 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 58 + if !ok { 59 + writeError(w, xrpcerr.MissingActorDidError, http.StatusBadRequest) 60 + return 61 + } 62 + 63 + repoPath, err := x.parseRepoParam(req.Repo) 64 + if err != nil { 65 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 66 + return 67 + } 68 + 69 + repoParts := strings.SplitN(req.Repo, "/", 2) 70 + if len(repoParts) != 2 { 71 + writeError(w, xrpcerr.NewXrpcError( 72 + xrpcerr.WithTag("InvalidRequest"), 73 + xrpcerr.WithMessage("invalid repo format"), 74 + ), http.StatusBadRequest) 75 + return 76 + } 77 + qualifiedRepo, _ := securejoin.SecureJoin(repoParts[0], repoParts[1]) 78 + pushOk, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, qualifiedRepo) 79 + if err != nil || !pushOk { 80 + writeError(w, xrpcerr.NewXrpcError( 81 + xrpcerr.WithTag("Forbidden"), 82 + xrpcerr.WithMessage("push permission required to unrecord changes"), 83 + ), http.StatusForbidden) 84 + return 85 + } 86 + 87 + // Open the repository with the target channel 88 + pr, err := pijul.Open(repoPath, req.Channel) 89 + if err != nil { 90 + writeError(w, xrpcerr.NewXrpcError( 91 + xrpcerr.WithTag("RepoNotFound"), 92 + xrpcerr.WithMessage("failed to open pijul repository"), 93 + ), http.StatusNotFound) 94 + return 95 + } 96 + 97 + // Unrecord each change 98 + response := UnrecordChangesResponse{ 99 + Unrecorded: make([]string, 0), 100 + Failed: make([]ApplyChangeFailure, 0), 101 + } 102 + 103 + for _, changeHash := range req.Changes { 104 + if err := pr.Unrecord(changeHash); err != nil { 105 + x.Logger.Error("failed to unrecord change", "hash", changeHash, "error", err.Error()) 106 + response.Failed = append(response.Failed, ApplyChangeFailure{ 107 + Hash: changeHash, 108 + Error: err.Error(), 109 + }) 110 + } else { 111 + response.Unrecorded = append(response.Unrecorded, changeHash) 112 + x.Logger.Info("unrecorded change", "hash", changeHash, "channel", req.Channel) 113 + } 114 + } 115 + 116 + if len(response.Failed) > 0 && len(response.Unrecorded) == 0 { 117 + writeError(w, xrpcerr.NewXrpcError( 118 + xrpcerr.WithTag("UnrecordFailed"), 119 + xrpcerr.WithMessage("all changes failed to unrecord"), 120 + ), http.StatusInternalServerError) 121 + return 122 + } 123 + 124 + writeJson(w, response) 125 + }
+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 }
+9
knotserver/xrpc/xrpc.go
··· 46 46 r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 47 47 r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 48 48 r.Post("/"+tangled.RepoMergeNSID, x.Merge) 49 + r.Post("/"+tangled.RepoApplyChangesNSID, x.RepoApplyChanges) 50 + r.Post("/"+tangled.RepoUnrecordChangesNSID, x.RepoUnrecordChanges) 51 + r.Get("/"+tangled.RepoPermissionsNSID, x.RepoPermissions) 49 52 }) 50 53 51 54 // merge check is an open endpoint ··· 62 65 r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 63 66 r.Get("/"+tangled.RepoTagNSID, x.RepoTag) 64 67 r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 68 + r.Get("/"+tangled.RepoPijulBlobNSID, x.RepoPijulBlob) 65 69 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 66 70 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 67 71 r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 72 + r.Get("/"+tangled.RepoGetDefaultChannelNSID, x.RepoGetDefaultChannel) 68 73 r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 69 74 r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 70 75 r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 76 + r.Get("/"+tangled.RepoChannelListNSID, x.RepoChannelList) 77 + r.Get("/"+tangled.RepoChangeListNSID, x.RepoChangeList) 78 + r.Get("/"+tangled.RepoChangeGetNSID, x.RepoChangeGet) 79 + r.Get("/"+tangled.RepoPijulTreeNSID, x.RepoPijulTree) 71 80 72 81 // knot query endpoints (no auth required) 73 82 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 + }
+50
lexicons/pijul/refUpdate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pijul.refUpdate", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Published to the committer's PDS when pijul changes are pushed to a channel. The PDS MST signature over this record is the verifiable proof of authorship.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["repo", "channel", "newState", "changes", "committerDid"], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "AT URI of the repository (at://did/sh.tangled.repo/name)" 17 + }, 18 + "channel": { 19 + "type": "string", 20 + "description": "Channel that was updated" 21 + }, 22 + "oldState": { 23 + "type": "string", 24 + "description": "Pijul Merkle state hash before push (empty for new channels)" 25 + }, 26 + "newState": { 27 + "type": "string", 28 + "description": "Pijul Merkle state hash after push" 29 + }, 30 + "changes": { 31 + "type": "array", 32 + "items": { 33 + "type": "string" 34 + }, 35 + "description": "Base32 change hashes pushed in this operation, in application order" 36 + }, 37 + "committerDid": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the pusher. Redundant when this record lives in the committer's own PDS, but preserved for replication." 41 + }, 42 + "languages": { 43 + "type": "object", 44 + "description": "Optional map of language name to line count, computed by knot server" 45 + } 46 + } 47 + } 48 + } 49 + } 50 + }
+84
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 + "newState": { 62 + "type": "string", 63 + "description": "Pijul channel Merkle state hash after applying changes. Empty string if unavailable." 64 + } 65 + } 66 + } 67 + }, 68 + "errors": [ 69 + { 70 + "name": "InvalidRequest" 71 + }, 72 + { 73 + "name": "RepoNotFound" 74 + }, 75 + { 76 + "name": "ChannelNotFound" 77 + }, 78 + { 79 + "name": "ApplyFailed" 80 + } 81 + ] 82 + } 83 + } 84 + }
+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
··· 34 34 "type": "string", 35 35 "format": "did", 36 36 "description": "Optional user-provided did:web to use as the repo identity instead of minting a did:plc." 37 + }, 38 + "vcs": { 39 + "type": "string", 40 + "description": "Version control system to use for the repository (git or pijul)." 37 41 } 38 42 } 39 43 }
+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 + }
+80
lexicons/repo/unrecordChanges.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.unrecordChanges", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Unrecord (revert) Pijul changes from a repository channel.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "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 unrecord changes from (optional, uses default if not set)" 21 + }, 22 + "changes": { 23 + "type": "array", 24 + "items": { 25 + "type": "string" 26 + }, 27 + "description": "List of change hashes to unrecord" 28 + } 29 + } 30 + } 31 + }, 32 + "output": { 33 + "encoding": "application/json", 34 + "schema": { 35 + "type": "object", 36 + "required": ["unrecorded"], 37 + "properties": { 38 + "unrecorded": { 39 + "type": "array", 40 + "items": { 41 + "type": "string" 42 + }, 43 + "description": "List of successfully unrecorded 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 unrecord" 60 + } 61 + } 62 + } 63 + }, 64 + "errors": [ 65 + { 66 + "name": "InvalidRequest" 67 + }, 68 + { 69 + "name": "RepoNotFound" 70 + }, 71 + { 72 + "name": "Forbidden" 73 + }, 74 + { 75 + "name": "UnrecordFailed" 76 + } 77 + ] 78 + } 79 + } 80 + }
+1
nix/modules/knot.nix
··· 184 184 config = mkIf cfg.enable { 185 185 environment.systemPackages = [ 186 186 pkgs.git 187 + pkgs.pijul 187 188 cfg.package 188 189 ]; 189 190
+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 )