detached 6a3e65d

feat(kvstore): store pending issues, bans and the issue form key

commit
6a3e65dac5833f8b52bd6273fbdbf9585cb371bb
author
brice · · 2026-10-19 06:54 UTC
parent
dca1fe7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +912 -13
M gen/ermite/storage/issue.pb.go +199 -12
@@ -255,6 +255,177 @@ func (x *IssueComment) GetCreatedAt() *timestamppb.Timestamp {
255255 return nil
256256 }
257257
258+// PendingIssue is an issue submitted through the public form, stored under
259+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
260+// It has no number until the owner approves it.
261+type PendingIssue struct {
262+ state protoimpl.MessageState `protogen:"open.v1"`
263+ Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
264+ Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
265+ // Display name given by the submitter. May be empty.
266+ Author string `protobuf:"bytes,3,opt,name=author,proto3" json:"author,omitempty"`
267+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
268+ // HMAC-SHA256 of the client key (32 bytes).
269+ IpHash []byte `protobuf:"bytes,5,opt,name=ip_hash,json=ipHash,proto3" json:"ip_hash,omitempty"`
270+ unknownFields protoimpl.UnknownFields
271+ sizeCache protoimpl.SizeCache
272+}
273+
274+func (x *PendingIssue) Reset() {
275+ *x = PendingIssue{}
276+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
277+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
278+ ms.StoreMessageInfo(mi)
279+}
280+
281+func (x *PendingIssue) String() string {
282+ return protoimpl.X.MessageStringOf(x)
283+}
284+
285+func (*PendingIssue) ProtoMessage() {}
286+
287+func (x *PendingIssue) ProtoReflect() protoreflect.Message {
288+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
289+ if x != nil {
290+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
291+ if ms.LoadMessageInfo() == nil {
292+ ms.StoreMessageInfo(mi)
293+ }
294+ return ms
295+ }
296+ return mi.MessageOf(x)
297+}
298+
299+// Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
300+func (*PendingIssue) Descriptor() ([]byte, []int) {
301+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
302+}
303+
304+func (x *PendingIssue) GetTitle() string {
305+ if x != nil {
306+ return x.Title
307+ }
308+ return ""
309+}
310+
311+func (x *PendingIssue) GetBody() string {
312+ if x != nil {
313+ return x.Body
314+ }
315+ return ""
316+}
317+
318+func (x *PendingIssue) GetAuthor() string {
319+ if x != nil {
320+ return x.Author
321+ }
322+ return ""
323+}
324+
325+func (x *PendingIssue) GetCreatedAt() *timestamppb.Timestamp {
326+ if x != nil {
327+ return x.CreatedAt
328+ }
329+ return nil
330+}
331+
332+func (x *PendingIssue) GetIpHash() []byte {
333+ if x != nil {
334+ return x.IpHash
335+ }
336+ return nil
337+}
338+
339+// IssueBan is stored under the key "isban/<hex of the hash>".
340+type IssueBan struct {
341+ state protoimpl.MessageState `protogen:"open.v1"`
342+ Until *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=until,proto3" json:"until,omitempty"`
343+ unknownFields protoimpl.UnknownFields
344+ sizeCache protoimpl.SizeCache
345+}
346+
347+func (x *IssueBan) Reset() {
348+ *x = IssueBan{}
349+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
350+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
351+ ms.StoreMessageInfo(mi)
352+}
353+
354+func (x *IssueBan) String() string {
355+ return protoimpl.X.MessageStringOf(x)
356+}
357+
358+func (*IssueBan) ProtoMessage() {}
359+
360+func (x *IssueBan) ProtoReflect() protoreflect.Message {
361+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
362+ if x != nil {
363+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
364+ if ms.LoadMessageInfo() == nil {
365+ ms.StoreMessageInfo(mi)
366+ }
367+ return ms
368+ }
369+ return mi.MessageOf(x)
370+}
371+
372+// Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
373+func (*IssueBan) Descriptor() ([]byte, []int) {
374+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
375+}
376+
377+func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
378+ if x != nil {
379+ return x.Until
380+ }
381+ return nil
382+}
383+
384+// IssueKey is stored under the key "meta/issuekey".
385+type IssueKey struct {
386+ state protoimpl.MessageState `protogen:"open.v1"`
387+ Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
388+ unknownFields protoimpl.UnknownFields
389+ sizeCache protoimpl.SizeCache
390+}
391+
392+func (x *IssueKey) Reset() {
393+ *x = IssueKey{}
394+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
395+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
396+ ms.StoreMessageInfo(mi)
397+}
398+
399+func (x *IssueKey) String() string {
400+ return protoimpl.X.MessageStringOf(x)
401+}
402+
403+func (*IssueKey) ProtoMessage() {}
404+
405+func (x *IssueKey) ProtoReflect() protoreflect.Message {
406+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
407+ if x != nil {
408+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
409+ if ms.LoadMessageInfo() == nil {
410+ ms.StoreMessageInfo(mi)
411+ }
412+ return ms
413+ }
414+ return mi.MessageOf(x)
415+}
416+
417+// Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
418+func (*IssueKey) Descriptor() ([]byte, []int) {
419+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
420+}
421+
422+func (x *IssueKey) GetKey() []byte {
423+ if x != nil {
424+ return x.Key
425+ }
426+ return nil
427+}
428+
258429 var File_ermite_storage_issue_proto protoreflect.FileDescriptor
259430
260431 const file_ermite_storage_issue_proto_rawDesc = "" +
@@ -277,7 +448,18 @@ const file_ermite_storage_issue_proto_rawDesc = "" +
277448 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
278449 "\x04text\x18\x03 \x01(\tR\x04text\x129\n" +
279450 "\n" +
280- "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt*p\n" +
451+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xa4\x01\n" +
452+ "\fPendingIssue\x12\x14\n" +
453+ "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
454+ "\x04body\x18\x02 \x01(\tR\x04body\x12\x16\n" +
455+ "\x06author\x18\x03 \x01(\tR\x06author\x129\n" +
456+ "\n" +
457+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x17\n" +
458+ "\aip_hash\x18\x05 \x01(\fR\x06ipHash\"<\n" +
459+ "\bIssueBan\x120\n" +
460+ "\x05until\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05until\"\x1c\n" +
461+ "\bIssueKey\x12\x10\n" +
462+ "\x03key\x18\x01 \x01(\fR\x03key*p\n" +
281463 "\n" +
282464 "IssueState\x12\x1b\n" +
283465 "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" +
@@ -298,23 +480,28 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
298480 }
299481
300482 var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
301-var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
483+var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
302484 var file_ermite_storage_issue_proto_goTypes = []any{
303485 (IssueState)(0), // 0: ermite.storage.IssueState
304486 (*Issue)(nil), // 1: ermite.storage.Issue
305487 (*IssueComment)(nil), // 2: ermite.storage.IssueComment
306- (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp
488+ (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue
489+ (*IssueBan)(nil), // 4: ermite.storage.IssueBan
490+ (*IssueKey)(nil), // 5: ermite.storage.IssueKey
491+ (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
307492 }
308493 var file_ermite_storage_issue_proto_depIdxs = []int32{
309494 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
310- 3, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
311- 3, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
312- 3, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
313- 4, // [4:4] is the sub-list for method output_type
314- 4, // [4:4] is the sub-list for method input_type
315- 4, // [4:4] is the sub-list for extension type_name
316- 4, // [4:4] is the sub-list for extension extendee
317- 0, // [0:4] is the sub-list for field type_name
495+ 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
496+ 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
497+ 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
498+ 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
499+ 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
500+ 6, // [6:6] is the sub-list for method output_type
501+ 6, // [6:6] is the sub-list for method input_type
502+ 6, // [6:6] is the sub-list for extension type_name
503+ 6, // [6:6] is the sub-list for extension extendee
504+ 0, // [0:6] is the sub-list for field type_name
318505 }
319506
320507 func init() { file_ermite_storage_issue_proto_init() }
@@ -328,7 +515,7 @@ func file_ermite_storage_issue_proto_init() {
328515 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
329516 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
330517 NumEnums: 1,
331- NumMessages: 2,
518+ NumMessages: 5,
332519 NumExtensions: 0,
333520 NumServices: 0,
334521 },
@@ -255,6 +255,177 @@ func (x *IssueComment) GetCreatedAt() *timestamppb.Timestamp {
255 return nil255 return nil
256 }256 }
257 257
258+// PendingIssue is an issue submitted through the public form, stored under
259+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
260+// It has no number until the owner approves it.
261+type PendingIssue struct {
262+ state protoimpl.MessageState `protogen:"open.v1"`
263+ Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
264+ Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
265+ // Display name given by the submitter. May be empty.
266+ Author string `protobuf:"bytes,3,opt,name=author,proto3" json:"author,omitempty"`
267+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
268+ // HMAC-SHA256 of the client key (32 bytes).
269+ IpHash []byte `protobuf:"bytes,5,opt,name=ip_hash,json=ipHash,proto3" json:"ip_hash,omitempty"`
270+ unknownFields protoimpl.UnknownFields
271+ sizeCache protoimpl.SizeCache
272+}
273+
274+func (x *PendingIssue) Reset() {
275+ *x = PendingIssue{}
276+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
277+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
278+ ms.StoreMessageInfo(mi)
279+}
280+
281+func (x *PendingIssue) String() string {
282+ return protoimpl.X.MessageStringOf(x)
283+}
284+
285+func (*PendingIssue) ProtoMessage() {}
286+
287+func (x *PendingIssue) ProtoReflect() protoreflect.Message {
288+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
289+ if x != nil {
290+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
291+ if ms.LoadMessageInfo() == nil {
292+ ms.StoreMessageInfo(mi)
293+ }
294+ return ms
295+ }
296+ return mi.MessageOf(x)
297+}
298+
299+// Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
300+func (*PendingIssue) Descriptor() ([]byte, []int) {
301+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
302+}
303+
304+func (x *PendingIssue) GetTitle() string {
305+ if x != nil {
306+ return x.Title
307+ }
308+ return ""
309+}
310+
311+func (x *PendingIssue) GetBody() string {
312+ if x != nil {
313+ return x.Body
314+ }
315+ return ""
316+}
317+
318+func (x *PendingIssue) GetAuthor() string {
319+ if x != nil {
320+ return x.Author
321+ }
322+ return ""
323+}
324+
325+func (x *PendingIssue) GetCreatedAt() *timestamppb.Timestamp {
326+ if x != nil {
327+ return x.CreatedAt
328+ }
329+ return nil
330+}
331+
332+func (x *PendingIssue) GetIpHash() []byte {
333+ if x != nil {
334+ return x.IpHash
335+ }
336+ return nil
337+}
338+
339+// IssueBan is stored under the key "isban/<hex of the hash>".
340+type IssueBan struct {
341+ state protoimpl.MessageState `protogen:"open.v1"`
342+ Until *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=until,proto3" json:"until,omitempty"`
343+ unknownFields protoimpl.UnknownFields
344+ sizeCache protoimpl.SizeCache
345+}
346+
347+func (x *IssueBan) Reset() {
348+ *x = IssueBan{}
349+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
350+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
351+ ms.StoreMessageInfo(mi)
352+}
353+
354+func (x *IssueBan) String() string {
355+ return protoimpl.X.MessageStringOf(x)
356+}
357+
358+func (*IssueBan) ProtoMessage() {}
359+
360+func (x *IssueBan) ProtoReflect() protoreflect.Message {
361+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
362+ if x != nil {
363+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
364+ if ms.LoadMessageInfo() == nil {
365+ ms.StoreMessageInfo(mi)
366+ }
367+ return ms
368+ }
369+ return mi.MessageOf(x)
370+}
371+
372+// Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
373+func (*IssueBan) Descriptor() ([]byte, []int) {
374+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
375+}
376+
377+func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
378+ if x != nil {
379+ return x.Until
380+ }
381+ return nil
382+}
383+
384+// IssueKey is stored under the key "meta/issuekey".
385+type IssueKey struct {
386+ state protoimpl.MessageState `protogen:"open.v1"`
387+ Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
388+ unknownFields protoimpl.UnknownFields
389+ sizeCache protoimpl.SizeCache
390+}
391+
392+func (x *IssueKey) Reset() {
393+ *x = IssueKey{}
394+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
395+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
396+ ms.StoreMessageInfo(mi)
397+}
398+
399+func (x *IssueKey) String() string {
400+ return protoimpl.X.MessageStringOf(x)
401+}
402+
403+func (*IssueKey) ProtoMessage() {}
404+
405+func (x *IssueKey) ProtoReflect() protoreflect.Message {
406+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
407+ if x != nil {
408+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
409+ if ms.LoadMessageInfo() == nil {
410+ ms.StoreMessageInfo(mi)
411+ }
412+ return ms
413+ }
414+ return mi.MessageOf(x)
415+}
416+
417+// Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
418+func (*IssueKey) Descriptor() ([]byte, []int) {
419+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
420+}
421+
422+func (x *IssueKey) GetKey() []byte {
423+ if x != nil {
424+ return x.Key
425+ }
426+ return nil
427+}
428+
258 var File_ermite_storage_issue_proto protoreflect.FileDescriptor429 var File_ermite_storage_issue_proto protoreflect.FileDescriptor
259 430
260 const file_ermite_storage_issue_proto_rawDesc = "" +431 const file_ermite_storage_issue_proto_rawDesc = "" +
@@ -277,7 +448,18 @@ const file_ermite_storage_issue_proto_rawDesc = "" +
277 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +448 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
278 "\x04text\x18\x03 \x01(\tR\x04text\x129\n" +449 "\x04text\x18\x03 \x01(\tR\x04text\x129\n" +
279 "\n" +450 "\n" +
280- "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt*p\n" +451+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xa4\x01\n" +
452+ "\fPendingIssue\x12\x14\n" +
453+ "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
454+ "\x04body\x18\x02 \x01(\tR\x04body\x12\x16\n" +
455+ "\x06author\x18\x03 \x01(\tR\x06author\x129\n" +
456+ "\n" +
457+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x17\n" +
458+ "\aip_hash\x18\x05 \x01(\fR\x06ipHash\"<\n" +
459+ "\bIssueBan\x120\n" +
460+ "\x05until\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05until\"\x1c\n" +
461+ "\bIssueKey\x12\x10\n" +
462+ "\x03key\x18\x01 \x01(\fR\x03key*p\n" +
281 "\n" +463 "\n" +
282 "IssueState\x12\x1b\n" +464 "IssueState\x12\x1b\n" +
283 "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" +465 "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" +
@@ -298,23 +480,28 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
298 }480 }
299 481
300 var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)482 var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
301-var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 2)483+var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
302 var file_ermite_storage_issue_proto_goTypes = []any{484 var file_ermite_storage_issue_proto_goTypes = []any{
303 (IssueState)(0), // 0: ermite.storage.IssueState485 (IssueState)(0), // 0: ermite.storage.IssueState
304 (*Issue)(nil), // 1: ermite.storage.Issue486 (*Issue)(nil), // 1: ermite.storage.Issue
305 (*IssueComment)(nil), // 2: ermite.storage.IssueComment487 (*IssueComment)(nil), // 2: ermite.storage.IssueComment
306- (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp488+ (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue
489+ (*IssueBan)(nil), // 4: ermite.storage.IssueBan
490+ (*IssueKey)(nil), // 5: ermite.storage.IssueKey
491+ (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
307 }492 }
308 var file_ermite_storage_issue_proto_depIdxs = []int32{493 var file_ermite_storage_issue_proto_depIdxs = []int32{
309 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState494 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
310- 3, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp495+ 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
311- 3, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp496+ 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
312- 3, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp497+ 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
313- 4, // [4:4] is the sub-list for method output_type498+ 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
314- 4, // [4:4] is the sub-list for method input_type499+ 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
315- 4, // [4:4] is the sub-list for extension type_name500+ 6, // [6:6] is the sub-list for method output_type
316- 4, // [4:4] is the sub-list for extension extendee501+ 6, // [6:6] is the sub-list for method input_type
317- 0, // [0:4] is the sub-list for field type_name502+ 6, // [6:6] is the sub-list for extension type_name
503+ 6, // [6:6] is the sub-list for extension extendee
504+ 0, // [0:6] is the sub-list for field type_name
318 }505 }
319 506
320 func init() { file_ermite_storage_issue_proto_init() }507 func init() { file_ermite_storage_issue_proto_init() }
@@ -328,7 +515,7 @@ func file_ermite_storage_issue_proto_init() {
328 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),515 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
329 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),516 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
330 NumEnums: 1,517 NumEnums: 1,
331- NumMessages: 2,518+ NumMessages: 5,
332 NumExtensions: 0,519 NumExtensions: 0,
333 NumServices: 0,520 NumServices: 0,
334 },521 },
A internal/adapters/kvstore/issues_pending.go +416 -0
@@ -0,0 +1,416 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "time"
15+ "unicode/utf8"
16+
17+ kv "bnema.dev/ermite-kv"
18+ "github.com/bnema/zerowrap"
19+ "google.golang.org/protobuf/proto"
20+
21+ storagepb "bnema.dev/ermite/gen/ermite/storage"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// Key layout of the public issue form.
27+const (
28+ pendingPrefix = "ispend/" // ispend/<repo>/<id>
29+ banPrefix = "isban/" // isban/<hex of the hash>
30+ issueKeyKey = "meta/issuekey"
31+ issueKeyLen = 32
32+
33+ // anonymousAuthor labels an approved issue whose submitter gave no name.
34+ anonymousAuthor = "anonymous"
35+)
36+
37+// PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
38+// database of the Store it comes from.
39+type PendingIssues struct {
40+ db *kv.DB
41+}
42+
43+var _ ports.PendingIssueStore = (*PendingIssues)(nil)
44+
45+// PendingIssues returns the pending issue store over the same database. It is
46+// valid until the Store is closed.
47+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db} }
48+
49+func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
50+func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
51+ return []byte(pendingRepoPrefix(repo) + id.String())
52+}
53+
54+// pendingKeys lists every key of the snapshot that holds a pending issue of
55+// the repository.
56+func pendingKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
57+ var keys [][]byte
58+ for k := range snap.Prefix(ctx, []byte(pendingRepoPrefix(repo))) {
59+ keys = append(keys, slices.Clone(k))
60+ }
61+ return keys
62+}
63+
64+// Put stores the pending issue under a new key. The repository must exist (see
65+// guardRepoKey): domain.ErrNotFound otherwise.
66+func (s *PendingIssues) Put(ctx context.Context, p domain.PendingIssue) error {
67+ val, err := encodePending(p)
68+ if err != nil {
69+ return err
70+ }
71+ var b kv.Batch
72+ if err := guardRepoKey(ctx, s.db, &b, p.Repo); err != nil {
73+ return err
74+ }
75+ b.RequireAbsent(pendingKey(p.Repo, p.ID))
76+ b.Put(pendingKey(p.Repo, p.ID), val)
77+ if _, err := s.db.Apply(ctx, &b); err != nil {
78+ if errors.Is(err, kv.ErrConflict) {
79+ return domain.ErrConflict
80+ }
81+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
82+ }
83+ return nil
84+}
85+
86+// Get reads ispend/<repo>/<id>.
87+func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
88+ item, ok := s.db.Get(ctx, pendingKey(repo, id))
89+ if !ok {
90+ return domain.PendingIssue{}, domain.ErrNotFound
91+ }
92+ return decodePending(repo, id, item)
93+}
94+
95+// List reads the pending issues of the repository, or of all, sorted by
96+// creation time then repository then id. There are at most
97+// domain.MaxPendingIssues of them (a little more under a race), so they are
98+// read and sorted. The page token is the position of the last item returned.
99+//
100+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
101+func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
102+ prefix := pendingPrefix
103+ if !repo.IsZero() {
104+ prefix = pendingRepoPrefix(repo)
105+ }
106+ var after pendingCursor
107+ if page.PageToken != "" {
108+ var err error
109+ if after, err = parsePendingCursor(page.PageToken); err != nil {
110+ return ports.PendingIssuePage{}, err
111+ }
112+ }
113+ var all []domain.PendingIssue
114+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
115+ if err := ctx.Err(); err != nil {
116+ return ports.PendingIssuePage{}, err
117+ }
118+ r, id, err := splitPendingKey(string(key))
119+ if err != nil {
120+ return ports.PendingIssuePage{}, err
121+ }
122+ p, err := decodePending(r, id, item)
123+ if err != nil {
124+ return ports.PendingIssuePage{}, fmt.Errorf("decode %s: %w", key, err)
125+ }
126+ all = append(all, p)
127+ }
128+ slices.SortFunc(all, comparePending)
129+ if page.PageToken != "" {
130+ all = slices.DeleteFunc(all, func(p domain.PendingIssue) bool { return comparePending(p, after.item()) <= 0 })
131+ }
132+ var out ports.PendingIssuePage
133+ if page.PageSize > 0 && len(all) > page.PageSize {
134+ all = all[:page.PageSize]
135+ out.NextPageToken = encodePendingCursor(all[len(all)-1])
136+ }
137+ out.Issues = all
138+ return out, nil
139+}
140+
141+func comparePending(a, b domain.PendingIssue) int {
142+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
143+ return c
144+ }
145+ if c := strings.Compare(a.Repo.String(), b.Repo.String()); c != 0 {
146+ return c
147+ }
148+ return strings.Compare(a.ID.String(), b.ID.String())
149+}
150+
151+type pendingCursor struct {
152+ nanos int64
153+ repo domain.RepoName
154+ id domain.PendingIssueID
155+}
156+
157+func (c pendingCursor) item() domain.PendingIssue {
158+ return domain.PendingIssue{Repo: c.repo, ID: c.id, CreatedAt: time.Unix(0, c.nanos)}
159+}
160+
161+func encodePendingCursor(p domain.PendingIssue) string {
162+ return strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "/" + p.Repo.String() + "/" + p.ID.String()
163+}
164+
165+func parsePendingCursor(s string) (pendingCursor, error) {
166+ bad := fmt.Errorf("%w: page token", domain.ErrInvalid)
167+ parts := strings.Split(s, "/")
168+ if len(parts) != 3 {
169+ return pendingCursor{}, bad
170+ }
171+ n, err := strconv.ParseInt(parts[0], 10, 64)
172+ if err != nil {
173+ return pendingCursor{}, bad
174+ }
175+ repo, err := domain.ParseRepoName(parts[1])
176+ if err != nil {
177+ return pendingCursor{}, bad
178+ }
179+ id, err := domain.ParsePendingIssueID(parts[2])
180+ if err != nil {
181+ return pendingCursor{}, bad
182+ }
183+ return pendingCursor{nanos: n, repo: repo, id: id}, nil
184+}
185+
186+func splitPendingKey(key string) (domain.RepoName, domain.PendingIssueID, error) {
187+ rest := strings.TrimPrefix(key, pendingPrefix)
188+ repoName, idStr, ok := strings.Cut(rest, "/")
189+ if !ok {
190+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q", key)
191+ }
192+ repo, err := domain.ParseRepoName(repoName)
193+ if err != nil {
194+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
195+ }
196+ id, err := domain.ParsePendingIssueID(idStr)
197+ if err != nil {
198+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
199+ }
200+ return repo, id, nil
201+}
202+
203+// Count counts the keys under ispend/, stopping after MaxPendingIssues+1.
204+func (s *PendingIssues) Count(ctx context.Context) (int, error) {
205+ n := 0
206+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(pendingPrefix)) {
207+ if err := ctx.Err(); err != nil {
208+ return 0, err
209+ }
210+ n++
211+ if n > domain.MaxPendingIssues {
212+ break
213+ }
214+ }
215+ return n, nil
216+}
217+
218+// Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
219+// writes the open issue and its index key and deletes the pending record, in
220+// one batch that requires the pending record to be unchanged.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
223+func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
224+ var number int64
225+ err := createNumbered(ctx, s.db, repo, func(b *kv.Batch, n int64) error {
226+ key := pendingKey(repo, id)
227+ item, ok := s.db.Get(ctx, key)
228+ if !ok {
229+ return domain.ErrNotFound
230+ }
231+ p, err := decodePending(repo, id, item)
232+ if err != nil {
233+ return err
234+ }
235+ author := p.Author
236+ if author == "" {
237+ author = anonymousAuthor
238+ }
239+ issue := domain.Issue{
240+ Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen,
241+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt,
242+ }
243+ val, err := encodeIssue(issue)
244+ if err != nil {
245+ return err
246+ }
247+ b.RequireVersion(key, item.Version)
248+ b.Delete(key)
249+ b.RequireAbsent(issueKey(repo, n))
250+ b.Put(issueKey(repo, n), val)
251+ indexOpenIssue(b, issue)
252+ number = n
253+ return nil
254+ })
255+ if err != nil {
256+ if errors.Is(err, domain.ErrNotFound) {
257+ return domain.Issue{}, domain.ErrNotFound
258+ }
259+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
260+ }
261+ return (&Issues{db: s.db}).Get(ctx, repo, number)
262+}
263+
264+// Delete removes the pending record, or returns domain.ErrNotFound.
265+func (s *PendingIssues) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
266+ key := pendingKey(repo, id)
267+ item, ok := s.db.Get(ctx, key)
268+ if !ok {
269+ return domain.ErrNotFound
270+ }
271+ var b kv.Batch
272+ b.RequireVersion(key, item.Version)
273+ b.Delete(key)
274+ if _, err := s.db.Apply(ctx, &b); err != nil {
275+ if errors.Is(err, kv.ErrConflict) {
276+ return domain.ErrNotFound // gone or approved meanwhile
277+ }
278+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
279+ }
280+ return nil
281+}
282+
283+func encodePending(p domain.PendingIssue) ([]byte, error) {
284+ if p.Repo.IsZero() {
285+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
286+ }
287+ if _, err := domain.ParsePendingIssueID(p.ID.String()); err != nil {
288+ return nil, err
289+ }
290+ if !utf8.ValidString(p.Author) {
291+ return nil, errors.New("pending issue author is not valid UTF-8")
292+ }
293+ if len(p.IPHash) != domain.IPHashLen {
294+ return nil, fmt.Errorf("%w: ip hash is %d bytes, want %d", domain.ErrInvalid, len(p.IPHash), domain.IPHashLen)
295+ }
296+ return mustMarshal(&storagepb.PendingIssue{
297+ Title: p.Title.String(), Body: p.Body.String(), Author: p.Author,
298+ CreatedAt: encodeTime(p.CreatedAt), IpHash: p.IPHash,
299+ }), nil
300+}
301+
302+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
303+func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
304+ var pb storagepb.PendingIssue
305+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
306+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue: %w", err)
307+ }
308+ title, err := domain.ParseIssueTitle(pb.GetTitle())
309+ if err != nil {
310+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
311+ }
312+ body, err := domain.ParseIssueBody(pb.GetBody())
313+ if err != nil {
314+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
315+ }
316+ return domain.PendingIssue{
317+ ID: id, Repo: repo, Title: title, Body: body, Author: pb.GetAuthor(),
318+ CreatedAt: decodeTime(pb.GetCreatedAt()), IPHash: slices.Clone(pb.GetIpHash()), Version: item.Version,
319+ }, nil
320+}
321+
322+// IssueKeys is the ermite-kv backed ports.IssueKeyStore.
323+type IssueKeys struct {
324+ db *kv.DB
325+}
326+
327+var _ ports.IssueKeyStore = (*IssueKeys)(nil)
328+
329+// IssueKeys returns the issue key store over the same database. It is valid
330+// until the Store is closed.
331+func (s *Store) IssueKeys() *IssueKeys { return &IssueKeys{db: s.db} }
332+
333+// Key reads meta/issuekey and generates it when absent. The key is never
334+// logged.
335+func (k *IssueKeys) Key(ctx context.Context) ([]byte, error) {
336+ for range updateAttempts {
337+ if item, ok := k.db.Get(ctx, []byte(issueKeyKey)); ok {
338+ var m storagepb.IssueKey
339+ if err := proto.Unmarshal(item.Value, &m); err != nil {
340+ return nil, fmt.Errorf("decode issue key: %w", err)
341+ }
342+ if len(m.GetKey()) != issueKeyLen {
343+ return nil, fmt.Errorf("decode issue key: %d bytes, want %d", len(m.GetKey()), issueKeyLen)
344+ }
345+ return append([]byte(nil), m.GetKey()...), nil
346+ }
347+ key := make([]byte, issueKeyLen)
348+ if _, err := rand.Read(key); err != nil {
349+ return nil, fmt.Errorf("generate issue key: %w", err)
350+ }
351+ var b kv.Batch
352+ b.RequireAbsent([]byte(issueKeyKey))
353+ b.Put([]byte(issueKeyKey), mustMarshal(&storagepb.IssueKey{Key: key}))
354+ _, err := k.db.Apply(ctx, &b)
355+ if errors.Is(err, kv.ErrConflict) {
356+ continue // written meanwhile: read it
357+ }
358+ if err != nil {
359+ return nil, fmt.Errorf("store issue key: %w", err)
360+ }
361+ log := zerowrap.FromCtx(ctx)
362+ log.Info().Msg("issue key generated")
363+ return key, nil
364+ }
365+ return nil, fmt.Errorf("store issue key: %w", domain.ErrConflict)
366+}
367+
368+// IssueBans is the ermite-kv backed ports.IssueBanStore.
369+type IssueBans struct {
370+ db *kv.DB
371+}
372+
373+var _ ports.IssueBanStore = (*IssueBans)(nil)
374+
375+// IssueBans returns the ban store over the same database. It is valid until
376+// the Store is closed.
377+func (s *Store) IssueBans() *IssueBans { return &IssueBans{db: s.db} }
378+
379+func banKey(hash []byte) []byte { return []byte(banPrefix + hex.EncodeToString(hash)) }
380+
381+// Ban writes isban/<hex hash> with its expiry.
382+func (s *IssueBans) Ban(ctx context.Context, hash []byte, until time.Time) error {
383+ if len(hash) != domain.IPHashLen {
384+ return fmt.Errorf("%w: ban hash is %d bytes, want %d", domain.ErrInvalid, len(hash), domain.IPHashLen)
385+ }
386+ var b kv.Batch
387+ b.Put(banKey(hash), mustMarshal(&storagepb.IssueBan{Until: encodeTime(until)}))
388+ if _, err := s.db.Apply(ctx, &b); err != nil {
389+ return fmt.Errorf("store ban: %w", err)
390+ }
391+ return nil
392+}
393+
394+// Banned reads the ban; one that expired is not a ban and is deleted (if it
395+// has not been replaced meanwhile).
396+func (s *IssueBans) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
397+ key := banKey(hash)
398+ item, ok := s.db.Get(ctx, key)
399+ if !ok {
400+ return false, nil
401+ }
402+ var pb storagepb.IssueBan
403+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
404+ return false, fmt.Errorf("decode ban: %w", err)
405+ }
406+ if now.Before(decodeTime(pb.GetUntil())) {
407+ return true, nil
408+ }
409+ var b kv.Batch
410+ b.RequireVersion(key, item.Version)
411+ b.Delete(key)
412+ if _, err := s.db.Apply(ctx, &b); err != nil && !errors.Is(err, kv.ErrConflict) {
413+ return false, fmt.Errorf("delete expired ban: %w", err)
414+ }
415+ return false, nil
416+}
@@ -0,0 +1,416 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "time"
15+ "unicode/utf8"
16+
17+ kv "bnema.dev/ermite-kv"
18+ "github.com/bnema/zerowrap"
19+ "google.golang.org/protobuf/proto"
20+
21+ storagepb "bnema.dev/ermite/gen/ermite/storage"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// Key layout of the public issue form.
27+const (
28+ pendingPrefix = "ispend/" // ispend/<repo>/<id>
29+ banPrefix = "isban/" // isban/<hex of the hash>
30+ issueKeyKey = "meta/issuekey"
31+ issueKeyLen = 32
32+
33+ // anonymousAuthor labels an approved issue whose submitter gave no name.
34+ anonymousAuthor = "anonymous"
35+)
36+
37+// PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
38+// database of the Store it comes from.
39+type PendingIssues struct {
40+ db *kv.DB
41+}
42+
43+var _ ports.PendingIssueStore = (*PendingIssues)(nil)
44+
45+// PendingIssues returns the pending issue store over the same database. It is
46+// valid until the Store is closed.
47+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db} }
48+
49+func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
50+func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
51+ return []byte(pendingRepoPrefix(repo) + id.String())
52+}
53+
54+// pendingKeys lists every key of the snapshot that holds a pending issue of
55+// the repository.
56+func pendingKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
57+ var keys [][]byte
58+ for k := range snap.Prefix(ctx, []byte(pendingRepoPrefix(repo))) {
59+ keys = append(keys, slices.Clone(k))
60+ }
61+ return keys
62+}
63+
64+// Put stores the pending issue under a new key. The repository must exist (see
65+// guardRepoKey): domain.ErrNotFound otherwise.
66+func (s *PendingIssues) Put(ctx context.Context, p domain.PendingIssue) error {
67+ val, err := encodePending(p)
68+ if err != nil {
69+ return err
70+ }
71+ var b kv.Batch
72+ if err := guardRepoKey(ctx, s.db, &b, p.Repo); err != nil {
73+ return err
74+ }
75+ b.RequireAbsent(pendingKey(p.Repo, p.ID))
76+ b.Put(pendingKey(p.Repo, p.ID), val)
77+ if _, err := s.db.Apply(ctx, &b); err != nil {
78+ if errors.Is(err, kv.ErrConflict) {
79+ return domain.ErrConflict
80+ }
81+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
82+ }
83+ return nil
84+}
85+
86+// Get reads ispend/<repo>/<id>.
87+func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
88+ item, ok := s.db.Get(ctx, pendingKey(repo, id))
89+ if !ok {
90+ return domain.PendingIssue{}, domain.ErrNotFound
91+ }
92+ return decodePending(repo, id, item)
93+}
94+
95+// List reads the pending issues of the repository, or of all, sorted by
96+// creation time then repository then id. There are at most
97+// domain.MaxPendingIssues of them (a little more under a race), so they are
98+// read and sorted. The page token is the position of the last item returned.
99+//
100+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
101+func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
102+ prefix := pendingPrefix
103+ if !repo.IsZero() {
104+ prefix = pendingRepoPrefix(repo)
105+ }
106+ var after pendingCursor
107+ if page.PageToken != "" {
108+ var err error
109+ if after, err = parsePendingCursor(page.PageToken); err != nil {
110+ return ports.PendingIssuePage{}, err
111+ }
112+ }
113+ var all []domain.PendingIssue
114+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
115+ if err := ctx.Err(); err != nil {
116+ return ports.PendingIssuePage{}, err
117+ }
118+ r, id, err := splitPendingKey(string(key))
119+ if err != nil {
120+ return ports.PendingIssuePage{}, err
121+ }
122+ p, err := decodePending(r, id, item)
123+ if err != nil {
124+ return ports.PendingIssuePage{}, fmt.Errorf("decode %s: %w", key, err)
125+ }
126+ all = append(all, p)
127+ }
128+ slices.SortFunc(all, comparePending)
129+ if page.PageToken != "" {
130+ all = slices.DeleteFunc(all, func(p domain.PendingIssue) bool { return comparePending(p, after.item()) <= 0 })
131+ }
132+ var out ports.PendingIssuePage
133+ if page.PageSize > 0 && len(all) > page.PageSize {
134+ all = all[:page.PageSize]
135+ out.NextPageToken = encodePendingCursor(all[len(all)-1])
136+ }
137+ out.Issues = all
138+ return out, nil
139+}
140+
141+func comparePending(a, b domain.PendingIssue) int {
142+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
143+ return c
144+ }
145+ if c := strings.Compare(a.Repo.String(), b.Repo.String()); c != 0 {
146+ return c
147+ }
148+ return strings.Compare(a.ID.String(), b.ID.String())
149+}
150+
151+type pendingCursor struct {
152+ nanos int64
153+ repo domain.RepoName
154+ id domain.PendingIssueID
155+}
156+
157+func (c pendingCursor) item() domain.PendingIssue {
158+ return domain.PendingIssue{Repo: c.repo, ID: c.id, CreatedAt: time.Unix(0, c.nanos)}
159+}
160+
161+func encodePendingCursor(p domain.PendingIssue) string {
162+ return strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "/" + p.Repo.String() + "/" + p.ID.String()
163+}
164+
165+func parsePendingCursor(s string) (pendingCursor, error) {
166+ bad := fmt.Errorf("%w: page token", domain.ErrInvalid)
167+ parts := strings.Split(s, "/")
168+ if len(parts) != 3 {
169+ return pendingCursor{}, bad
170+ }
171+ n, err := strconv.ParseInt(parts[0], 10, 64)
172+ if err != nil {
173+ return pendingCursor{}, bad
174+ }
175+ repo, err := domain.ParseRepoName(parts[1])
176+ if err != nil {
177+ return pendingCursor{}, bad
178+ }
179+ id, err := domain.ParsePendingIssueID(parts[2])
180+ if err != nil {
181+ return pendingCursor{}, bad
182+ }
183+ return pendingCursor{nanos: n, repo: repo, id: id}, nil
184+}
185+
186+func splitPendingKey(key string) (domain.RepoName, domain.PendingIssueID, error) {
187+ rest := strings.TrimPrefix(key, pendingPrefix)
188+ repoName, idStr, ok := strings.Cut(rest, "/")
189+ if !ok {
190+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q", key)
191+ }
192+ repo, err := domain.ParseRepoName(repoName)
193+ if err != nil {
194+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
195+ }
196+ id, err := domain.ParsePendingIssueID(idStr)
197+ if err != nil {
198+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
199+ }
200+ return repo, id, nil
201+}
202+
203+// Count counts the keys under ispend/, stopping after MaxPendingIssues+1.
204+func (s *PendingIssues) Count(ctx context.Context) (int, error) {
205+ n := 0
206+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(pendingPrefix)) {
207+ if err := ctx.Err(); err != nil {
208+ return 0, err
209+ }
210+ n++
211+ if n > domain.MaxPendingIssues {
212+ break
213+ }
214+ }
215+ return n, nil
216+}
217+
218+// Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
219+// writes the open issue and its index key and deletes the pending record, in
220+// one batch that requires the pending record to be unchanged.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
223+func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
224+ var number int64
225+ err := createNumbered(ctx, s.db, repo, func(b *kv.Batch, n int64) error {
226+ key := pendingKey(repo, id)
227+ item, ok := s.db.Get(ctx, key)
228+ if !ok {
229+ return domain.ErrNotFound
230+ }
231+ p, err := decodePending(repo, id, item)
232+ if err != nil {
233+ return err
234+ }
235+ author := p.Author
236+ if author == "" {
237+ author = anonymousAuthor
238+ }
239+ issue := domain.Issue{
240+ Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen,
241+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt,
242+ }
243+ val, err := encodeIssue(issue)
244+ if err != nil {
245+ return err
246+ }
247+ b.RequireVersion(key, item.Version)
248+ b.Delete(key)
249+ b.RequireAbsent(issueKey(repo, n))
250+ b.Put(issueKey(repo, n), val)
251+ indexOpenIssue(b, issue)
252+ number = n
253+ return nil
254+ })
255+ if err != nil {
256+ if errors.Is(err, domain.ErrNotFound) {
257+ return domain.Issue{}, domain.ErrNotFound
258+ }
259+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
260+ }
261+ return (&Issues{db: s.db}).Get(ctx, repo, number)
262+}
263+
264+// Delete removes the pending record, or returns domain.ErrNotFound.
265+func (s *PendingIssues) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
266+ key := pendingKey(repo, id)
267+ item, ok := s.db.Get(ctx, key)
268+ if !ok {
269+ return domain.ErrNotFound
270+ }
271+ var b kv.Batch
272+ b.RequireVersion(key, item.Version)
273+ b.Delete(key)
274+ if _, err := s.db.Apply(ctx, &b); err != nil {
275+ if errors.Is(err, kv.ErrConflict) {
276+ return domain.ErrNotFound // gone or approved meanwhile
277+ }
278+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
279+ }
280+ return nil
281+}
282+
283+func encodePending(p domain.PendingIssue) ([]byte, error) {
284+ if p.Repo.IsZero() {
285+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
286+ }
287+ if _, err := domain.ParsePendingIssueID(p.ID.String()); err != nil {
288+ return nil, err
289+ }
290+ if !utf8.ValidString(p.Author) {
291+ return nil, errors.New("pending issue author is not valid UTF-8")
292+ }
293+ if len(p.IPHash) != domain.IPHashLen {
294+ return nil, fmt.Errorf("%w: ip hash is %d bytes, want %d", domain.ErrInvalid, len(p.IPHash), domain.IPHashLen)
295+ }
296+ return mustMarshal(&storagepb.PendingIssue{
297+ Title: p.Title.String(), Body: p.Body.String(), Author: p.Author,
298+ CreatedAt: encodeTime(p.CreatedAt), IpHash: p.IPHash,
299+ }), nil
300+}
301+
302+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
303+func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
304+ var pb storagepb.PendingIssue
305+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
306+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue: %w", err)
307+ }
308+ title, err := domain.ParseIssueTitle(pb.GetTitle())
309+ if err != nil {
310+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
311+ }
312+ body, err := domain.ParseIssueBody(pb.GetBody())
313+ if err != nil {
314+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
315+ }
316+ return domain.PendingIssue{
317+ ID: id, Repo: repo, Title: title, Body: body, Author: pb.GetAuthor(),
318+ CreatedAt: decodeTime(pb.GetCreatedAt()), IPHash: slices.Clone(pb.GetIpHash()), Version: item.Version,
319+ }, nil
320+}
321+
322+// IssueKeys is the ermite-kv backed ports.IssueKeyStore.
323+type IssueKeys struct {
324+ db *kv.DB
325+}
326+
327+var _ ports.IssueKeyStore = (*IssueKeys)(nil)
328+
329+// IssueKeys returns the issue key store over the same database. It is valid
330+// until the Store is closed.
331+func (s *Store) IssueKeys() *IssueKeys { return &IssueKeys{db: s.db} }
332+
333+// Key reads meta/issuekey and generates it when absent. The key is never
334+// logged.
335+func (k *IssueKeys) Key(ctx context.Context) ([]byte, error) {
336+ for range updateAttempts {
337+ if item, ok := k.db.Get(ctx, []byte(issueKeyKey)); ok {
338+ var m storagepb.IssueKey
339+ if err := proto.Unmarshal(item.Value, &m); err != nil {
340+ return nil, fmt.Errorf("decode issue key: %w", err)
341+ }
342+ if len(m.GetKey()) != issueKeyLen {
343+ return nil, fmt.Errorf("decode issue key: %d bytes, want %d", len(m.GetKey()), issueKeyLen)
344+ }
345+ return append([]byte(nil), m.GetKey()...), nil
346+ }
347+ key := make([]byte, issueKeyLen)
348+ if _, err := rand.Read(key); err != nil {
349+ return nil, fmt.Errorf("generate issue key: %w", err)
350+ }
351+ var b kv.Batch
352+ b.RequireAbsent([]byte(issueKeyKey))
353+ b.Put([]byte(issueKeyKey), mustMarshal(&storagepb.IssueKey{Key: key}))
354+ _, err := k.db.Apply(ctx, &b)
355+ if errors.Is(err, kv.ErrConflict) {
356+ continue // written meanwhile: read it
357+ }
358+ if err != nil {
359+ return nil, fmt.Errorf("store issue key: %w", err)
360+ }
361+ log := zerowrap.FromCtx(ctx)
362+ log.Info().Msg("issue key generated")
363+ return key, nil
364+ }
365+ return nil, fmt.Errorf("store issue key: %w", domain.ErrConflict)
366+}
367+
368+// IssueBans is the ermite-kv backed ports.IssueBanStore.
369+type IssueBans struct {
370+ db *kv.DB
371+}
372+
373+var _ ports.IssueBanStore = (*IssueBans)(nil)
374+
375+// IssueBans returns the ban store over the same database. It is valid until
376+// the Store is closed.
377+func (s *Store) IssueBans() *IssueBans { return &IssueBans{db: s.db} }
378+
379+func banKey(hash []byte) []byte { return []byte(banPrefix + hex.EncodeToString(hash)) }
380+
381+// Ban writes isban/<hex hash> with its expiry.
382+func (s *IssueBans) Ban(ctx context.Context, hash []byte, until time.Time) error {
383+ if len(hash) != domain.IPHashLen {
384+ return fmt.Errorf("%w: ban hash is %d bytes, want %d", domain.ErrInvalid, len(hash), domain.IPHashLen)
385+ }
386+ var b kv.Batch
387+ b.Put(banKey(hash), mustMarshal(&storagepb.IssueBan{Until: encodeTime(until)}))
388+ if _, err := s.db.Apply(ctx, &b); err != nil {
389+ return fmt.Errorf("store ban: %w", err)
390+ }
391+ return nil
392+}
393+
394+// Banned reads the ban; one that expired is not a ban and is deleted (if it
395+// has not been replaced meanwhile).
396+func (s *IssueBans) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
397+ key := banKey(hash)
398+ item, ok := s.db.Get(ctx, key)
399+ if !ok {
400+ return false, nil
401+ }
402+ var pb storagepb.IssueBan
403+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
404+ return false, fmt.Errorf("decode ban: %w", err)
405+ }
406+ if now.Before(decodeTime(pb.GetUntil())) {
407+ return true, nil
408+ }
409+ var b kv.Batch
410+ b.RequireVersion(key, item.Version)
411+ b.Delete(key)
412+ if _, err := s.db.Apply(ctx, &b); err != nil && !errors.Is(err, kv.ErrConflict) {
413+ return false, fmt.Errorf("delete expired ban: %w", err)
414+ }
415+ return false, nil
416+}
A internal/adapters/kvstore/issues_pending_test.go +269 -0
@@ -0,0 +1,269 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20+ t.Helper()
21+ id, err := domain.NewPendingIssueID()
22+ require.NoError(t, err)
23+ ti, err := domain.ParseIssueTitle(title)
24+ require.NoError(t, err)
25+ body, err := domain.ParseIssueBody("details")
26+ require.NoError(t, err)
27+ return domain.PendingIssue{
28+ ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29+ IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30+ }
31+}
32+
33+func TestPendingIssuePutGetListCount(t *testing.T) {
34+ ctx := context.Background()
35+ s, _, n := repoWithMRs(t)
36+ other := name(t, "other")
37+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38+ ps := s.PendingIssues()
39+
40+ a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41+ b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42+ c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43+ for _, p := range []domain.PendingIssue{c, a, b} {
44+ require.NoError(t, ps.Put(ctx, p))
45+ }
46+ require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47+ require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48+
49+ got, err := ps.Get(ctx, n, a.ID)
50+ require.NoError(t, err)
51+ assert.Equal(t, a.Title, got.Title)
52+ assert.Equal(t, "Alex", got.Author)
53+ assert.Equal(t, a.IPHash, got.IPHash)
54+ assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55+ assert.NotZero(t, got.Version)
56+ _, err = ps.Get(ctx, other, a.ID)
57+ assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58+
59+ all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60+ require.NoError(t, err)
61+ require.Len(t, all.Issues, 3)
62+ assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63+
64+ mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65+ require.NoError(t, err)
66+ assert.Len(t, mine.Issues, 2)
67+
68+ p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69+ require.NoError(t, err)
70+ require.Len(t, p1.Issues, 2)
71+ require.NotEmpty(t, p1.NextPageToken)
72+ p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73+ require.NoError(t, err)
74+ require.Len(t, p2.Issues, 1)
75+ assert.Equal(t, c.ID, p2.Issues[0].ID)
76+ assert.Empty(t, p2.NextPageToken)
77+ _, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78+ assert.ErrorIs(t, err, domain.ErrInvalid)
79+
80+ cnt, err := ps.Count(ctx)
81+ require.NoError(t, err)
82+ assert.Equal(t, 3, cnt)
83+
84+ require.NoError(t, ps.Delete(ctx, n, a.ID))
85+ assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86+ cnt, err = ps.Count(ctx)
87+ require.NoError(t, err)
88+ assert.Equal(t, 2, cnt)
89+}
90+
91+func TestPendingIssueCountIsCapped(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ ps := s.PendingIssues()
95+ for i := range domain.MaxPendingIssues + 5 {
96+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97+ }
98+ cnt, err := ps.Count(ctx)
99+ require.NoError(t, err)
100+ assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101+}
102+
103+func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104+ ctx := context.Background()
105+ s, mrs, n := repoWithMRs(t)
106+ ps := s.PendingIssues()
107+
108+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109+ require.NoError(t, err)
110+ assert.Equal(t, int64(1), mr.Number)
111+
112+ named := newPending(t, n, "named", "Alex", created)
113+ anon := newPending(t, n, "anon", "", created.Add(time.Second))
114+ require.NoError(t, ps.Put(ctx, named))
115+ require.NoError(t, ps.Put(ctx, anon))
116+
117+ issue, err := ps.Approve(ctx, n, named.ID)
118+ require.NoError(t, err)
119+ assert.Equal(t, int64(2), issue.Number)
120+ assert.Equal(t, domain.IssueStateOpen, issue.State)
121+ assert.Equal(t, "Alex", issue.Author)
122+ assert.Equal(t, "named", issue.Title.String())
123+
124+ _, err = ps.Get(ctx, n, named.ID)
125+ assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
126+ _, err = ps.Approve(ctx, n, named.ID)
127+ assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
128+
129+ got, err := s.Issues().Get(ctx, n, 2)
130+ require.NoError(t, err)
131+ assert.Equal(t, issue.Title, got.Title)
132+ open, err := s.Issues().ListOpen(ctx, n)
133+ require.NoError(t, err)
134+ require.Len(t, open, 1)
135+ assert.Equal(t, int64(2), open[0].Number)
136+
137+ second, err := ps.Approve(ctx, n, anon.ID)
138+ require.NoError(t, err)
139+ assert.Equal(t, int64(3), second.Number)
140+ assert.Equal(t, "anonymous", second.Author)
141+
142+ next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
143+ require.NoError(t, err)
144+ assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
145+}
146+
147+func TestPendingIssueApproveConcurrent(t *testing.T) {
148+ ctx := context.Background()
149+ s, mrs, n := repoWithMRs(t)
150+ ps := s.PendingIssues()
151+ const k = 8
152+ ids := make([]domain.PendingIssueID, k)
153+ for i := range k {
154+ p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
155+ ids[i] = p.ID
156+ require.NoError(t, ps.Put(ctx, p))
157+ }
158+ var mu sync.Mutex
159+ seen := map[int64]bool{}
160+ var wg sync.WaitGroup
161+ for _, id := range ids {
162+ wg.Add(1)
163+ go func() {
164+ defer wg.Done()
165+ is, err := ps.Approve(ctx, n, id)
166+ if assert.NoError(t, err) {
167+ mu.Lock()
168+ seen[is.Number] = true
169+ mu.Unlock()
170+ }
171+ }()
172+ }
173+ wg.Add(1)
174+ go func() {
175+ defer wg.Done()
176+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
177+ if assert.NoError(t, err) {
178+ mu.Lock()
179+ seen[mr.Number] = true
180+ mu.Unlock()
181+ }
182+ }()
183+ wg.Wait()
184+ assert.Len(t, seen, k+1, "no number is given twice")
185+}
186+
187+func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
188+ ctx := context.Background()
189+ s, _, n := repoWithMRs(t)
190+ ps := s.PendingIssues()
191+ p := newPending(t, n, "t", "", created)
192+ require.NoError(t, ps.Put(ctx, p))
193+ require.NoError(t, s.Delete(ctx, n))
194+
195+ _, err := ps.Approve(ctx, n, p.ID)
196+ assert.ErrorIs(t, err, domain.ErrNotFound)
197+ assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
198+ assert.Zero(t, countKeys(t, s, "is/"))
199+ assert.Zero(t, countKeys(t, s, "isopen/"))
200+}
201+
202+func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
203+ ctx := context.Background()
204+ s, _, n := repoWithMRs(t)
205+ other := name(t, "other")
206+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
207+ ps := s.PendingIssues()
208+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
209+ require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
210+ keep := newPending(t, other, "k", "", created)
211+ require.NoError(t, ps.Put(ctx, keep))
212+
213+ require.NoError(t, s.Delete(ctx, n))
214+ assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
215+ _, err := ps.Get(ctx, other, keep.ID)
216+ assert.NoError(t, err, "another repository keeps its pending issues")
217+ cnt, err := ps.Count(ctx)
218+ require.NoError(t, err)
219+ assert.Equal(t, 1, cnt)
220+}
221+
222+func TestIssueBansExpire(t *testing.T) {
223+ ctx := context.Background()
224+ s := openStore(t, t.TempDir())
225+ bans := s.IssueBans()
226+ h := bytes.Repeat([]byte{1}, domain.IPHashLen)
227+ other := bytes.Repeat([]byte{2}, domain.IPHashLen)
228+ now := created
229+
230+ banned, err := bans.Banned(ctx, h, now)
231+ require.NoError(t, err)
232+ assert.False(t, banned)
233+
234+ require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
235+ require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
236+ banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
237+ require.NoError(t, err)
238+ assert.True(t, banned)
239+ banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
240+ require.NoError(t, err)
241+ assert.False(t, banned, "another hash is not banned")
242+ assert.Equal(t, 1, countKeys(t, s, "isban/"))
243+
244+ banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
245+ require.NoError(t, err)
246+ assert.False(t, banned, "expired")
247+ assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
248+}
249+
250+func TestIssueKeyPersists(t *testing.T) {
251+ ctx := context.Background()
252+ dir := t.TempDir()
253+ s := openStore(t, dir)
254+ key, err := s.IssueKeys().Key(ctx)
255+ require.NoError(t, err)
256+ assert.Len(t, key, 32)
257+ again, err := s.IssueKeys().Key(ctx)
258+ require.NoError(t, err)
259+ assert.Equal(t, key, again)
260+ sess, err := s.Sessions().Key(ctx)
261+ require.NoError(t, err)
262+ assert.NotEqual(t, sess, key, "not the session key")
263+ require.NoError(t, s.Close(ctx))
264+
265+ s = openStore(t, dir)
266+ reopened, err := s.IssueKeys().Key(ctx)
267+ require.NoError(t, err)
268+ assert.Equal(t, key, reopened, "a restart keeps the key")
269+}
@@ -0,0 +1,269 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20+ t.Helper()
21+ id, err := domain.NewPendingIssueID()
22+ require.NoError(t, err)
23+ ti, err := domain.ParseIssueTitle(title)
24+ require.NoError(t, err)
25+ body, err := domain.ParseIssueBody("details")
26+ require.NoError(t, err)
27+ return domain.PendingIssue{
28+ ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29+ IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30+ }
31+}
32+
33+func TestPendingIssuePutGetListCount(t *testing.T) {
34+ ctx := context.Background()
35+ s, _, n := repoWithMRs(t)
36+ other := name(t, "other")
37+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38+ ps := s.PendingIssues()
39+
40+ a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41+ b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42+ c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43+ for _, p := range []domain.PendingIssue{c, a, b} {
44+ require.NoError(t, ps.Put(ctx, p))
45+ }
46+ require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47+ require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48+
49+ got, err := ps.Get(ctx, n, a.ID)
50+ require.NoError(t, err)
51+ assert.Equal(t, a.Title, got.Title)
52+ assert.Equal(t, "Alex", got.Author)
53+ assert.Equal(t, a.IPHash, got.IPHash)
54+ assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55+ assert.NotZero(t, got.Version)
56+ _, err = ps.Get(ctx, other, a.ID)
57+ assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58+
59+ all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60+ require.NoError(t, err)
61+ require.Len(t, all.Issues, 3)
62+ assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63+
64+ mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65+ require.NoError(t, err)
66+ assert.Len(t, mine.Issues, 2)
67+
68+ p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69+ require.NoError(t, err)
70+ require.Len(t, p1.Issues, 2)
71+ require.NotEmpty(t, p1.NextPageToken)
72+ p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73+ require.NoError(t, err)
74+ require.Len(t, p2.Issues, 1)
75+ assert.Equal(t, c.ID, p2.Issues[0].ID)
76+ assert.Empty(t, p2.NextPageToken)
77+ _, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78+ assert.ErrorIs(t, err, domain.ErrInvalid)
79+
80+ cnt, err := ps.Count(ctx)
81+ require.NoError(t, err)
82+ assert.Equal(t, 3, cnt)
83+
84+ require.NoError(t, ps.Delete(ctx, n, a.ID))
85+ assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86+ cnt, err = ps.Count(ctx)
87+ require.NoError(t, err)
88+ assert.Equal(t, 2, cnt)
89+}
90+
91+func TestPendingIssueCountIsCapped(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ ps := s.PendingIssues()
95+ for i := range domain.MaxPendingIssues + 5 {
96+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97+ }
98+ cnt, err := ps.Count(ctx)
99+ require.NoError(t, err)
100+ assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101+}
102+
103+func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104+ ctx := context.Background()
105+ s, mrs, n := repoWithMRs(t)
106+ ps := s.PendingIssues()
107+
108+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109+ require.NoError(t, err)
110+ assert.Equal(t, int64(1), mr.Number)
111+
112+ named := newPending(t, n, "named", "Alex", created)
113+ anon := newPending(t, n, "anon", "", created.Add(time.Second))
114+ require.NoError(t, ps.Put(ctx, named))
115+ require.NoError(t, ps.Put(ctx, anon))
116+
117+ issue, err := ps.Approve(ctx, n, named.ID)
118+ require.NoError(t, err)
119+ assert.Equal(t, int64(2), issue.Number)
120+ assert.Equal(t, domain.IssueStateOpen, issue.State)
121+ assert.Equal(t, "Alex", issue.Author)
122+ assert.Equal(t, "named", issue.Title.String())
123+
124+ _, err = ps.Get(ctx, n, named.ID)
125+ assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
126+ _, err = ps.Approve(ctx, n, named.ID)
127+ assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
128+
129+ got, err := s.Issues().Get(ctx, n, 2)
130+ require.NoError(t, err)
131+ assert.Equal(t, issue.Title, got.Title)
132+ open, err := s.Issues().ListOpen(ctx, n)
133+ require.NoError(t, err)
134+ require.Len(t, open, 1)
135+ assert.Equal(t, int64(2), open[0].Number)
136+
137+ second, err := ps.Approve(ctx, n, anon.ID)
138+ require.NoError(t, err)
139+ assert.Equal(t, int64(3), second.Number)
140+ assert.Equal(t, "anonymous", second.Author)
141+
142+ next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
143+ require.NoError(t, err)
144+ assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
145+}
146+
147+func TestPendingIssueApproveConcurrent(t *testing.T) {
148+ ctx := context.Background()
149+ s, mrs, n := repoWithMRs(t)
150+ ps := s.PendingIssues()
151+ const k = 8
152+ ids := make([]domain.PendingIssueID, k)
153+ for i := range k {
154+ p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
155+ ids[i] = p.ID
156+ require.NoError(t, ps.Put(ctx, p))
157+ }
158+ var mu sync.Mutex
159+ seen := map[int64]bool{}
160+ var wg sync.WaitGroup
161+ for _, id := range ids {
162+ wg.Add(1)
163+ go func() {
164+ defer wg.Done()
165+ is, err := ps.Approve(ctx, n, id)
166+ if assert.NoError(t, err) {
167+ mu.Lock()
168+ seen[is.Number] = true
169+ mu.Unlock()
170+ }
171+ }()
172+ }
173+ wg.Add(1)
174+ go func() {
175+ defer wg.Done()
176+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
177+ if assert.NoError(t, err) {
178+ mu.Lock()
179+ seen[mr.Number] = true
180+ mu.Unlock()
181+ }
182+ }()
183+ wg.Wait()
184+ assert.Len(t, seen, k+1, "no number is given twice")
185+}
186+
187+func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
188+ ctx := context.Background()
189+ s, _, n := repoWithMRs(t)
190+ ps := s.PendingIssues()
191+ p := newPending(t, n, "t", "", created)
192+ require.NoError(t, ps.Put(ctx, p))
193+ require.NoError(t, s.Delete(ctx, n))
194+
195+ _, err := ps.Approve(ctx, n, p.ID)
196+ assert.ErrorIs(t, err, domain.ErrNotFound)
197+ assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
198+ assert.Zero(t, countKeys(t, s, "is/"))
199+ assert.Zero(t, countKeys(t, s, "isopen/"))
200+}
201+
202+func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
203+ ctx := context.Background()
204+ s, _, n := repoWithMRs(t)
205+ other := name(t, "other")
206+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
207+ ps := s.PendingIssues()
208+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
209+ require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
210+ keep := newPending(t, other, "k", "", created)
211+ require.NoError(t, ps.Put(ctx, keep))
212+
213+ require.NoError(t, s.Delete(ctx, n))
214+ assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
215+ _, err := ps.Get(ctx, other, keep.ID)
216+ assert.NoError(t, err, "another repository keeps its pending issues")
217+ cnt, err := ps.Count(ctx)
218+ require.NoError(t, err)
219+ assert.Equal(t, 1, cnt)
220+}
221+
222+func TestIssueBansExpire(t *testing.T) {
223+ ctx := context.Background()
224+ s := openStore(t, t.TempDir())
225+ bans := s.IssueBans()
226+ h := bytes.Repeat([]byte{1}, domain.IPHashLen)
227+ other := bytes.Repeat([]byte{2}, domain.IPHashLen)
228+ now := created
229+
230+ banned, err := bans.Banned(ctx, h, now)
231+ require.NoError(t, err)
232+ assert.False(t, banned)
233+
234+ require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
235+ require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
236+ banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
237+ require.NoError(t, err)
238+ assert.True(t, banned)
239+ banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
240+ require.NoError(t, err)
241+ assert.False(t, banned, "another hash is not banned")
242+ assert.Equal(t, 1, countKeys(t, s, "isban/"))
243+
244+ banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
245+ require.NoError(t, err)
246+ assert.False(t, banned, "expired")
247+ assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
248+}
249+
250+func TestIssueKeyPersists(t *testing.T) {
251+ ctx := context.Background()
252+ dir := t.TempDir()
253+ s := openStore(t, dir)
254+ key, err := s.IssueKeys().Key(ctx)
255+ require.NoError(t, err)
256+ assert.Len(t, key, 32)
257+ again, err := s.IssueKeys().Key(ctx)
258+ require.NoError(t, err)
259+ assert.Equal(t, key, again)
260+ sess, err := s.Sessions().Key(ctx)
261+ require.NoError(t, err)
262+ assert.NotEqual(t, sess, key, "not the session key")
263+ require.NoError(t, s.Close(ctx))
264+
265+ s = openStore(t, dir)
266+ reopened, err := s.IssueKeys().Key(ctx)
267+ require.NoError(t, err)
268+ assert.Equal(t, key, reopened, "a restart keeps the key")
269+}
M internal/adapters/kvstore/repos.go +1 -0
@@ -145,6 +145,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
145145 snap := s.db.Snapshot(ctx)
146146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147147 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, pendingKeys(ctx, snap, name)...)
148149 keys = append(keys, ciKeys(ctx, snap, name)...)
149150 keys = append(keys, settingsKeys(ctx, snap, name)...)
150151 for len(keys) > deleteChunk {
@@ -145,6 +145,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
145 snap := s.db.Snapshot(ctx)145 snap := s.db.Snapshot(ctx)
146 repoItem, repoOK := snap.Get(ctx, repoKey(name))146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)147 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, pendingKeys(ctx, snap, name)...)
148 keys = append(keys, ciKeys(ctx, snap, name)...)149 keys = append(keys, ciKeys(ctx, snap, name)...)
149 keys = append(keys, settingsKeys(ctx, snap, name)...)150 keys = append(keys, settingsKeys(ctx, snap, name)...)
150 for len(keys) > deleteChunk {151 for len(keys) > deleteChunk {
M internal/adapters/kvstore/store.go +4 -1
@@ -33,7 +33,10 @@ import (
3333 // isopen/, numbered from the same mrseq/ sequence as the merge requests, and
3434 // RepoSettings.issues): an older build would not delete them with their
3535 // repository, nor keep the numbers apart, and it would misread the issue:write
36-// scope, so it must refuse the data. The upgrade also adds issue:write to the
36+// scope, so it must refuse the data. It also adds the pending issues of the
37+// public form (ispend/<repo>/<id>, removed with their repository), the bans of
38+// their submitters (isban/<hex hash>) and the form's server key
39+// (meta/issuekey). The upgrade also adds issue:write to the
3740 // stored tokens that held all nine earlier scopes with no repository list and
3841 // no branch prefix, so that they keep the owner rights (see upgradeTokens).
3942 const SchemaVersion = 4
@@ -33,7 +33,10 @@ import (
33 // isopen/, numbered from the same mrseq/ sequence as the merge requests, and33 // isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34 // RepoSettings.issues): an older build would not delete them with their34 // RepoSettings.issues): an older build would not delete them with their
35 // repository, nor keep the numbers apart, and it would misread the issue:write35 // repository, nor keep the numbers apart, and it would misread the issue:write
36-// scope, so it must refuse the data. The upgrade also adds issue:write to the36+// scope, so it must refuse the data. It also adds the pending issues of the
37+// public form (ispend/<repo>/<id>, removed with their repository), the bans of
38+// their submitters (isban/<hex hash>) and the form's server key
39+// (meta/issuekey). The upgrade also adds issue:write to the
37 // stored tokens that held all nine earlier scopes with no repository list and40 // stored tokens that held all nine earlier scopes with no repository list and
38 // no branch prefix, so that they keep the owner rights (see upgradeTokens).41 // no branch prefix, so that they keep the owner rights (see upgradeTokens).
39 const SchemaVersion = 442 const SchemaVersion = 4
M proto/ermite/storage/issue.proto +23 -0
@@ -43,3 +43,26 @@ message IssueComment {
4343 string text = 3;
4444 google.protobuf.Timestamp created_at = 4;
4545 }
46+
47+// PendingIssue is an issue submitted through the public form, stored under
48+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
49+// It has no number until the owner approves it.
50+message PendingIssue {
51+ string title = 1;
52+ string body = 2;
53+ // Display name given by the submitter. May be empty.
54+ string author = 3;
55+ google.protobuf.Timestamp created_at = 4;
56+ // HMAC-SHA256 of the client key (32 bytes).
57+ bytes ip_hash = 5;
58+}
59+
60+// IssueBan is stored under the key "isban/<hex of the hash>".
61+message IssueBan {
62+ google.protobuf.Timestamp until = 1;
63+}
64+
65+// IssueKey is stored under the key "meta/issuekey".
66+message IssueKey {
67+ bytes key = 1;
68+}
@@ -43,3 +43,26 @@ message IssueComment {
43 string text = 3;43 string text = 3;
44 google.protobuf.Timestamp created_at = 4;44 google.protobuf.Timestamp created_at = 4;
45 }45 }
46+
47+// PendingIssue is an issue submitted through the public form, stored under
48+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
49+// It has no number until the owner approves it.
50+message PendingIssue {
51+ string title = 1;
52+ string body = 2;
53+ // Display name given by the submitter. May be empty.
54+ string author = 3;
55+ google.protobuf.Timestamp created_at = 4;
56+ // HMAC-SHA256 of the client key (32 bytes).
57+ bytes ip_hash = 5;
58+}
59+
60+// IssueBan is stored under the key "isban/<hex of the hash>".
61+message IssueBan {
62+ google.protobuf.Timestamp until = 1;
63+}
64+
65+// IssueKey is stored under the key "meta/issuekey".
66+message IssueKey {
67+ bytes key = 1;
68+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close