detached 4bec9a0

#18 feat(issues): link issues and merge requests

state
merged · 4bec9a0 · fast-forward
branch
feat/issue-mr-links → main
opened
bnema ·
updated

Issue ↔ merge request links.

  • Closing: close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved (optional ':'), lists like 'fixes #3, #4 and #5', in the MR title, description or commit messages close the issue when the MR is merged (never at creation or on close). The issue shows 'closed by !M'; a manual reopen clears it. Issues off: nothing is closed.
  • Back-references: any #N naming an issue in the MR title, description, notes or commit messages lists the MR on the issue (web rail section, API references, 'ermite issue show' text and JSON). Links are recomputed per MR (create, ready, notes, push, merge, moved branches), so removing a mention removes it. Limits: 100 most recent commits, 16 KiB per text, existing issues only.
  • Markdown: #N in issue and MR text links to the issue or MR it names.
  • Live: issue pages get a reload notice when a link is added or removed.
  • Storage: isref/ and mrref/ keys, Issue.closed_by_merge_request; repo deletion cascades.

Depends on ermite-api!7. go.mod holds a temporary local replace for bnema.dev/ermite-api, so CI fails until ermite-api v0.13.0 is tagged and the replace is swapped for the version.

CommitSubjectAuthorDateSignatureStatus
4bec9a0refactor(web): render the issue body and its merge request links in their own helpersbriceGPG Verified
804d55bMerge branch 'main' into feat/issue-mr-linksbriceGPG Verified
8782b5abuild(deps): require bnema.dev/ermite-api v0.13.0 for issue referencesbriceGPG Verified
b58599afix(issues): serialize issue link reindexing, keep truncated references whole and refresh links after mergesbriceGPG Verified
29c90bbMerge branch 'feat/issue-mr-links-api' into feat/issue-mr-linksbriceGPG Verified
7dac988feat(issues): show merge request references and closing merge request in the API and CLIbriceGPG Verified
6859f89feat(web): link #N references in Markdown and show merge request links on issue pagesbriceGPG Verified
1386983feat(issues): index merge request mentions of issues and close linked issues on mergebriceGPG Verified
8a27537feat(issues): parse issue references and store issue to merge request linksbriceGPG Verified

Checks 8

48 files · +2515 -179
7/8
M docs/cli.md +1 -0
@@ -61,6 +61,7 @@ ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--del
6161
6262 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6363 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
6465
6566 ermite run list <repo> [--workflow …] [--branch …] [--status …]
6667 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
@@ -61,6 +61,7 @@ ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--del
61 61
62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
64 65
65 ermite run list <repo> [--workflow …] [--branch …] [--status …]66 ermite run list <repo> [--workflow …] [--branch …] [--status …]
66 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]67 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
M docs/issues.md +12 -0
@@ -34,6 +34,18 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br
3434
3535 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
3636
37+## Merge request links
38+
39+A merge request and the issues of its repository are linked by `#N` in its text:
40+
41+- **Closing keywords** close the issue when the merge request is **merged**, and only then: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` (any case), then `#N`, with an optional `:` after the keyword. A list works: `fixes #3, #4 and #5`. They count in the title, the description and the commit messages, not in notes. Closing the merge request without merging closes nothing.
42+- **Any `#N`** in the title, description, notes or commit messages shows the merge request on the issue (`ermite issue show`, the API `references`), with its state and whether it closes the issue.
43+- A closed issue records the merge request that closed it ("closed by !N"). Reopening or closing by hand clears it.
44+- The links are recomputed whenever the merge request changes (a note added, edited or deleted, a push to its branch, a merge), so removing a mention removes the link.
45+- Words glued to the number (`abc#3`), `&#39;` and `##3` are not references. Code spans are not skipped.
46+
47+Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 100 merge requests listed per issue. Only numbers of existing issues are linked: a mention of a number that does not exist yet is not filled in later. With issues `off`, nothing is closed.
48+
3749 ## Inbox
3850
3951 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
@@ -34,6 +34,18 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br
34 34
35 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.35 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
36 36
37+## Merge request links
38+
39+A merge request and the issues of its repository are linked by `#N` in its text:
40+
41+- **Closing keywords** close the issue when the merge request is **merged**, and only then: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` (any case), then `#N`, with an optional `:` after the keyword. A list works: `fixes #3, #4 and #5`. They count in the title, the description and the commit messages, not in notes. Closing the merge request without merging closes nothing.
42+- **Any `#N`** in the title, description, notes or commit messages shows the merge request on the issue (`ermite issue show`, the API `references`), with its state and whether it closes the issue.
43+- A closed issue records the merge request that closed it ("closed by !N"). Reopening or closing by hand clears it.
44+- The links are recomputed whenever the merge request changes (a note added, edited or deleted, a push to its branch, a merge), so removing a mention removes the link.
45+- Words glued to the number (`abc#3`), `&#39;` and `##3` are not references. Code spans are not skipped.
46+
47+Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 100 merge requests listed per issue. Only numbers of existing issues are linked: a mention of a number that does not exist yet is not filled in later. With issues `off`, nothing is closed.
48+
37 ## Inbox49 ## Inbox
38 50
39 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
M docs/merge-requests.md +2 -0
@@ -8,6 +8,8 @@ ermite mr show myrepo 1 --diff
88 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
99 ```
1010
11+Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issues](issues.md#merge-request-links).
12+
1113 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
1214 merge request web URL on its own line. With `--json`, merge request objects
1315 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -8,6 +8,8 @@ ermite mr show myrepo 1 --diff
8 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]8 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
9 ```9 ```
10 10
11+Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issues](issues.md#merge-request-links).
12+
11 `ermite mr create` and `ermite mr close` print a confirmation followed by the full13 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
12 merge request web URL on its own line. With `--json`, merge request objects14 merge request web URL on its own line. With `--json`, merge request objects
13 (create, show, list, close and merge) include `url` alongside the other fields.15 (create, show, list, close and merge) include `url` alongside the other fields.
M gen/ermite/storage/issue.pb.go +146 -31
@@ -97,9 +97,10 @@ type Issue struct {
9797 CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
9898 // True for an issue approved from the public form: its text comes from
9999 // an anonymous visitor and is rendered as untrusted.
100- External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"`
101- unknownFields protoimpl.UnknownFields
102- sizeCache protoimpl.SizeCache
100+ External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"`
101+ ClosedByMergeRequest int64 `protobuf:"varint,10,opt,name=closed_by_merge_request,json=closedByMergeRequest,proto3" json:"closed_by_merge_request,omitempty"`
102+ unknownFields protoimpl.UnknownFields
103+ sizeCache protoimpl.SizeCache
103104 }
104105
105106 func (x *Issue) Reset() {
@@ -195,6 +196,111 @@ func (x *Issue) GetExternal() bool {
195196 return false
196197 }
197198
199+func (x *Issue) GetClosedByMergeRequest() int64 {
200+ if x != nil {
201+ return x.ClosedByMergeRequest
202+ }
203+ return 0
204+}
205+
206+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
207+type IssueLink struct {
208+ state protoimpl.MessageState `protogen:"open.v1"`
209+ Closes bool `protobuf:"varint,1,opt,name=closes,proto3" json:"closes,omitempty"`
210+ unknownFields protoimpl.UnknownFields
211+ sizeCache protoimpl.SizeCache
212+}
213+
214+func (x *IssueLink) Reset() {
215+ *x = IssueLink{}
216+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
217+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
218+ ms.StoreMessageInfo(mi)
219+}
220+
221+func (x *IssueLink) String() string {
222+ return protoimpl.X.MessageStringOf(x)
223+}
224+
225+func (*IssueLink) ProtoMessage() {}
226+
227+func (x *IssueLink) ProtoReflect() protoreflect.Message {
228+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
229+ if x != nil {
230+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
231+ if ms.LoadMessageInfo() == nil {
232+ ms.StoreMessageInfo(mi)
233+ }
234+ return ms
235+ }
236+ return mi.MessageOf(x)
237+}
238+
239+// Deprecated: Use IssueLink.ProtoReflect.Descriptor instead.
240+func (*IssueLink) Descriptor() ([]byte, []int) {
241+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1}
242+}
243+
244+func (x *IssueLink) GetCloses() bool {
245+ if x != nil {
246+ return x.Closes
247+ }
248+ return false
249+}
250+
251+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
252+type MergeRequestReferences struct {
253+ state protoimpl.MessageState `protogen:"open.v1"`
254+ Issues []int64 `protobuf:"varint,1,rep,packed,name=issues,proto3" json:"issues,omitempty"`
255+ Closes []int64 `protobuf:"varint,2,rep,packed,name=closes,proto3" json:"closes,omitempty"`
256+ unknownFields protoimpl.UnknownFields
257+ sizeCache protoimpl.SizeCache
258+}
259+
260+func (x *MergeRequestReferences) Reset() {
261+ *x = MergeRequestReferences{}
262+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
263+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
264+ ms.StoreMessageInfo(mi)
265+}
266+
267+func (x *MergeRequestReferences) String() string {
268+ return protoimpl.X.MessageStringOf(x)
269+}
270+
271+func (*MergeRequestReferences) ProtoMessage() {}
272+
273+func (x *MergeRequestReferences) ProtoReflect() protoreflect.Message {
274+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
275+ if x != nil {
276+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
277+ if ms.LoadMessageInfo() == nil {
278+ ms.StoreMessageInfo(mi)
279+ }
280+ return ms
281+ }
282+ return mi.MessageOf(x)
283+}
284+
285+// Deprecated: Use MergeRequestReferences.ProtoReflect.Descriptor instead.
286+func (*MergeRequestReferences) Descriptor() ([]byte, []int) {
287+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
288+}
289+
290+func (x *MergeRequestReferences) GetIssues() []int64 {
291+ if x != nil {
292+ return x.Issues
293+ }
294+ return nil
295+}
296+
297+func (x *MergeRequestReferences) GetCloses() []int64 {
298+ if x != nil {
299+ return x.Closes
300+ }
301+ return nil
302+}
303+
198304 // IssueComment is stored under the key
199305 // "isc/<repo>/<number:010d>/<id:010d>".
200306 type IssueComment struct {
@@ -209,7 +315,7 @@ type IssueComment struct {
209315
210316 func (x *IssueComment) Reset() {
211317 *x = IssueComment{}
212- mi := &file_ermite_storage_issue_proto_msgTypes[1]
318+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
213319 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
214320 ms.StoreMessageInfo(mi)
215321 }
@@ -221,7 +327,7 @@ func (x *IssueComment) String() string {
221327 func (*IssueComment) ProtoMessage() {}
222328
223329 func (x *IssueComment) ProtoReflect() protoreflect.Message {
224- mi := &file_ermite_storage_issue_proto_msgTypes[1]
330+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
225331 if x != nil {
226332 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
227333 if ms.LoadMessageInfo() == nil {
@@ -234,7 +340,7 @@ func (x *IssueComment) ProtoReflect() protoreflect.Message {
234340
235341 // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead.
236342 func (*IssueComment) Descriptor() ([]byte, []int) {
237- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1}
343+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
238344 }
239345
240346 func (x *IssueComment) GetId() int64 {
@@ -283,7 +389,7 @@ type PendingIssue struct {
283389
284390 func (x *PendingIssue) Reset() {
285391 *x = PendingIssue{}
286- mi := &file_ermite_storage_issue_proto_msgTypes[2]
392+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
287393 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
288394 ms.StoreMessageInfo(mi)
289395 }
@@ -295,7 +401,7 @@ func (x *PendingIssue) String() string {
295401 func (*PendingIssue) ProtoMessage() {}
296402
297403 func (x *PendingIssue) ProtoReflect() protoreflect.Message {
298- mi := &file_ermite_storage_issue_proto_msgTypes[2]
404+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
299405 if x != nil {
300406 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
301407 if ms.LoadMessageInfo() == nil {
@@ -308,7 +414,7 @@ func (x *PendingIssue) ProtoReflect() protoreflect.Message {
308414
309415 // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
310416 func (*PendingIssue) Descriptor() ([]byte, []int) {
311- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
417+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
312418 }
313419
314420 func (x *PendingIssue) GetTitle() string {
@@ -356,7 +462,7 @@ type IssueBan struct {
356462
357463 func (x *IssueBan) Reset() {
358464 *x = IssueBan{}
359- mi := &file_ermite_storage_issue_proto_msgTypes[3]
465+ mi := &file_ermite_storage_issue_proto_msgTypes[5]
360466 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
361467 ms.StoreMessageInfo(mi)
362468 }
@@ -368,7 +474,7 @@ func (x *IssueBan) String() string {
368474 func (*IssueBan) ProtoMessage() {}
369475
370476 func (x *IssueBan) ProtoReflect() protoreflect.Message {
371- mi := &file_ermite_storage_issue_proto_msgTypes[3]
477+ mi := &file_ermite_storage_issue_proto_msgTypes[5]
372478 if x != nil {
373479 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
374480 if ms.LoadMessageInfo() == nil {
@@ -381,7 +487,7 @@ func (x *IssueBan) ProtoReflect() protoreflect.Message {
381487
382488 // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
383489 func (*IssueBan) Descriptor() ([]byte, []int) {
384- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
490+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{5}
385491 }
386492
387493 func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
@@ -401,7 +507,7 @@ type IssueKey struct {
401507
402508 func (x *IssueKey) Reset() {
403509 *x = IssueKey{}
404- mi := &file_ermite_storage_issue_proto_msgTypes[4]
510+ mi := &file_ermite_storage_issue_proto_msgTypes[6]
405511 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
406512 ms.StoreMessageInfo(mi)
407513 }
@@ -413,7 +519,7 @@ func (x *IssueKey) String() string {
413519 func (*IssueKey) ProtoMessage() {}
414520
415521 func (x *IssueKey) ProtoReflect() protoreflect.Message {
416- mi := &file_ermite_storage_issue_proto_msgTypes[4]
522+ mi := &file_ermite_storage_issue_proto_msgTypes[6]
417523 if x != nil {
418524 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
419525 if ms.LoadMessageInfo() == nil {
@@ -426,7 +532,7 @@ func (x *IssueKey) ProtoReflect() protoreflect.Message {
426532
427533 // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
428534 func (*IssueKey) Descriptor() ([]byte, []int) {
429- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
535+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{6}
430536 }
431537
432538 func (x *IssueKey) GetKey() []byte {
@@ -440,7 +546,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor
440546
441547 const file_ermite_storage_issue_proto_rawDesc = "" +
442548 "\n" +
443- "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" +
549+ "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfd\x02\n" +
444550 "\x05Issue\x12\x16\n" +
445551 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
446552 "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@@ -453,7 +559,14 @@ const file_ermite_storage_issue_proto_rawDesc = "" +
453559 "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" +
454560 "\vcomment_seq\x18\b \x01(\x03R\n" +
455561 "commentSeq\x12\x1a\n" +
456- "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" +
562+ "\bexternal\x18\t \x01(\bR\bexternal\x125\n" +
563+ "\x17closed_by_merge_request\x18\n" +
564+ " \x01(\x03R\x14closedByMergeRequest\"#\n" +
565+ "\tIssueLink\x12\x16\n" +
566+ "\x06closes\x18\x01 \x01(\bR\x06closes\"H\n" +
567+ "\x16MergeRequestReferences\x12\x16\n" +
568+ "\x06issues\x18\x01 \x03(\x03R\x06issues\x12\x16\n" +
569+ "\x06closes\x18\x02 \x03(\x03R\x06closes\"\x85\x01\n" +
457570 "\fIssueComment\x12\x0e\n" +
458571 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
459572 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
@@ -491,23 +604,25 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
491604 }
492605
493606 var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
494-var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
607+var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
495608 var file_ermite_storage_issue_proto_goTypes = []any{
496- (IssueState)(0), // 0: ermite.storage.IssueState
497- (*Issue)(nil), // 1: ermite.storage.Issue
498- (*IssueComment)(nil), // 2: ermite.storage.IssueComment
499- (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue
500- (*IssueBan)(nil), // 4: ermite.storage.IssueBan
501- (*IssueKey)(nil), // 5: ermite.storage.IssueKey
502- (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
609+ (IssueState)(0), // 0: ermite.storage.IssueState
610+ (*Issue)(nil), // 1: ermite.storage.Issue
611+ (*IssueLink)(nil), // 2: ermite.storage.IssueLink
612+ (*MergeRequestReferences)(nil), // 3: ermite.storage.MergeRequestReferences
613+ (*IssueComment)(nil), // 4: ermite.storage.IssueComment
614+ (*PendingIssue)(nil), // 5: ermite.storage.PendingIssue
615+ (*IssueBan)(nil), // 6: ermite.storage.IssueBan
616+ (*IssueKey)(nil), // 7: ermite.storage.IssueKey
617+ (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
503618 }
504619 var file_ermite_storage_issue_proto_depIdxs = []int32{
505620 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
506- 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
507- 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
508- 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
509- 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
510- 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
621+ 8, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
622+ 8, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
623+ 8, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
624+ 8, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
625+ 8, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
511626 6, // [6:6] is the sub-list for method output_type
512627 6, // [6:6] is the sub-list for method input_type
513628 6, // [6:6] is the sub-list for extension type_name
@@ -526,7 +641,7 @@ func file_ermite_storage_issue_proto_init() {
526641 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
527642 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
528643 NumEnums: 1,
529- NumMessages: 5,
644+ NumMessages: 7,
530645 NumExtensions: 0,
531646 NumServices: 0,
532647 },
@@ -97,9 +97,10 @@ type Issue struct {
97 CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`97 CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
98 // True for an issue approved from the public form: its text comes from98 // True for an issue approved from the public form: its text comes from
99 // an anonymous visitor and is rendered as untrusted.99 // an anonymous visitor and is rendered as untrusted.
100- External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"`100+ External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"`
101- unknownFields protoimpl.UnknownFields101+ ClosedByMergeRequest int64 `protobuf:"varint,10,opt,name=closed_by_merge_request,json=closedByMergeRequest,proto3" json:"closed_by_merge_request,omitempty"`
102- sizeCache protoimpl.SizeCache102+ unknownFields protoimpl.UnknownFields
103+ sizeCache protoimpl.SizeCache
103 }104 }
104 105
105 func (x *Issue) Reset() {106 func (x *Issue) Reset() {
@@ -195,6 +196,111 @@ func (x *Issue) GetExternal() bool {
195 return false196 return false
196 }197 }
197 198
199+func (x *Issue) GetClosedByMergeRequest() int64 {
200+ if x != nil {
201+ return x.ClosedByMergeRequest
202+ }
203+ return 0
204+}
205+
206+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
207+type IssueLink struct {
208+ state protoimpl.MessageState `protogen:"open.v1"`
209+ Closes bool `protobuf:"varint,1,opt,name=closes,proto3" json:"closes,omitempty"`
210+ unknownFields protoimpl.UnknownFields
211+ sizeCache protoimpl.SizeCache
212+}
213+
214+func (x *IssueLink) Reset() {
215+ *x = IssueLink{}
216+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
217+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
218+ ms.StoreMessageInfo(mi)
219+}
220+
221+func (x *IssueLink) String() string {
222+ return protoimpl.X.MessageStringOf(x)
223+}
224+
225+func (*IssueLink) ProtoMessage() {}
226+
227+func (x *IssueLink) ProtoReflect() protoreflect.Message {
228+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
229+ if x != nil {
230+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
231+ if ms.LoadMessageInfo() == nil {
232+ ms.StoreMessageInfo(mi)
233+ }
234+ return ms
235+ }
236+ return mi.MessageOf(x)
237+}
238+
239+// Deprecated: Use IssueLink.ProtoReflect.Descriptor instead.
240+func (*IssueLink) Descriptor() ([]byte, []int) {
241+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1}
242+}
243+
244+func (x *IssueLink) GetCloses() bool {
245+ if x != nil {
246+ return x.Closes
247+ }
248+ return false
249+}
250+
251+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
252+type MergeRequestReferences struct {
253+ state protoimpl.MessageState `protogen:"open.v1"`
254+ Issues []int64 `protobuf:"varint,1,rep,packed,name=issues,proto3" json:"issues,omitempty"`
255+ Closes []int64 `protobuf:"varint,2,rep,packed,name=closes,proto3" json:"closes,omitempty"`
256+ unknownFields protoimpl.UnknownFields
257+ sizeCache protoimpl.SizeCache
258+}
259+
260+func (x *MergeRequestReferences) Reset() {
261+ *x = MergeRequestReferences{}
262+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
263+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
264+ ms.StoreMessageInfo(mi)
265+}
266+
267+func (x *MergeRequestReferences) String() string {
268+ return protoimpl.X.MessageStringOf(x)
269+}
270+
271+func (*MergeRequestReferences) ProtoMessage() {}
272+
273+func (x *MergeRequestReferences) ProtoReflect() protoreflect.Message {
274+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
275+ if x != nil {
276+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
277+ if ms.LoadMessageInfo() == nil {
278+ ms.StoreMessageInfo(mi)
279+ }
280+ return ms
281+ }
282+ return mi.MessageOf(x)
283+}
284+
285+// Deprecated: Use MergeRequestReferences.ProtoReflect.Descriptor instead.
286+func (*MergeRequestReferences) Descriptor() ([]byte, []int) {
287+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
288+}
289+
290+func (x *MergeRequestReferences) GetIssues() []int64 {
291+ if x != nil {
292+ return x.Issues
293+ }
294+ return nil
295+}
296+
297+func (x *MergeRequestReferences) GetCloses() []int64 {
298+ if x != nil {
299+ return x.Closes
300+ }
301+ return nil
302+}
303+
198 // IssueComment is stored under the key304 // IssueComment is stored under the key
199 // "isc/<repo>/<number:010d>/<id:010d>".305 // "isc/<repo>/<number:010d>/<id:010d>".
200 type IssueComment struct {306 type IssueComment struct {
@@ -209,7 +315,7 @@ type IssueComment struct {
209 315
210 func (x *IssueComment) Reset() {316 func (x *IssueComment) Reset() {
211 *x = IssueComment{}317 *x = IssueComment{}
212- mi := &file_ermite_storage_issue_proto_msgTypes[1]318+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
213 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))319 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
214 ms.StoreMessageInfo(mi)320 ms.StoreMessageInfo(mi)
215 }321 }
@@ -221,7 +327,7 @@ func (x *IssueComment) String() string {
221 func (*IssueComment) ProtoMessage() {}327 func (*IssueComment) ProtoMessage() {}
222 328
223 func (x *IssueComment) ProtoReflect() protoreflect.Message {329 func (x *IssueComment) ProtoReflect() protoreflect.Message {
224- mi := &file_ermite_storage_issue_proto_msgTypes[1]330+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
225 if x != nil {331 if x != nil {
226 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))332 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
227 if ms.LoadMessageInfo() == nil {333 if ms.LoadMessageInfo() == nil {
@@ -234,7 +340,7 @@ func (x *IssueComment) ProtoReflect() protoreflect.Message {
234 340
235 // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead.341 // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead.
236 func (*IssueComment) Descriptor() ([]byte, []int) {342 func (*IssueComment) Descriptor() ([]byte, []int) {
237- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1}343+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
238 }344 }
239 345
240 func (x *IssueComment) GetId() int64 {346 func (x *IssueComment) GetId() int64 {
@@ -283,7 +389,7 @@ type PendingIssue struct {
283 389
284 func (x *PendingIssue) Reset() {390 func (x *PendingIssue) Reset() {
285 *x = PendingIssue{}391 *x = PendingIssue{}
286- mi := &file_ermite_storage_issue_proto_msgTypes[2]392+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
287 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))393 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
288 ms.StoreMessageInfo(mi)394 ms.StoreMessageInfo(mi)
289 }395 }
@@ -295,7 +401,7 @@ func (x *PendingIssue) String() string {
295 func (*PendingIssue) ProtoMessage() {}401 func (*PendingIssue) ProtoMessage() {}
296 402
297 func (x *PendingIssue) ProtoReflect() protoreflect.Message {403 func (x *PendingIssue) ProtoReflect() protoreflect.Message {
298- mi := &file_ermite_storage_issue_proto_msgTypes[2]404+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
299 if x != nil {405 if x != nil {
300 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))406 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
301 if ms.LoadMessageInfo() == nil {407 if ms.LoadMessageInfo() == nil {
@@ -308,7 +414,7 @@ func (x *PendingIssue) ProtoReflect() protoreflect.Message {
308 414
309 // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.415 // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
310 func (*PendingIssue) Descriptor() ([]byte, []int) {416 func (*PendingIssue) Descriptor() ([]byte, []int) {
311- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}417+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
312 }418 }
313 419
314 func (x *PendingIssue) GetTitle() string {420 func (x *PendingIssue) GetTitle() string {
@@ -356,7 +462,7 @@ type IssueBan struct {
356 462
357 func (x *IssueBan) Reset() {463 func (x *IssueBan) Reset() {
358 *x = IssueBan{}464 *x = IssueBan{}
359- mi := &file_ermite_storage_issue_proto_msgTypes[3]465+ mi := &file_ermite_storage_issue_proto_msgTypes[5]
360 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))466 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
361 ms.StoreMessageInfo(mi)467 ms.StoreMessageInfo(mi)
362 }468 }
@@ -368,7 +474,7 @@ func (x *IssueBan) String() string {
368 func (*IssueBan) ProtoMessage() {}474 func (*IssueBan) ProtoMessage() {}
369 475
370 func (x *IssueBan) ProtoReflect() protoreflect.Message {476 func (x *IssueBan) ProtoReflect() protoreflect.Message {
371- mi := &file_ermite_storage_issue_proto_msgTypes[3]477+ mi := &file_ermite_storage_issue_proto_msgTypes[5]
372 if x != nil {478 if x != nil {
373 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))479 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
374 if ms.LoadMessageInfo() == nil {480 if ms.LoadMessageInfo() == nil {
@@ -381,7 +487,7 @@ func (x *IssueBan) ProtoReflect() protoreflect.Message {
381 487
382 // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.488 // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
383 func (*IssueBan) Descriptor() ([]byte, []int) {489 func (*IssueBan) Descriptor() ([]byte, []int) {
384- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}490+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{5}
385 }491 }
386 492
387 func (x *IssueBan) GetUntil() *timestamppb.Timestamp {493 func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
@@ -401,7 +507,7 @@ type IssueKey struct {
401 507
402 func (x *IssueKey) Reset() {508 func (x *IssueKey) Reset() {
403 *x = IssueKey{}509 *x = IssueKey{}
404- mi := &file_ermite_storage_issue_proto_msgTypes[4]510+ mi := &file_ermite_storage_issue_proto_msgTypes[6]
405 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))511 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
406 ms.StoreMessageInfo(mi)512 ms.StoreMessageInfo(mi)
407 }513 }
@@ -413,7 +519,7 @@ func (x *IssueKey) String() string {
413 func (*IssueKey) ProtoMessage() {}519 func (*IssueKey) ProtoMessage() {}
414 520
415 func (x *IssueKey) ProtoReflect() protoreflect.Message {521 func (x *IssueKey) ProtoReflect() protoreflect.Message {
416- mi := &file_ermite_storage_issue_proto_msgTypes[4]522+ mi := &file_ermite_storage_issue_proto_msgTypes[6]
417 if x != nil {523 if x != nil {
418 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))524 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
419 if ms.LoadMessageInfo() == nil {525 if ms.LoadMessageInfo() == nil {
@@ -426,7 +532,7 @@ func (x *IssueKey) ProtoReflect() protoreflect.Message {
426 532
427 // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.533 // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
428 func (*IssueKey) Descriptor() ([]byte, []int) {534 func (*IssueKey) Descriptor() ([]byte, []int) {
429- return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}535+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{6}
430 }536 }
431 537
432 func (x *IssueKey) GetKey() []byte {538 func (x *IssueKey) GetKey() []byte {
@@ -440,7 +546,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor
440 546
441 const file_ermite_storage_issue_proto_rawDesc = "" +547 const file_ermite_storage_issue_proto_rawDesc = "" +
442 "\n" +548 "\n" +
443- "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" +549+ "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfd\x02\n" +
444 "\x05Issue\x12\x16\n" +550 "\x05Issue\x12\x16\n" +
445 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +551 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
446 "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +552 "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@@ -453,7 +559,14 @@ const file_ermite_storage_issue_proto_rawDesc = "" +
453 "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" +559 "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" +
454 "\vcomment_seq\x18\b \x01(\x03R\n" +560 "\vcomment_seq\x18\b \x01(\x03R\n" +
455 "commentSeq\x12\x1a\n" +561 "commentSeq\x12\x1a\n" +
456- "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" +562+ "\bexternal\x18\t \x01(\bR\bexternal\x125\n" +
563+ "\x17closed_by_merge_request\x18\n" +
564+ " \x01(\x03R\x14closedByMergeRequest\"#\n" +
565+ "\tIssueLink\x12\x16\n" +
566+ "\x06closes\x18\x01 \x01(\bR\x06closes\"H\n" +
567+ "\x16MergeRequestReferences\x12\x16\n" +
568+ "\x06issues\x18\x01 \x03(\x03R\x06issues\x12\x16\n" +
569+ "\x06closes\x18\x02 \x03(\x03R\x06closes\"\x85\x01\n" +
457 "\fIssueComment\x12\x0e\n" +570 "\fIssueComment\x12\x0e\n" +
458 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +571 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
459 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +572 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
@@ -491,23 +604,25 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
491 }604 }
492 605
493 var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)606 var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
494-var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5)607+var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
495 var file_ermite_storage_issue_proto_goTypes = []any{608 var file_ermite_storage_issue_proto_goTypes = []any{
496- (IssueState)(0), // 0: ermite.storage.IssueState609+ (IssueState)(0), // 0: ermite.storage.IssueState
497- (*Issue)(nil), // 1: ermite.storage.Issue610+ (*Issue)(nil), // 1: ermite.storage.Issue
498- (*IssueComment)(nil), // 2: ermite.storage.IssueComment611+ (*IssueLink)(nil), // 2: ermite.storage.IssueLink
499- (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue612+ (*MergeRequestReferences)(nil), // 3: ermite.storage.MergeRequestReferences
500- (*IssueBan)(nil), // 4: ermite.storage.IssueBan613+ (*IssueComment)(nil), // 4: ermite.storage.IssueComment
501- (*IssueKey)(nil), // 5: ermite.storage.IssueKey614+ (*PendingIssue)(nil), // 5: ermite.storage.PendingIssue
502- (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp615+ (*IssueBan)(nil), // 6: ermite.storage.IssueBan
616+ (*IssueKey)(nil), // 7: ermite.storage.IssueKey
617+ (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
503 }618 }
504 var file_ermite_storage_issue_proto_depIdxs = []int32{619 var file_ermite_storage_issue_proto_depIdxs = []int32{
505 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState620 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
506- 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp621+ 8, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
507- 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp622+ 8, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
508- 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp623+ 8, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
509- 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp624+ 8, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
510- 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp625+ 8, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
511 6, // [6:6] is the sub-list for method output_type626 6, // [6:6] is the sub-list for method output_type
512 6, // [6:6] is the sub-list for method input_type627 6, // [6:6] is the sub-list for method input_type
513 6, // [6:6] is the sub-list for extension type_name628 6, // [6:6] is the sub-list for extension type_name
@@ -526,7 +641,7 @@ func file_ermite_storage_issue_proto_init() {
526 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),641 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
527 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),642 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
528 NumEnums: 1,643 NumEnums: 1,
529- NumMessages: 5,644+ NumMessages: 7,
530 NumExtensions: 0,645 NumExtensions: 0,
531 NumServices: 0,646 NumServices: 0,
532 },647 },
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.12.0
16+ bnema.dev/ermite-api v0.13.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.12.016+ bnema.dev/ermite-api v0.13.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
A internal/adapters/kvstore/issue_links.go +151 -0
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ kv "bnema.dev/ermite-kv"
14+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "google.golang.org/protobuf/proto"
18+)
19+
20+const issueRefPrefix = "isref/"
21+const mrRefPrefix = "mrref/"
22+
23+// IssueLinks stores the derived issue-to-merge-request index.
24+type IssueLinks struct{ db *kv.DB }
25+
26+var _ ports.IssueLinkStore = (*IssueLinks)(nil)
27+
28+// IssueLinks returns a link store sharing this Store's database and lifetime.
29+func (s *Store) IssueLinks() *IssueLinks { return &IssueLinks{db: s.db} }
30+
31+func mrRefKey(repo domain.RepoName, mr int64) []byte {
32+ return []byte(mrRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, mr))
33+}
34+func issueRefKeys(repo domain.RepoName, issue int64) string {
35+ return issueRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, issue) + "/"
36+}
37+func issueRefKey(repo domain.RepoName, issue, mr int64) []byte {
38+ return []byte(issueRefKeys(repo, issue) + fmt.Sprintf(numberFormat, mr))
39+}
40+
41+// ReplaceLinks atomically replaces both directions of the index.
42+func (l *IssueLinks) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) {
43+ key := mrRefKey(repo, mr)
44+ item, exists := l.db.Get(ctx, key)
45+ var old storagepb.MergeRequestReferences
46+ if exists {
47+ if err := proto.Unmarshal(item.Value, &old); err != nil {
48+ return nil, nil, fmt.Errorf("decode MR references: %w", err)
49+ }
50+ }
51+ issues = slices.Sorted(slices.Values(issues))
52+ issues = slices.Compact(issues)
53+ closes = slices.Sorted(slices.Values(closes))
54+ closes = slices.Compact(closes)
55+ for _, n := range issues {
56+ if n < 1 {
57+ return nil, nil, domain.ErrInvalid
58+ }
59+ }
60+ issueSet, closeSet := toSet(issues), toSet(closes)
61+ oldIssues, oldCloses := toSet(old.GetIssues()), toSet(old.GetCloses())
62+ for _, n := range closes {
63+ if _, ok := issueSet[n]; !ok {
64+ return nil, nil, domain.ErrInvalid
65+ }
66+ }
67+ var b kv.Batch
68+ if err := guardRepoKey(ctx, l.db, &b, repo); err != nil {
69+ return nil, nil, err
70+ }
71+ if exists {
72+ b.RequireVersion(key, item.Version)
73+ } else {
74+ b.RequireAbsent(key)
75+ }
76+ for _, n := range old.GetIssues() {
77+ _, wasClosing := oldCloses[n]
78+ if _, ok := issueSet[n]; !ok {
79+ b.Delete(issueRefKey(repo, n, mr))
80+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing})
81+ } else if _, still := closeSet[n]; wasClosing && !still {
82+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true})
83+ }
84+ }
85+ for _, n := range issues {
86+ _, closing := closeSet[n]
87+ b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing}))
88+ _, was := oldIssues[n]
89+ _, wasClosing := oldCloses[n]
90+ if !was || closing && !wasClosing {
91+ added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing})
92+ }
93+ }
94+ b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes}))
95+ if _, err := l.db.Apply(ctx, &b); err != nil {
96+ if errors.Is(err, kv.ErrConflict) {
97+ return nil, nil, domain.ErrConflict
98+ }
99+ return nil, nil, fmt.Errorf("replace issue links: %w", err)
100+ }
101+ return added, removed, nil
102+}
103+
104+// LinksOfIssue returns links ordered by merge request number.
105+func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
106+ var result []domain.IssueLink
107+ prefix := issueRefKeys(repo, issue)
108+ for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
109+ if err := ctx.Err(); err != nil {
110+ return nil, err
111+ }
112+ mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
113+ if err != nil {
114+ return nil, fmt.Errorf("decode issue link key: %w", err)
115+ }
116+ var pb storagepb.IssueLink
117+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
118+ return nil, fmt.Errorf("decode issue link: %w", err)
119+ }
120+ result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()})
121+ if limit > 0 && len(result) >= limit {
122+ break
123+ }
124+ }
125+ return result, nil
126+}
127+
128+// LinksOfMergeRequest returns the links recorded by its last rebuild.
129+func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
130+ item, ok := l.db.Get(ctx, mrRefKey(repo, mr))
131+ if !ok {
132+ return nil, nil
133+ }
134+ var pb storagepb.MergeRequestReferences
135+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
136+ return nil, fmt.Errorf("decode MR references: %w", err)
137+ }
138+ var result []domain.IssueLink
139+ for _, n := range pb.GetIssues() {
140+ result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)})
141+ }
142+ return result, nil
143+}
144+
145+func toSet(numbers []int64) map[int64]struct{} {
146+ set := make(map[int64]struct{}, len(numbers))
147+ for _, n := range numbers {
148+ set[n] = struct{}{}
149+ }
150+ return set
151+}
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ kv "bnema.dev/ermite-kv"
14+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "google.golang.org/protobuf/proto"
18+)
19+
20+const issueRefPrefix = "isref/"
21+const mrRefPrefix = "mrref/"
22+
23+// IssueLinks stores the derived issue-to-merge-request index.
24+type IssueLinks struct{ db *kv.DB }
25+
26+var _ ports.IssueLinkStore = (*IssueLinks)(nil)
27+
28+// IssueLinks returns a link store sharing this Store's database and lifetime.
29+func (s *Store) IssueLinks() *IssueLinks { return &IssueLinks{db: s.db} }
30+
31+func mrRefKey(repo domain.RepoName, mr int64) []byte {
32+ return []byte(mrRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, mr))
33+}
34+func issueRefKeys(repo domain.RepoName, issue int64) string {
35+ return issueRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, issue) + "/"
36+}
37+func issueRefKey(repo domain.RepoName, issue, mr int64) []byte {
38+ return []byte(issueRefKeys(repo, issue) + fmt.Sprintf(numberFormat, mr))
39+}
40+
41+// ReplaceLinks atomically replaces both directions of the index.
42+func (l *IssueLinks) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) {
43+ key := mrRefKey(repo, mr)
44+ item, exists := l.db.Get(ctx, key)
45+ var old storagepb.MergeRequestReferences
46+ if exists {
47+ if err := proto.Unmarshal(item.Value, &old); err != nil {
48+ return nil, nil, fmt.Errorf("decode MR references: %w", err)
49+ }
50+ }
51+ issues = slices.Sorted(slices.Values(issues))
52+ issues = slices.Compact(issues)
53+ closes = slices.Sorted(slices.Values(closes))
54+ closes = slices.Compact(closes)
55+ for _, n := range issues {
56+ if n < 1 {
57+ return nil, nil, domain.ErrInvalid
58+ }
59+ }
60+ issueSet, closeSet := toSet(issues), toSet(closes)
61+ oldIssues, oldCloses := toSet(old.GetIssues()), toSet(old.GetCloses())
62+ for _, n := range closes {
63+ if _, ok := issueSet[n]; !ok {
64+ return nil, nil, domain.ErrInvalid
65+ }
66+ }
67+ var b kv.Batch
68+ if err := guardRepoKey(ctx, l.db, &b, repo); err != nil {
69+ return nil, nil, err
70+ }
71+ if exists {
72+ b.RequireVersion(key, item.Version)
73+ } else {
74+ b.RequireAbsent(key)
75+ }
76+ for _, n := range old.GetIssues() {
77+ _, wasClosing := oldCloses[n]
78+ if _, ok := issueSet[n]; !ok {
79+ b.Delete(issueRefKey(repo, n, mr))
80+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing})
81+ } else if _, still := closeSet[n]; wasClosing && !still {
82+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true})
83+ }
84+ }
85+ for _, n := range issues {
86+ _, closing := closeSet[n]
87+ b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing}))
88+ _, was := oldIssues[n]
89+ _, wasClosing := oldCloses[n]
90+ if !was || closing && !wasClosing {
91+ added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing})
92+ }
93+ }
94+ b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes}))
95+ if _, err := l.db.Apply(ctx, &b); err != nil {
96+ if errors.Is(err, kv.ErrConflict) {
97+ return nil, nil, domain.ErrConflict
98+ }
99+ return nil, nil, fmt.Errorf("replace issue links: %w", err)
100+ }
101+ return added, removed, nil
102+}
103+
104+// LinksOfIssue returns links ordered by merge request number.
105+func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
106+ var result []domain.IssueLink
107+ prefix := issueRefKeys(repo, issue)
108+ for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
109+ if err := ctx.Err(); err != nil {
110+ return nil, err
111+ }
112+ mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
113+ if err != nil {
114+ return nil, fmt.Errorf("decode issue link key: %w", err)
115+ }
116+ var pb storagepb.IssueLink
117+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
118+ return nil, fmt.Errorf("decode issue link: %w", err)
119+ }
120+ result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()})
121+ if limit > 0 && len(result) >= limit {
122+ break
123+ }
124+ }
125+ return result, nil
126+}
127+
128+// LinksOfMergeRequest returns the links recorded by its last rebuild.
129+func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
130+ item, ok := l.db.Get(ctx, mrRefKey(repo, mr))
131+ if !ok {
132+ return nil, nil
133+ }
134+ var pb storagepb.MergeRequestReferences
135+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
136+ return nil, fmt.Errorf("decode MR references: %w", err)
137+ }
138+ var result []domain.IssueLink
139+ for _, n := range pb.GetIssues() {
140+ result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)})
141+ }
142+ return result, nil
143+}
144+
145+func toSet(numbers []int64) map[int64]struct{} {
146+ set := make(map[int64]struct{}, len(numbers))
147+ for _, n := range numbers {
148+ set[n] = struct{}{}
149+ }
150+ return set
151+}
A internal/adapters/kvstore/issue_links_test.go +66 -0
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "context"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "testing"
10+)
11+
12+func TestIssueLinksReplace(t *testing.T) {
13+ ctx := context.Background()
14+ s, _, repo := repoWithMRs(t)
15+ links := s.IssueLinks()
16+ added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2})
17+ require.NoError(t, err)
18+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added)
19+ assert.Empty(t, removed)
20+ got, err := links.LinksOfMergeRequest(ctx, repo, 9)
21+ require.NoError(t, err)
22+ assert.Equal(t, added, got)
23+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2})
24+ require.NoError(t, err)
25+ assert.Empty(t, added)
26+ assert.Empty(t, removed)
27+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3})
28+ require.NoError(t, err)
29+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added)
30+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed)
31+ got, err = links.LinksOfIssue(ctx, repo, 2, 100)
32+ require.NoError(t, err)
33+ assert.Empty(t, got)
34+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, added)
37+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed)
38+ for _, mr := range []int64{12, 1, 5} {
39+ _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil)
40+ require.NoError(t, err)
41+ }
42+ got, err = links.LinksOfIssue(ctx, repo, 3, 2)
43+ require.NoError(t, err)
44+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got)
45+ require.NoError(t, s.Delete(ctx, repo))
46+ assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/"))
47+ assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/"))
48+ _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil)
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+}
51+
52+func TestIssueClosedByRoundTrip(t *testing.T) {
53+ ctx := context.Background()
54+ s, _, repo := repoWithMRs(t)
55+ issue := newIssue(t, repo, "closed by merge")
56+ issue.State = domain.IssueStateClosed
57+ issue.ClosedByMergeRequest = 17
58+ stored, err := s.Issues().Create(ctx, issue)
59+ require.NoError(t, err)
60+ assert.Equal(t, int64(17), stored.ClosedByMergeRequest)
61+ stored.ClosedByMergeRequest = 0
62+ require.NoError(t, s.Issues().Update(ctx, stored))
63+ stored, err = s.Issues().Get(ctx, repo, stored.Number)
64+ require.NoError(t, err)
65+ assert.Zero(t, stored.ClosedByMergeRequest)
66+}
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "context"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "testing"
10+)
11+
12+func TestIssueLinksReplace(t *testing.T) {
13+ ctx := context.Background()
14+ s, _, repo := repoWithMRs(t)
15+ links := s.IssueLinks()
16+ added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2})
17+ require.NoError(t, err)
18+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added)
19+ assert.Empty(t, removed)
20+ got, err := links.LinksOfMergeRequest(ctx, repo, 9)
21+ require.NoError(t, err)
22+ assert.Equal(t, added, got)
23+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2})
24+ require.NoError(t, err)
25+ assert.Empty(t, added)
26+ assert.Empty(t, removed)
27+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3})
28+ require.NoError(t, err)
29+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added)
30+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed)
31+ got, err = links.LinksOfIssue(ctx, repo, 2, 100)
32+ require.NoError(t, err)
33+ assert.Empty(t, got)
34+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, added)
37+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed)
38+ for _, mr := range []int64{12, 1, 5} {
39+ _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil)
40+ require.NoError(t, err)
41+ }
42+ got, err = links.LinksOfIssue(ctx, repo, 3, 2)
43+ require.NoError(t, err)
44+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got)
45+ require.NoError(t, s.Delete(ctx, repo))
46+ assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/"))
47+ assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/"))
48+ _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil)
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+}
51+
52+func TestIssueClosedByRoundTrip(t *testing.T) {
53+ ctx := context.Background()
54+ s, _, repo := repoWithMRs(t)
55+ issue := newIssue(t, repo, "closed by merge")
56+ issue.State = domain.IssueStateClosed
57+ issue.ClosedByMergeRequest = 17
58+ stored, err := s.Issues().Create(ctx, issue)
59+ require.NoError(t, err)
60+ assert.Equal(t, int64(17), stored.ClosedByMergeRequest)
61+ stored.ClosedByMergeRequest = 0
62+ require.NoError(t, s.Issues().Update(ctx, stored))
63+ stored, err = s.Issues().Get(ctx, repo, stored.Number)
64+ require.NoError(t, err)
65+ assert.Zero(t, stored.ClosedByMergeRequest)
66+}
M internal/adapters/kvstore/issues.go +15 -13
@@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
7272 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
7373 var keys [][]byte
7474 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
7676 } {
7777 for k := range snap.Prefix(ctx, []byte(p)) {
7878 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312312 return nil, err
313313 }
314314 return mustMarshal(&storagepb.Issue{
315- Number: issue.Number,
316- Title: issue.Title.String(),
317- Body: issue.Body.String(),
318- State: state,
319- Author: issue.Author,
320- CreatedAt: encodeTime(issue.CreatedAt),
321- UpdatedAt: encodeTime(issue.UpdatedAt),
322- CommentSeq: issue.CommentSeq,
323- External: issue.External,
315+ Number: issue.Number,
316+ Title: issue.Title.String(),
317+ Body: issue.Body.String(),
318+ State: state,
319+ Author: issue.Author,
320+ CreatedAt: encodeTime(issue.CreatedAt),
321+ UpdatedAt: encodeTime(issue.UpdatedAt),
322+ CommentSeq: issue.CommentSeq,
323+ External: issue.External,
324+ ClosedByMergeRequest: issue.ClosedByMergeRequest,
324325 }), nil
325326 }
326327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349350 Author: pb.GetAuthor(),
350351 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
351- CommentSeq: pb.GetCommentSeq(),
352- External: pb.GetExternal(),
353- Version: item.Version,
352+ CommentSeq: pb.GetCommentSeq(),
353+ External: pb.GetExternal(),
354+ ClosedByMergeRequest: pb.GetClosedByMergeRequest(),
355+ Version: item.Version,
354356 }, nil
355357 }
356358
@@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
72 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {72 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73 var keys [][]byte73 var keys [][]byte
74 for _, p := range []string{74 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
76 } {76 } {
77 for k := range snap.Prefix(ctx, []byte(p)) {77 for k := range snap.Prefix(ctx, []byte(p)) {
78 keys = append(keys, slices.Clone(k))78 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312 return nil, err312 return nil, err
313 }313 }
314 return mustMarshal(&storagepb.Issue{314 return mustMarshal(&storagepb.Issue{
315- Number: issue.Number,315+ Number: issue.Number,
316- Title: issue.Title.String(),316+ Title: issue.Title.String(),
317- Body: issue.Body.String(),317+ Body: issue.Body.String(),
318- State: state,318+ State: state,
319- Author: issue.Author,319+ Author: issue.Author,
320- CreatedAt: encodeTime(issue.CreatedAt),320+ CreatedAt: encodeTime(issue.CreatedAt),
321- UpdatedAt: encodeTime(issue.UpdatedAt),321+ UpdatedAt: encodeTime(issue.UpdatedAt),
322- CommentSeq: issue.CommentSeq,322+ CommentSeq: issue.CommentSeq,
323- External: issue.External,323+ External: issue.External,
324+ ClosedByMergeRequest: issue.ClosedByMergeRequest,
324 }), nil325 }), nil
325 }326 }
326 327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349 Author: pb.GetAuthor(),350 Author: pb.GetAuthor(),
350 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),351 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
351- CommentSeq: pb.GetCommentSeq(),352+ CommentSeq: pb.GetCommentSeq(),
352- External: pb.GetExternal(),353+ External: pb.GetExternal(),
353- Version: item.Version,354+ ClosedByMergeRequest: pb.GetClosedByMergeRequest(),
355+ Version: item.Version,
354 }, nil356 }, nil
355 }357 }
356 358
M internal/adapters/kvstore/mergerequests.go +1 -1
@@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
181181 var keys [][]byte
182182 for _, p := range []string{
183183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185185 mergePrefix + repo.String() + "/",
186186 } {
187187 for k := range snap.Prefix(ctx, []byte(p)) {
@@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
181 var keys [][]byte181 var keys [][]byte
182 for _, p := range []string{182 for _, p := range []string{
183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185 mergePrefix + repo.String() + "/",185 mergePrefix + repo.String() + "/",
186 } {186 } {
187 for k := range snap.Prefix(ctx, []byte(p)) {187 for k := range snap.Prefix(ctx, []byte(p)) {
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +2 -2
@@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
530530 out.Time = timestamp(ev.At)
531531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532532 default:
533- // Web-only events, such as domain.IssuePendingRemoved (the API has
534- // no message for it; watchers list pending issues on demand).
533+ // Web-only events, such as domain.IssueReferenced and
534+ // domain.IssuePendingRemoved: the API has no messages for them.
535535 return nil
536536 }
537537 return out
@@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
530 out.Time = timestamp(ev.At)530 out.Time = timestamp(ev.At)
531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532 default:532 default:
533- // Web-only events, such as domain.IssuePendingRemoved (the API has533+ // Web-only events, such as domain.IssueReferenced and
534- // no message for it; watchers list pending issues on demand).534+ // domain.IssuePendingRemoved: the API has no messages for them.
535 return nil535 return nil
536 }536 }
537 return out537 return out
M internal/adapters/rpc/issue.go +19 -9
@@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
7070
7171 func issueToPB(i domain.Issue) *pb.Issue {
7272 return &pb.Issue{
73- Repo: i.Repo.String(),
74- Number: i.Number,
75- Title: i.Title.String(),
76- Body: i.Body.String(),
77- State: issueStateToPB(i.State),
78- Author: i.Author,
79- CreatedAt: timestamp(i.CreatedAt),
80- UpdatedAt: timestamp(i.UpdatedAt),
81- CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ ClosedByMergeRequest: i.ClosedByMergeRequest,
8283 }
8384 }
8485
@@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
138139 for _, c := range d.Comments {
139140 out.Comments = append(out.Comments, issueCommentToPB(c))
140141 }
142+ out.References = make([]*pb.IssueReference, 0, len(d.References))
143+ for _, r := range d.References {
144+ out.References = append(out.References, &pb.IssueReference{
145+ Number: r.Number,
146+ Title: r.Title.String(),
147+ State: mergeRequestStateToPB(r.State),
148+ Closes: r.Closes,
149+ })
150+ }
141151 return connect.NewResponse(out), nil
142152 }
143153
@@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
70 70
71 func issueToPB(i domain.Issue) *pb.Issue {71 func issueToPB(i domain.Issue) *pb.Issue {
72 return &pb.Issue{72 return &pb.Issue{
73- Repo: i.Repo.String(),73+ Repo: i.Repo.String(),
74- Number: i.Number,74+ Number: i.Number,
75- Title: i.Title.String(),75+ Title: i.Title.String(),
76- Body: i.Body.String(),76+ Body: i.Body.String(),
77- State: issueStateToPB(i.State),77+ State: issueStateToPB(i.State),
78- Author: i.Author,78+ Author: i.Author,
79- CreatedAt: timestamp(i.CreatedAt),79+ CreatedAt: timestamp(i.CreatedAt),
80- UpdatedAt: timestamp(i.UpdatedAt),80+ UpdatedAt: timestamp(i.UpdatedAt),
81- CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ ClosedByMergeRequest: i.ClosedByMergeRequest,
82 }83 }
83 }84 }
84 85
@@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
138 for _, c := range d.Comments {139 for _, c := range d.Comments {
139 out.Comments = append(out.Comments, issueCommentToPB(c))140 out.Comments = append(out.Comments, issueCommentToPB(c))
140 }141 }
142+ out.References = make([]*pb.IssueReference, 0, len(d.References))
143+ for _, r := range d.References {
144+ out.References = append(out.References, &pb.IssueReference{
145+ Number: r.Number,
146+ Title: r.Title.String(),
147+ State: mergeRequestStateToPB(r.State),
148+ Closes: r.Closes,
149+ })
150+ }
141 return connect.NewResponse(out), nil151 return connect.NewResponse(out), nil
142 }152 }
143 153
M internal/adapters/web/fragments.go +3 -1
@@ -63,7 +63,7 @@ func liveEvent(we ports.WatchEvent) bool {
6363 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6464 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6565 domain.RunUpdated, domain.JobUpdated,
66- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
66+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
6767 return true
6868 }
6969 return false
@@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
210210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
211211 case domain.IssueCommented:
212212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
213+ case domain.IssueReferenced:
214+ return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
213215 case domain.PushEvent:
214216 switch l.view {
215217 case "mr":
@@ -63,7 +63,7 @@ func liveEvent(we ports.WatchEvent) bool {
63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,64 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
65 domain.RunUpdated, domain.JobUpdated,65 domain.RunUpdated, domain.JobUpdated,
66- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:66+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
67 return true67 return true
68 }68 }
69 return false69 return false
@@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
211 case domain.IssueCommented:211 case domain.IssueCommented:
212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
213+ case domain.IssueReferenced:
214+ return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
213 case domain.PushEvent:215 case domain.PushEvent:
214 switch l.view {216 switch l.view {
215 case "mr":217 case "mr":
M internal/adapters/web/issues.go +90 -13
@@ -83,6 +83,58 @@ type IssuePage struct {
8383 Form *CommentForm
8484 Transition *IssueActions
8585 Live bool
86+ // ClosedBy is the merge request whose merge closed the issue.
87+ ClosedBy *Link
88+ // References are the merge requests naming the issue.
89+ References []IssueRefView
90+}
91+
92+// IssueRefView is a merge request naming an issue.
93+type IssueRefView struct {
94+ Number, Title, State, Href string
95+ Closes bool
96+}
97+
98+// maxNumberLookups bounds the lookups of #N references of one render.
99+const maxNumberLookups = 50
100+
101+// numberResolver resolves #N to the page of an issue or a merge request of
102+// the repository. It is meant for one render: answers are memoized and at most
103+// maxNumberLookups numbers are asked, the others do not resolve.
104+func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, domain.NumberKind, bool) {
105+ if s.cfg.IssueNumbers == nil {
106+ return nil
107+ }
108+ name, err := domain.ParseRepoName(repo)
109+ if err != nil {
110+ return nil
111+ }
112+ type answer struct {
113+ href string
114+ kind domain.NumberKind
115+ ok bool
116+ }
117+ seen := map[int64]answer{}
118+ return func(n int64) (string, domain.NumberKind, bool) {
119+ if a, ok := seen[n]; ok {
120+ return a.href, a.kind, a.ok
121+ }
122+ if len(seen) >= maxNumberLookups {
123+ return "", domain.NumberNone, false
124+ }
125+ var a answer
126+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
127+ if err == nil {
128+ switch kind {
129+ case domain.NumberIssue:
130+ a = answer{issueHref(repo, n), kind, true}
131+ case domain.NumberMergeRequest:
132+ a = answer{mrHref(repo, n), kind, true}
133+ }
134+ }
135+ seen[n] = a
136+ return a.href, a.kind, a.ok
137+ }
86138 }
87139
88140 // IssueNewPage is the form that opens an issue.
@@ -238,20 +290,11 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
238290 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
239291 State: string(i.State), Open: i.IsOpen(),
240292 }
241- if body := i.Body.String(); strings.TrimSpace(body) != "" {
242- var h template.HTML
243- var err error
244- if i.External {
245- // Written by an anonymous visitor: the untrusted policy.
246- h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
247- } else {
248- h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
249- }
250- if err != nil {
251- return nil, 0, err
252- }
253- p.Desc = h
293+ if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
294+ return nil, 0, err
254295 }
296+ p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297+ p.References = issueRefViews(d.name(), res.References)
255298 p.Opened.Name = s.who(i.Author)
256299 if i.External {
257300 // The label is the visitor's, not the owner's: shown as plain text.
@@ -295,6 +338,40 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
295338 return IssueShow(l, p), http.StatusOK, nil
296339 }
297340
341+// issueBody renders the description of an issue; an issue from the public form
342+// was written by an anonymous visitor and gets the untrusted policy.
343+func (s *Server) issueBody(ctx context.Context, d *repoData, i domain.Issue) (template.HTML, error) {
344+ body := i.Body.String()
345+ if strings.TrimSpace(body) == "" {
346+ return "", nil
347+ }
348+ numbers := s.numberResolver(ctx, d.name())
349+ if i.External {
350+ return s.md.renderUntrusted([]byte(body), "issue-desc-", numbers)
351+ }
352+ return s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", numbers)
353+}
354+
355+// numberLink links merge request n of repo; nil for 0.
356+func numberLink(repo string, n int64) *Link {
357+ if n == 0 {
358+ return nil
359+ }
360+ return &Link{Label: "!" + strconv.FormatInt(n, 10), Href: mrHref(repo, n)}
361+}
362+
363+// issueRefViews are the merge requests that mention an issue.
364+func issueRefViews(repo string, refs []ports.IssueReference) []IssueRefView {
365+ views := make([]IssueRefView, 0, len(refs))
366+ for _, ref := range refs {
367+ views = append(views, IssueRefView{
368+ Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
369+ State: string(ref.State), Href: mrHref(repo, ref.Number), Closes: ref.Closes,
370+ })
371+ }
372+ return views
373+}
374+
298375 // pageOrRedirect is page(), except that a page that is not found may be the
299376 // page of the other kind of the number: target then names where to send the
300377 // visitor ("" when there is none).
@@ -83,6 +83,58 @@ type IssuePage struct {
83 Form *CommentForm83 Form *CommentForm
84 Transition *IssueActions84 Transition *IssueActions
85 Live bool85 Live bool
86+ // ClosedBy is the merge request whose merge closed the issue.
87+ ClosedBy *Link
88+ // References are the merge requests naming the issue.
89+ References []IssueRefView
90+}
91+
92+// IssueRefView is a merge request naming an issue.
93+type IssueRefView struct {
94+ Number, Title, State, Href string
95+ Closes bool
96+}
97+
98+// maxNumberLookups bounds the lookups of #N references of one render.
99+const maxNumberLookups = 50
100+
101+// numberResolver resolves #N to the page of an issue or a merge request of
102+// the repository. It is meant for one render: answers are memoized and at most
103+// maxNumberLookups numbers are asked, the others do not resolve.
104+func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, domain.NumberKind, bool) {
105+ if s.cfg.IssueNumbers == nil {
106+ return nil
107+ }
108+ name, err := domain.ParseRepoName(repo)
109+ if err != nil {
110+ return nil
111+ }
112+ type answer struct {
113+ href string
114+ kind domain.NumberKind
115+ ok bool
116+ }
117+ seen := map[int64]answer{}
118+ return func(n int64) (string, domain.NumberKind, bool) {
119+ if a, ok := seen[n]; ok {
120+ return a.href, a.kind, a.ok
121+ }
122+ if len(seen) >= maxNumberLookups {
123+ return "", domain.NumberNone, false
124+ }
125+ var a answer
126+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
127+ if err == nil {
128+ switch kind {
129+ case domain.NumberIssue:
130+ a = answer{issueHref(repo, n), kind, true}
131+ case domain.NumberMergeRequest:
132+ a = answer{mrHref(repo, n), kind, true}
133+ }
134+ }
135+ seen[n] = a
136+ return a.href, a.kind, a.ok
137+ }
86 }138 }
87 139
88 // IssueNewPage is the form that opens an issue.140 // IssueNewPage is the form that opens an issue.
@@ -238,20 +290,11 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
238 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),290 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
239 State: string(i.State), Open: i.IsOpen(),291 State: string(i.State), Open: i.IsOpen(),
240 }292 }
241- if body := i.Body.String(); strings.TrimSpace(body) != "" {293+ if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
242- var h template.HTML294+ return nil, 0, err
243- var err error
244- if i.External {
245- // Written by an anonymous visitor: the untrusted policy.
246- h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
247- } else {
248- h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
249- }
250- if err != nil {
251- return nil, 0, err
252- }
253- p.Desc = h
254 }295 }
296+ p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297+ p.References = issueRefViews(d.name(), res.References)
255 p.Opened.Name = s.who(i.Author)298 p.Opened.Name = s.who(i.Author)
256 if i.External {299 if i.External {
257 // The label is the visitor's, not the owner's: shown as plain text.300 // The label is the visitor's, not the owner's: shown as plain text.
@@ -295,6 +338,40 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
295 return IssueShow(l, p), http.StatusOK, nil338 return IssueShow(l, p), http.StatusOK, nil
296 }339 }
297 340
341+// issueBody renders the description of an issue; an issue from the public form
342+// was written by an anonymous visitor and gets the untrusted policy.
343+func (s *Server) issueBody(ctx context.Context, d *repoData, i domain.Issue) (template.HTML, error) {
344+ body := i.Body.String()
345+ if strings.TrimSpace(body) == "" {
346+ return "", nil
347+ }
348+ numbers := s.numberResolver(ctx, d.name())
349+ if i.External {
350+ return s.md.renderUntrusted([]byte(body), "issue-desc-", numbers)
351+ }
352+ return s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", numbers)
353+}
354+
355+// numberLink links merge request n of repo; nil for 0.
356+func numberLink(repo string, n int64) *Link {
357+ if n == 0 {
358+ return nil
359+ }
360+ return &Link{Label: "!" + strconv.FormatInt(n, 10), Href: mrHref(repo, n)}
361+}
362+
363+// issueRefViews are the merge requests that mention an issue.
364+func issueRefViews(repo string, refs []ports.IssueReference) []IssueRefView {
365+ views := make([]IssueRefView, 0, len(refs))
366+ for _, ref := range refs {
367+ views = append(views, IssueRefView{
368+ Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
369+ State: string(ref.State), Href: mrHref(repo, ref.Number), Closes: ref.Closes,
370+ })
371+ }
372+ return views
373+}
374+
298 // pageOrRedirect is page(), except that a page that is not found may be the375 // pageOrRedirect is page(), except that a page that is not found may be the
299 // page of the other kind of the number: target then names where to send the376 // page of the other kind of the number: target then names where to send the
300 // visitor ("" when there is none).377 // visitor ("" when there is none).
M internal/adapters/web/issues.templ +21 -0
@@ -149,6 +149,10 @@ templ IssueShow(l Layout, p IssuePage) {
149149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150150 }
151151 </dd>
152+ if p.ClosedBy != nil {
153+ <dt>closed by</dt>
154+ <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>
155+ }
152156 if p.UpdatedAt != "" {
153157 <dt>updated</dt>
154158 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
@@ -178,6 +182,23 @@ templ IssueShow(l Layout, p IssuePage) {
178182 }
179183
180184 templ issueRail(p IssuePage) {
185+ if len(p.References) > 0 {
186+ <section aria-labelledby="r-refs">
187+ <h2 id="r-refs">Merge requests</h2>
188+ <ul>
189+ for _, r := range p.References {
190+ <li>
191+ <a class="ref" href={ templ.SafeURL(r.Href) }>{ r.Number }</a>
192+ { " " + r.Title + " " }
193+ <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194+ if r.Closes {
195+ { " · closes" }
196+ }
197+ </li>
198+ }
199+ </ul>
200+ </section>
201+ }
181202 if p.Transition != nil {
182203 <section aria-labelledby="r-act">
183204 <h2 id="r-act">Issue</h2>
@@ -149,6 +149,10 @@ templ IssueShow(l Layout, p IssuePage) {
149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150 }150 }
151 </dd>151 </dd>
152+ if p.ClosedBy != nil {
153+ <dt>closed by</dt>
154+ <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>
155+ }
152 if p.UpdatedAt != "" {156 if p.UpdatedAt != "" {
153 <dt>updated</dt>157 <dt>updated</dt>
154 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>158 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
@@ -178,6 +182,23 @@ templ IssueShow(l Layout, p IssuePage) {
178 }182 }
179 183
180 templ issueRail(p IssuePage) {184 templ issueRail(p IssuePage) {
185+ if len(p.References) > 0 {
186+ <section aria-labelledby="r-refs">
187+ <h2 id="r-refs">Merge requests</h2>
188+ <ul>
189+ for _, r := range p.References {
190+ <li>
191+ <a class="ref" href={ templ.SafeURL(r.Href) }>{ r.Number }</a>
192+ { " " + r.Title + " " }
193+ <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194+ if r.Closes {
195+ { " · closes" }
196+ }
197+ </li>
198+ }
199+ </ul>
200+ </section>
201+ }
181 if p.Transition != nil {202 if p.Transition != nil {
182 <section aria-labelledby="r-act">203 <section aria-labelledby="r-act">
183 <h2 id="r-act">Issue</h2>204 <h2 id="r-act">Issue</h2>
M internal/adapters/web/issues_templ.go +164 -36 generated file: collapsed
M internal/adapters/web/issues_test.go +45 -0
@@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) {
309309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
312314 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313315 {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314316 }
@@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) {
330332 require.NoError(t, err)
331333 assert.Empty(t, got)
332334 }
335+
336+func TestIssueShowReferences(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesPublic, true)
339+ title, err := domain.ParseMRTitle("fix <b>it</b>")
340+ require.NoError(t, err)
341+ issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342+ issue.ClosedByMergeRequest = 2
343+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344+ Issue: issue,
345+ References: []ports.IssueReference{
346+ {Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347+ {Number: 3, Title: title, State: domain.MRStateOpen},
348+ },
349+ }, nil)
350+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352+
353+ rec := r.do("GET", "/proj/issues/5", false, nil)
354+ require.Equal(t, 200, rec.Code, rec.Body.String())
355+ body := rec.Body.String()
356+ assert.Contains(t, body, "closed by")
357+ assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358+ assert.Contains(t, body, ">!2<")
359+ assert.Contains(t, body, ">!3<")
360+ assert.Contains(t, body, `id="r-refs"`)
361+ assert.Contains(t, body, "closes")
362+ assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363+ assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364+ assert.Contains(t, body, `title="merge request 7"`)
365+ assert.NotContains(t, body, `/issues/8"`)
366+}
367+
368+func TestIssueShowWithoutReferences(t *testing.T) {
369+ t.Parallel()
370+ r := newIssueRig(t, domain.IssuesPublic, false)
371+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373+ }, nil)
374+ body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375+ assert.NotContains(t, body, "r-refs")
376+ assert.NotContains(t, body, "closed by")
377+}
@@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) {
309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
312 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},314 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313 {domain.IssueClosed{Issue: issue}, "summary", "", ""},315 {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314 }316 }
@@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) {
330 require.NoError(t, err)332 require.NoError(t, err)
331 assert.Empty(t, got)333 assert.Empty(t, got)
332 }334 }
335+
336+func TestIssueShowReferences(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesPublic, true)
339+ title, err := domain.ParseMRTitle("fix <b>it</b>")
340+ require.NoError(t, err)
341+ issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342+ issue.ClosedByMergeRequest = 2
343+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344+ Issue: issue,
345+ References: []ports.IssueReference{
346+ {Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347+ {Number: 3, Title: title, State: domain.MRStateOpen},
348+ },
349+ }, nil)
350+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352+
353+ rec := r.do("GET", "/proj/issues/5", false, nil)
354+ require.Equal(t, 200, rec.Code, rec.Body.String())
355+ body := rec.Body.String()
356+ assert.Contains(t, body, "closed by")
357+ assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358+ assert.Contains(t, body, ">!2<")
359+ assert.Contains(t, body, ">!3<")
360+ assert.Contains(t, body, `id="r-refs"`)
361+ assert.Contains(t, body, "closes")
362+ assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363+ assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364+ assert.Contains(t, body, `title="merge request 7"`)
365+ assert.NotContains(t, body, `/issues/8"`)
366+}
367+
368+func TestIssueShowWithoutReferences(t *testing.T) {
369+ t.Parallel()
370+ r := newIssueRig(t, domain.IssuesPublic, false)
371+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373+ }, nil)
374+ body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375+ assert.NotContains(t, body, "r-refs")
376+ assert.NotContains(t, body, "closed by")
377+}
M internal/adapters/web/markdown.go +73 -23
@@ -12,6 +12,7 @@ import (
1212 "strings"
1313 "time"
1414
15+ "bnema.dev/ermite/internal/domain"
1516 "github.com/yuin/goldmark"
1617 "github.com/yuin/goldmark/ast"
1718 "github.com/yuin/goldmark/extension"
@@ -65,15 +66,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6566 // #fragment links chosen, so that several documents on one page (the summaries
6667 // of a run) do not share ids.
6768 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
68- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
69+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
6970 }
7071
71-// renderNote uses the repository Markdown policy, then adds local note links.
72-func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
73- h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
72+// renderNote uses the repository Markdown policy, then adds local note links
73+// and links to issues and merge requests.
74+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
75+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
7476 return h, err
7577 }
7678
79+// renderIssue renders the trusted body of an issue: the policy of render, plus
80+// links to issues and merge requests.
81+func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
82+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
83+ return h, err
84+}
85+
86+// refResolver tells which references of a text are links.
87+type refResolver struct {
88+ // notes tells whether ^N names a note of the page (nil: no ^N links).
89+ notes func(int64) bool
90+ // numbers gives the page of the issue or merge request #N, when it exists
91+ // (nil: no #N links). The href is built by the server, never from the text.
92+ numbers func(int64) (href string, kind domain.NumberKind, ok bool)
93+}
94+
7795 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
7896 // approved from the public form). On top of the policy of render, which already
7997 // drops raw HTML and never loads an image:
@@ -81,19 +99,19 @@ func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes f
8199 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
82100 // every other scheme lose their link and keep the text;
83101 // - nothing points into the repository.
84-func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
85- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
102+func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
103+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
86104 return h, err
87105 }
88106
89-func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
107+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
90108 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
91109 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
92110 pol.source = source
93111 // Collect eligible text before the policy unwraps rejected links: their
94112 // labels must not accidentally become references either.
95113 var refs []*ast.Text
96- if notes != nil {
114+ if res.notes != nil || res.numbers != nil {
97115 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
98116 if !entering {
99117 return ast.WalkContinue, nil
@@ -109,7 +127,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
109127 }
110128 pol.apply(doc)
111129 for _, t := range refs {
112- noteReferences(t, source, notes)
130+ references(t, source, res)
113131 }
114132
115133 doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
@@ -392,42 +410,74 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
392410 })
393411 }
394412
395-var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
413+// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
414+// character is not part of a word; #N also rejects & and # so that numeric
415+// entities such as &#39; stay text.
416+var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
396417
397-// noteReferences changes only text segments; code and link labels never enter
398-// this function. New fragment links deliberately bypass heading-prefix rewriting.
399-func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
418+// references changes only text segments; code and link labels never enter
419+// this function. New links deliberately bypass heading-prefix rewriting.
420+func references(t *ast.Text, source []byte, res refResolver) {
400421 parent := t.Parent()
401422 if parent == nil {
402423 return
403424 }
404425 value := t.Value(source)
405426 offset := 0
406- for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
407- id := string(value[match[4]:match[5]])
427+ for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
428+ isNote := match[4] >= 0
429+ var from, to int
430+ if isNote {
431+ from, to = match[4], match[5]
432+ } else {
433+ from, to = match[8], match[9]
434+ }
435+ if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
436+ continue
437+ }
438+ id := string(value[from:to])
408439 n, err := strconv.ParseInt(id, 10, 64)
409- if err != nil || !exists(n) {
440+ if err != nil {
410441 continue
411442 }
412- start := match[4] - 1
443+ start := from - 1
413444 // Markdown punctuation may split adjacent text into separate AST nodes.
414445 // Check the original preceding byte as well as the segment-local regex.
415446 if at := t.Segment.Start + start; at > 0 {
416447 prev := source[at-1]
417- if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
448+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
449+ continue
450+ }
451+ if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
418452 continue
419453 }
420454 }
455+ link := ast.NewLink()
456+ if isNote {
457+ if !res.notes(n) {
458+ continue
459+ }
460+ link.Destination = []byte("#c" + id)
461+ link.Title = []byte("note " + id)
462+ } else {
463+ href, kind, ok := res.numbers(n)
464+ if !ok {
465+ continue
466+ }
467+ link.Destination = []byte(href)
468+ title := "issue "
469+ if kind == domain.NumberMergeRequest {
470+ title = "merge request "
471+ }
472+ link.Title = []byte(title + id)
473+ }
421474 if start > offset {
422475 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
423476 }
424- link := ast.NewLink()
425- link.Destination = []byte("#c" + id)
426- link.Title = []byte("note " + id)
427477 link.SetAttributeString("class", []byte("ref"))
428- link.AppendChild(link, ast.NewString(value[start:match[5]]))
478+ link.AppendChild(link, ast.NewString(value[start:to]))
429479 parent.InsertBefore(parent, t, link)
430- offset = match[5]
480+ offset = to
431481 }
432482 if offset == 0 {
433483 return
@@ -12,6 +12,7 @@ import (
12 "strings"12 "strings"
13 "time"13 "time"
14 14
15+ "bnema.dev/ermite/internal/domain"
15 "github.com/yuin/goldmark"16 "github.com/yuin/goldmark"
16 "github.com/yuin/goldmark/ast"17 "github.com/yuin/goldmark/ast"
17 "github.com/yuin/goldmark/extension"18 "github.com/yuin/goldmark/extension"
@@ -65,15 +66,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
65 // #fragment links chosen, so that several documents on one page (the summaries66 // #fragment links chosen, so that several documents on one page (the summaries
66 // of a run) do not share ids.67 // of a run) do not share ids.
67 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {68 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
68- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)69+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
69 }70 }
70 71
71-// renderNote uses the repository Markdown policy, then adds local note links.72+// renderNote uses the repository Markdown policy, then adds local note links
72-func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {73+// and links to issues and merge requests.
73- h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)74+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
75+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
74 return h, err76 return h, err
75 }77 }
76 78
79+// renderIssue renders the trusted body of an issue: the policy of render, plus
80+// links to issues and merge requests.
81+func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
82+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
83+ return h, err
84+}
85+
86+// refResolver tells which references of a text are links.
87+type refResolver struct {
88+ // notes tells whether ^N names a note of the page (nil: no ^N links).
89+ notes func(int64) bool
90+ // numbers gives the page of the issue or merge request #N, when it exists
91+ // (nil: no #N links). The href is built by the server, never from the text.
92+ numbers func(int64) (href string, kind domain.NumberKind, ok bool)
93+}
94+
77 // renderUntrusted renders Markdown written by an anonymous visitor (an issue95 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
78 // approved from the public form). On top of the policy of render, which already96 // approved from the public form). On top of the policy of render, which already
79 // drops raw HTML and never loads an image:97 // drops raw HTML and never loads an image:
@@ -81,19 +99,19 @@ func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes f
81 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and99 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
82 // every other scheme lose their link and keep the text;100 // every other scheme lose their link and keep the text;
83 // - nothing points into the repository.101 // - nothing points into the repository.
84-func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {102+func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
85- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)103+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
86 return h, err104 return h, err
87 }105 }
88 106
89-func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {107+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
90 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))108 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
91 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))109 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
92 pol.source = source110 pol.source = source
93 // Collect eligible text before the policy unwraps rejected links: their111 // Collect eligible text before the policy unwraps rejected links: their
94 // labels must not accidentally become references either.112 // labels must not accidentally become references either.
95 var refs []*ast.Text113 var refs []*ast.Text
96- if notes != nil {114+ if res.notes != nil || res.numbers != nil {
97 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {115 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
98 if !entering {116 if !entering {
99 return ast.WalkContinue, nil117 return ast.WalkContinue, nil
@@ -109,7 +127,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
109 }127 }
110 pol.apply(doc)128 pol.apply(doc)
111 for _, t := range refs {129 for _, t := range refs {
112- noteReferences(t, source, notes)130+ references(t, source, res)
113 }131 }
114 132
115 doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})133 doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
@@ -392,42 +410,74 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
392 })410 })
393 }411 }
394 412
395-var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)413+// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
414+// character is not part of a word; #N also rejects & and # so that numeric
415+// entities such as &#39; stay text.
416+var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
396 417
397-// noteReferences changes only text segments; code and link labels never enter418+// references changes only text segments; code and link labels never enter
398-// this function. New fragment links deliberately bypass heading-prefix rewriting.419+// this function. New links deliberately bypass heading-prefix rewriting.
399-func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {420+func references(t *ast.Text, source []byte, res refResolver) {
400 parent := t.Parent()421 parent := t.Parent()
401 if parent == nil {422 if parent == nil {
402 return423 return
403 }424 }
404 value := t.Value(source)425 value := t.Value(source)
405 offset := 0426 offset := 0
406- for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {427+ for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
407- id := string(value[match[4]:match[5]])428+ isNote := match[4] >= 0
429+ var from, to int
430+ if isNote {
431+ from, to = match[4], match[5]
432+ } else {
433+ from, to = match[8], match[9]
434+ }
435+ if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
436+ continue
437+ }
438+ id := string(value[from:to])
408 n, err := strconv.ParseInt(id, 10, 64)439 n, err := strconv.ParseInt(id, 10, 64)
409- if err != nil || !exists(n) {440+ if err != nil {
410 continue441 continue
411 }442 }
412- start := match[4] - 1443+ start := from - 1
413 // Markdown punctuation may split adjacent text into separate AST nodes.444 // Markdown punctuation may split adjacent text into separate AST nodes.
414 // Check the original preceding byte as well as the segment-local regex.445 // Check the original preceding byte as well as the segment-local regex.
415 if at := t.Segment.Start + start; at > 0 {446 if at := t.Segment.Start + start; at > 0 {
416 prev := source[at-1]447 prev := source[at-1]
417- if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {448+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
449+ continue
450+ }
451+ if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
418 continue452 continue
419 }453 }
420 }454 }
455+ link := ast.NewLink()
456+ if isNote {
457+ if !res.notes(n) {
458+ continue
459+ }
460+ link.Destination = []byte("#c" + id)
461+ link.Title = []byte("note " + id)
462+ } else {
463+ href, kind, ok := res.numbers(n)
464+ if !ok {
465+ continue
466+ }
467+ link.Destination = []byte(href)
468+ title := "issue "
469+ if kind == domain.NumberMergeRequest {
470+ title = "merge request "
471+ }
472+ link.Title = []byte(title + id)
473+ }
421 if start > offset {474 if start > offset {
422 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))475 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
423 }476 }
424- link := ast.NewLink()
425- link.Destination = []byte("#c" + id)
426- link.Title = []byte("note " + id)
427 link.SetAttributeString("class", []byte("ref"))477 link.SetAttributeString("class", []byte("ref"))
428- link.AppendChild(link, ast.NewString(value[start:match[5]]))478+ link.AppendChild(link, ast.NewString(value[start:to]))
429 parent.InsertBefore(parent, t, link)479 parent.InsertBefore(parent, t, link)
430- offset = match[5]480+ offset = to
431 }481 }
432 if offset == 0 {482 if offset == 0 {
433 return483 return
M internal/adapters/web/markdown_test.go +91 -4
@@ -6,7 +6,10 @@ import (
66 "strings"
77 "testing"
88
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
911 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
1013 "github.com/stretchr/testify/require"
1114 )
1215
@@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) {
139142
140143 func renderUntrustedMD(t *testing.T, src string) string {
141144 t.Helper()
142- h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
145+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
143146 require.NoError(t, err)
144147 return string(h)
145148 }
@@ -218,14 +221,14 @@ func TestNoteMarkdown(t *testing.T) {
218221 for _, tt := range tests {
219222 t.Run(tt.source, func(t *testing.T) {
220223 t.Parallel()
221- h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
224+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
222225 require.NoError(t, err)
223226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224227 assert.NotContains(t, string(h), `href="#c99"`)
225228 assert.NotContains(t, string(h), "javascript:")
226229 })
227230 }
228- h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
231+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
229232 require.NoError(t, err)
230233 assert.Contains(t, string(h), `id="c1-heading"`)
231234 assert.Contains(t, string(h), "<strong>bold</strong>")
@@ -233,6 +236,90 @@ func TestNoteMarkdown(t *testing.T) {
233236 assert.Contains(t, string(h), `title="note 2"`)
234237 }
235238
239+func TestNumberReferences(t *testing.T) {
240+ t.Parallel()
241+ numbers := func(n int64) (string, domain.NumberKind, bool) {
242+ switch n {
243+ case 3:
244+ return "/demo/issues/3", domain.NumberIssue, true
245+ case 4:
246+ return "/demo/merge-requests/4", domain.NumberMergeRequest, true
247+ }
248+ return "", domain.NumberNone, false
249+ }
250+ tests := []struct {
251+ source string
252+ want []string
253+ not []string
254+ }{
255+ {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256+ {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257+ {"&#39; &#3;", nil, []string{"<a "}},
258+ {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259+ {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260+ {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261+ {"#03 #12345678901", nil, []string{"<a "}},
262+ }
263+ for _, tt := range tests {
264+ t.Run(tt.source, func(t *testing.T) {
265+ t.Parallel()
266+ h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267+ require.NoError(t, err)
268+ for _, w := range tt.want {
269+ assert.Contains(t, string(h), w)
270+ }
271+ for _, w := range tt.not {
272+ assert.NotContains(t, string(h), w)
273+ }
274+ u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275+ require.NoError(t, err)
276+ assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277+ })
278+ }
279+ // Without a resolver nothing links.
280+ h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281+ require.NoError(t, err)
282+ assert.NotContains(t, string(h), "<a ")
283+}
284+
285+func TestNumberAndNoteReferencesTogether(t *testing.T) {
286+ t.Parallel()
287+ h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288+ notes: func(n int64) bool { return n == 2 },
289+ numbers: func(n int64) (string, domain.NumberKind, bool) { return "/demo/issues/3", domain.NumberIssue, n == 3 },
290+ })
291+ require.NoError(t, err)
292+ assert.Contains(t, string(h), `href="#c2"`)
293+ assert.Contains(t, string(h), `href="/demo/issues/3"`)
294+ assert.Contains(t, string(h), "and ")
295+}
296+
297+func TestNumberResolverCapsLookups(t *testing.T) {
298+ t.Parallel()
299+ nums := mocks.NewIssueNumberResolver(t)
300+ nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301+ srv := &Server{cfg: Config{IssueNumbers: nums}}
302+ resolve := srv.numberResolver(t.Context(), "demo")
303+ require.NotNil(t, resolve)
304+ for n := int64(1); n <= maxNumberLookups+10; n++ {
305+ _, _, ok := resolve(n)
306+ assert.Equal(t, n <= maxNumberLookups, ok, n)
307+ }
308+ _, _, ok := resolve(1) // memoized
309+ assert.True(t, ok)
310+}
311+
312+func TestNumberReferenceTitleFromKindNotHref(t *testing.T) {
313+ t.Parallel()
314+ numbers := func(n int64) (string, domain.NumberKind, bool) {
315+ return "/owner/merge-requests/issues/3", domain.NumberIssue, true
316+ }
317+ h, err := newMarkdown().renderIssue([]byte("see #3"), "merge-requests", "main", "i-", numbers)
318+ require.NoError(t, err)
319+ assert.Contains(t, string(h), `title="issue 3"`)
320+ assert.NotContains(t, string(h), "merge request 3")
321+}
322+
236323 func TestMarkdownFencedHighlight(t *testing.T) {
237324 tests := []struct {
238325 name, info, code string
@@ -252,7 +339,7 @@ func TestMarkdownFencedHighlight(t *testing.T) {
252339 for _, untrusted := range []bool{false, true} {
253340 var h string
254341 if untrusted {
255- out, err := newMarkdown().renderUntrusted([]byte(src), "test-")
342+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-", nil)
256343 require.NoError(t, err)
257344 h = string(out)
258345 } else {
@@ -6,7 +6,10 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
11 )14 )
12 15
@@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) {
139 142
140 func renderUntrustedMD(t *testing.T, src string) string {143 func renderUntrustedMD(t *testing.T, src string) string {
141 t.Helper()144 t.Helper()
142- h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")145+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
143 require.NoError(t, err)146 require.NoError(t, err)
144 return string(h)147 return string(h)
145 }148 }
@@ -218,14 +221,14 @@ func TestNoteMarkdown(t *testing.T) {
218 for _, tt := range tests {221 for _, tt := range tests {
219 t.Run(tt.source, func(t *testing.T) {222 t.Run(tt.source, func(t *testing.T) {
220 t.Parallel()223 t.Parallel()
221- h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })224+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
222 require.NoError(t, err)225 require.NoError(t, err)
223 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224 assert.NotContains(t, string(h), `href="#c99"`)227 assert.NotContains(t, string(h), `href="#c99"`)
225 assert.NotContains(t, string(h), "javascript:")228 assert.NotContains(t, string(h), "javascript:")
226 })229 })
227 }230 }
228- h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })231+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
229 require.NoError(t, err)232 require.NoError(t, err)
230 assert.Contains(t, string(h), `id="c1-heading"`)233 assert.Contains(t, string(h), `id="c1-heading"`)
231 assert.Contains(t, string(h), "<strong>bold</strong>")234 assert.Contains(t, string(h), "<strong>bold</strong>")
@@ -233,6 +236,90 @@ func TestNoteMarkdown(t *testing.T) {
233 assert.Contains(t, string(h), `title="note 2"`)236 assert.Contains(t, string(h), `title="note 2"`)
234 }237 }
235 238
239+func TestNumberReferences(t *testing.T) {
240+ t.Parallel()
241+ numbers := func(n int64) (string, domain.NumberKind, bool) {
242+ switch n {
243+ case 3:
244+ return "/demo/issues/3", domain.NumberIssue, true
245+ case 4:
246+ return "/demo/merge-requests/4", domain.NumberMergeRequest, true
247+ }
248+ return "", domain.NumberNone, false
249+ }
250+ tests := []struct {
251+ source string
252+ want []string
253+ not []string
254+ }{
255+ {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256+ {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257+ {"&#39; &#3;", nil, []string{"<a "}},
258+ {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259+ {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260+ {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261+ {"#03 #12345678901", nil, []string{"<a "}},
262+ }
263+ for _, tt := range tests {
264+ t.Run(tt.source, func(t *testing.T) {
265+ t.Parallel()
266+ h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267+ require.NoError(t, err)
268+ for _, w := range tt.want {
269+ assert.Contains(t, string(h), w)
270+ }
271+ for _, w := range tt.not {
272+ assert.NotContains(t, string(h), w)
273+ }
274+ u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275+ require.NoError(t, err)
276+ assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277+ })
278+ }
279+ // Without a resolver nothing links.
280+ h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281+ require.NoError(t, err)
282+ assert.NotContains(t, string(h), "<a ")
283+}
284+
285+func TestNumberAndNoteReferencesTogether(t *testing.T) {
286+ t.Parallel()
287+ h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288+ notes: func(n int64) bool { return n == 2 },
289+ numbers: func(n int64) (string, domain.NumberKind, bool) { return "/demo/issues/3", domain.NumberIssue, n == 3 },
290+ })
291+ require.NoError(t, err)
292+ assert.Contains(t, string(h), `href="#c2"`)
293+ assert.Contains(t, string(h), `href="/demo/issues/3"`)
294+ assert.Contains(t, string(h), "and ")
295+}
296+
297+func TestNumberResolverCapsLookups(t *testing.T) {
298+ t.Parallel()
299+ nums := mocks.NewIssueNumberResolver(t)
300+ nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301+ srv := &Server{cfg: Config{IssueNumbers: nums}}
302+ resolve := srv.numberResolver(t.Context(), "demo")
303+ require.NotNil(t, resolve)
304+ for n := int64(1); n <= maxNumberLookups+10; n++ {
305+ _, _, ok := resolve(n)
306+ assert.Equal(t, n <= maxNumberLookups, ok, n)
307+ }
308+ _, _, ok := resolve(1) // memoized
309+ assert.True(t, ok)
310+}
311+
312+func TestNumberReferenceTitleFromKindNotHref(t *testing.T) {
313+ t.Parallel()
314+ numbers := func(n int64) (string, domain.NumberKind, bool) {
315+ return "/owner/merge-requests/issues/3", domain.NumberIssue, true
316+ }
317+ h, err := newMarkdown().renderIssue([]byte("see #3"), "merge-requests", "main", "i-", numbers)
318+ require.NoError(t, err)
319+ assert.Contains(t, string(h), `title="issue 3"`)
320+ assert.NotContains(t, string(h), "merge request 3")
321+}
322+
236 func TestMarkdownFencedHighlight(t *testing.T) {323 func TestMarkdownFencedHighlight(t *testing.T) {
237 tests := []struct {324 tests := []struct {
238 name, info, code string325 name, info, code string
@@ -252,7 +339,7 @@ func TestMarkdownFencedHighlight(t *testing.T) {
252 for _, untrusted := range []bool{false, true} {339 for _, untrusted := range []bool{false, true} {
253 var h string340 var h string
254 if untrusted {341 if untrusted {
255- out, err := newMarkdown().renderUntrusted([]byte(src), "test-")342+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-", nil)
256 require.NoError(t, err)343 require.NoError(t, err)
257 h = string(out)344 h = string(out)
258 } else {345 } else {
M internal/adapters/web/mr.go +4 -4
@@ -616,7 +616,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
616616 if canWrite {
617617 on = parseOn(r.URL.Query().Get("on"))
618618 }
619- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
619+ s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
620620 lines, invisible := diffLines(p.Files)
621621 p.Invisible = invisible
622622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -680,7 +680,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
680680 // Relative links point into the source branch as it is now (its head
681681 // commit); the heading ids carry their own prefix, apart from the
682682 // ids of the diff.
683- h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
683+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
684684 if err != nil {
685685 return err
686686 }
@@ -905,7 +905,7 @@ func parseOn(s string) *lineRef {
905905 // as links to the form of their line; on designates the line whose form shows.
906906 //
907907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908-func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
908+func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909909 now := s.cfg.Now()
910910 self := mrHref(repo, n)
911911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
@@ -953,7 +953,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
953953 return index[lineKey{path, side, line}]
954954 }
955955
956- exists := noteIDs(comments)
956+ exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
957957 children := map[int64][]domain.Comment{}
958958 for _, c := range comments {
959959 if c.ReplyTo != 0 && !c.Deleted {
@@ -616,7 +616,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
616 if canWrite {616 if canWrite {
617 on = parseOn(r.URL.Query().Get("on"))617 on = parseOn(r.URL.Query().Get("on"))
618 }618 }
619- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)619+ s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
620 lines, invisible := diffLines(p.Files)620 lines, invisible := diffLines(p.Files)
621 p.Invisible = invisible621 p.Invisible = invisible
622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -680,7 +680,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
680 // Relative links point into the source branch as it is now (its head680 // Relative links point into the source branch as it is now (its head
681 // commit); the heading ids carry their own prefix, apart from the681 // commit); the heading ids carry their own prefix, apart from the
682 // ids of the diff.682 // ids of the diff.
683- h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))683+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
684 if err != nil {684 if err != nil {
685 return err685 return err
686 }686 }
@@ -905,7 +905,7 @@ func parseOn(s string) *lineRef {
905 // as links to the form of their line; on designates the line whose form shows.905 // as links to the form of their line; on designates the line whose form shows.
906 //906 //
907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908-func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {908+func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909 now := s.cfg.Now()909 now := s.cfg.Now()
910 self := mrHref(repo, n)910 self := mrHref(repo, n)
911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
@@ -953,7 +953,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
953 return index[lineKey{path, side, line}]953 return index[lineKey{path, side, line}]
954 }954 }
955 955
956- exists := noteIDs(comments)956+ exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
957 children := map[int64][]domain.Comment{}957 children := map[int64][]domain.Comment{}
958 for _, c := range comments {958 for _, c := range comments {
959 if c.ReplyTo != 0 && !c.Deleted {959 if c.ReplyTo != 0 && !c.Deleted {
M internal/adapters/web/mr_files.go +1 -1
@@ -227,7 +227,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
227227 } else {
228228 collapseFiles(p.Files, res.Diff.Files, links)
229229 }
230- s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
230+ s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232232 if one {
233233 window = p.Files[from : from+1]
@@ -227,7 +227,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
227 } else {227 } else {
228 collapseFiles(p.Files, res.Diff.Files, links)228 collapseFiles(p.Files, res.Diff.Files, links)
229 }229 }
230- s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)230+ s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232 if one {232 if one {
233 window = p.Files[from : from+1]233 window = p.Files[from : from+1]
M internal/adapters/web/mr_notes_test.go +3 -3
@@ -43,7 +43,7 @@ func TestNoteThreads(t *testing.T) {
4343 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
4444 }
4545 p := MRPage{NoteAuthor: "alice"}
46- s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
46+ s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
4747 require.Len(t, p.General, 3)
4848 assert.Equal(t, 4, p.NComments)
4949 assert.Equal(t, 1, p.NUnresolved)
@@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) {
117117 require.NoError(t, err)
118118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119119 p := MRPage{}
120- s.placeComments(&p, "demo", 1, []domain.Comment{
120+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122122 {ID: 2, Deleted: true},
123123 {ID: 3, ReplyTo: 2, Deleted: true},
@@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) {
147147 require.NoError(t, err)
148148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150- s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
150+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152152 require.Len(t, notes, 1)
153153 var b bytes.Buffer
@@ -43,7 +43,7 @@ func TestNoteThreads(t *testing.T) {
43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44 }44 }
45 p := MRPage{NoteAuthor: "alice"}45 p := MRPage{NoteAuthor: "alice"}
46- s.placeComments(&p, "demo", 1, comments, "", true, true, nil)46+ s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
47 require.Len(t, p.General, 3)47 require.Len(t, p.General, 3)
48 assert.Equal(t, 4, p.NComments)48 assert.Equal(t, 4, p.NComments)
49 assert.Equal(t, 1, p.NUnresolved)49 assert.Equal(t, 1, p.NUnresolved)
@@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) {
117 require.NoError(t, err)117 require.NoError(t, err)
118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119 p := MRPage{}119 p := MRPage{}
120- s.placeComments(&p, "demo", 1, []domain.Comment{120+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122 {ID: 2, Deleted: true},122 {ID: 2, Deleted: true},
123 {ID: 3, ReplyTo: 2, Deleted: true},123 {ID: 3, ReplyTo: 2, Deleted: true},
@@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) {
147 require.NoError(t, err)147 require.NoError(t, err)
148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150- s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)150+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151 notes := p.Files[0].Hunks[0].Lines[0].Notes151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152 require.Len(t, notes, 1)152 require.Len(t, notes, 1)
153 var b bytes.Buffer153 var b bytes.Buffer
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_issue.go +55 -9
M internal/app/cli_issue_e2e_test.go +7 -2
M internal/app/cli_issue_test.go +36 -1
M internal/app/forge.go +4 -2
M internal/app/issue_api_test.go +52 -0
M internal/domain/event.go +12 -0
M internal/domain/issue.go +8 -0
A internal/domain/reference.go +112 -0
A internal/domain/reference_test.go +78 -0
M internal/ports/dto.go +12 -2
A internal/ports/mocks/IssueLinkStore.go +296 -0
A internal/ports/mocks/IssueLinkTrigger.go +262 -0
M internal/ports/ports.go +20 -0
M internal/ports/services.go +2 -1
M internal/usecase/events.go +7 -1
M internal/usecase/events_test.go +24 -0
A internal/usecase/issue_links.go +286 -0
A internal/usecase/issue_links_test.go +235 -0
M internal/usecase/issues.go +35 -9
M internal/usecase/issues_test.go +4 -2
M internal/usecase/mergerequests.go +33 -3
M internal/usecase/pushes.go +7 -0
M proto/ermite/storage/issue.proto +12 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 3

Tags 41

↑↓ moveenter openesc close