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
M docs/issues.md +12 -0
M docs/merge-requests.md +2 -0
M gen/ermite/storage/issue.pb.go +146 -31
M go.mod +1 -1
M go.sum +2 -2
A internal/adapters/kvstore/issue_links.go +151 -0
A internal/adapters/kvstore/issue_links_test.go +66 -0
M internal/adapters/kvstore/issues.go +15 -13
M internal/adapters/kvstore/mergerequests.go +1 -1
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +2 -2
M internal/adapters/rpc/issue.go +19 -9
M internal/adapters/web/fragments.go +3 -1
M internal/adapters/web/issues.go +90 -13
M internal/adapters/web/issues.templ +21 -0
M internal/adapters/web/issues_templ.go +164 -36
M internal/adapters/web/issues_test.go +45 -0
M internal/adapters/web/markdown.go +73 -23
M internal/adapters/web/markdown_test.go +91 -4
M internal/adapters/web/mr.go +4 -4
M internal/adapters/web/mr_files.go +1 -1
M internal/adapters/web/mr_notes_test.go +3 -3
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +1 -1
M internal/app/cli_issue.go +55 -9
@@ -57,6 +57,8 @@ type jsonIssue struct {
5757 CreatedAt *string `json:"created_at"`
5858 UpdatedAt *string `json:"updated_at"`
5959 CommentCount int32 `json:"comment_count"`
60+ // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61+ ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
6062 }
6163
6264 func toJSONIssue(i *ermite.Issue) jsonIssue {
@@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue {
6466 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
6567 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
6668 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69+ ClosedByMergeRequest: i.GetClosedByMergeRequest(),
6770 }
6871 }
6972
@@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
8285 // jsonIssueDetail is the stable JSON shape of `issue show --json`.
8386 type jsonIssueDetail struct {
8487 jsonIssue
85- Comments []jsonIssueComment `json:"comments"`
88+ Comments []jsonIssueComment `json:"comments"`
89+ References []jsonIssueReference `json:"references"`
90+}
91+
92+// jsonIssueReference is a merge request that names the issue.
93+type jsonIssueReference struct {
94+ Number int64 `json:"number"`
95+ Title string `json:"title"`
96+ State string `json:"state"`
97+ Closes bool `json:"closes"`
8698 }
8799
88100 const jsonIssueHelp = `
89101 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90-(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`
102+(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103+the merge request whose merge closed the issue, 0 for none).`
91104
92105 const jsonIssueCommentHelp = `
93106 JSON fields of an issue comment: id, author, text, created_at.`
94107
108+const jsonIssueReferenceHelp = `
109+JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110+(true when merging it closes the issue).`
111+
95112 func newIssueCmd(env *cliEnv) *cobra.Command {
96113 issue := &cobra.Command{
97114 Use: "issue",
@@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
298315 cmd := &cobra.Command{
299316 Use: "show <repo> <number>",
300317 Short: "Show an issue with its comments",
301- Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +
302- "\n--json adds comments to the issue fields.",
318+ Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
319+ "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320+ "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322+ "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
303323 Args: cobra.ExactArgs(2),
304324 ValidArgsFunction: completeIssueRepoThenNumber(env),
305325 }
@@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
322342 for _, cm := range res.Msg.GetComments() {
323343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
324344 }
345+ out.References = []jsonIssueReference{}
346+ for _, r := range res.Msg.GetReferences() {
347+ out.References = append(out.References, jsonIssueReference{
348+ Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349+ })
350+ }
325351 return writeJSON(cmd.OutOrStdout(), out)
326352 }
327353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
@@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
335361 if body == "" {
336362 body = "-"
337363 }
338- err := fields(w, [][2]string{
364+ rows := [][2]string{
339365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340366 {"title", i.GetTitle()},
341367 {"state", issueStateName(i.GetState())},
342- {"author", i.GetAuthor()},
343- {"created", shortTime(i.GetCreatedAt(), "-")},
344- {"updated", shortTime(i.GetUpdatedAt(), "-")},
345- })
368+ }
369+ if n := i.GetClosedByMergeRequest(); n > 0 {
370+ rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
371+ }
372+ rows = append(rows,
373+ [2]string{"author", i.GetAuthor()},
374+ [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375+ [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376+ )
377+ err := fields(w, rows)
346378 if err != nil {
347379 return err
348380 }
349381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350382 return err
351383 }
384+ if refs := d.GetReferences(); len(refs) > 0 {
385+ if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386+ return err
387+ }
388+ for _, r := range refs {
389+ line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390+ if r.GetCloses() {
391+ line += " · closes"
392+ }
393+ if _, err := fmt.Fprintln(w, line); err != nil {
394+ return err
395+ }
396+ }
397+ }
352398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353399 return err
354400 }
@@ -57,6 +57,8 @@ type jsonIssue struct {
57 CreatedAt *string `json:"created_at"`57 CreatedAt *string `json:"created_at"`
58 UpdatedAt *string `json:"updated_at"`58 UpdatedAt *string `json:"updated_at"`
59 CommentCount int32 `json:"comment_count"`59 CommentCount int32 `json:"comment_count"`
60+ // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61+ ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
60 }62 }
61 63
62 func toJSONIssue(i *ermite.Issue) jsonIssue {64 func toJSONIssue(i *ermite.Issue) jsonIssue {
@@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue {
64 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),66 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
65 State: issueStateName(i.GetState()), Author: i.GetAuthor(),67 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
66 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),68 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69+ ClosedByMergeRequest: i.GetClosedByMergeRequest(),
67 }70 }
68 }71 }
69 72
@@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
82 // jsonIssueDetail is the stable JSON shape of `issue show --json`.85 // jsonIssueDetail is the stable JSON shape of `issue show --json`.
83 type jsonIssueDetail struct {86 type jsonIssueDetail struct {
84 jsonIssue87 jsonIssue
85- Comments []jsonIssueComment `json:"comments"`88+ Comments []jsonIssueComment `json:"comments"`
89+ References []jsonIssueReference `json:"references"`
90+}
91+
92+// jsonIssueReference is a merge request that names the issue.
93+type jsonIssueReference struct {
94+ Number int64 `json:"number"`
95+ Title string `json:"title"`
96+ State string `json:"state"`
97+ Closes bool `json:"closes"`
86 }98 }
87 99
88 const jsonIssueHelp = `100 const jsonIssueHelp = `
89 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author101 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90-(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`102+(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103+the merge request whose merge closed the issue, 0 for none).`
91 104
92 const jsonIssueCommentHelp = `105 const jsonIssueCommentHelp = `
93 JSON fields of an issue comment: id, author, text, created_at.`106 JSON fields of an issue comment: id, author, text, created_at.`
94 107
108+const jsonIssueReferenceHelp = `
109+JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110+(true when merging it closes the issue).`
111+
95 func newIssueCmd(env *cliEnv) *cobra.Command {112 func newIssueCmd(env *cliEnv) *cobra.Command {
96 issue := &cobra.Command{113 issue := &cobra.Command{
97 Use: "issue",114 Use: "issue",
@@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
298 cmd := &cobra.Command{315 cmd := &cobra.Command{
299 Use: "show <repo> <number>",316 Use: "show <repo> <number>",
300 Short: "Show an issue with its comments",317 Short: "Show an issue with its comments",
301- Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +318+ Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
302- "\n--json adds comments to the issue fields.",319+ "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320+ "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322+ "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
303 Args: cobra.ExactArgs(2),323 Args: cobra.ExactArgs(2),
304 ValidArgsFunction: completeIssueRepoThenNumber(env),324 ValidArgsFunction: completeIssueRepoThenNumber(env),
305 }325 }
@@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
322 for _, cm := range res.Msg.GetComments() {342 for _, cm := range res.Msg.GetComments() {
323 out.Comments = append(out.Comments, toJSONIssueComment(cm))343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
324 }344 }
345+ out.References = []jsonIssueReference{}
346+ for _, r := range res.Msg.GetReferences() {
347+ out.References = append(out.References, jsonIssueReference{
348+ Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349+ })
350+ }
325 return writeJSON(cmd.OutOrStdout(), out)351 return writeJSON(cmd.OutOrStdout(), out)
326 }352 }
327 return printIssueDetail(cmd.OutOrStdout(), res.Msg)353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
@@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
335 if body == "" {361 if body == "" {
336 body = "-"362 body = "-"
337 }363 }
338- err := fields(w, [][2]string{364+ rows := [][2]string{
339 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340 {"title", i.GetTitle()},366 {"title", i.GetTitle()},
341 {"state", issueStateName(i.GetState())},367 {"state", issueStateName(i.GetState())},
342- {"author", i.GetAuthor()},368+ }
343- {"created", shortTime(i.GetCreatedAt(), "-")},369+ if n := i.GetClosedByMergeRequest(); n > 0 {
344- {"updated", shortTime(i.GetUpdatedAt(), "-")},370+ rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
345- })371+ }
372+ rows = append(rows,
373+ [2]string{"author", i.GetAuthor()},
374+ [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375+ [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376+ )
377+ err := fields(w, rows)
346 if err != nil {378 if err != nil {
347 return err379 return err
348 }380 }
349 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350 return err382 return err
351 }383 }
384+ if refs := d.GetReferences(); len(refs) > 0 {
385+ if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386+ return err
387+ }
388+ for _, r := range refs {
389+ line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390+ if r.GetCloses() {
391+ line += " · closes"
392+ }
393+ if _, err := fmt.Fprintln(w, line); err != nil {
394+ return err
395+ }
396+ }
397+ }
352 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353 return err399 return err
354 }400 }
M internal/app/cli_issue_e2e_test.go +7 -2
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "encoding/json"
67 "strings"
78 "testing"
89
@@ -24,7 +25,7 @@ type issueOut struct {
2425 CommentCount int `json:"comment_count"`
2526 }
2627
27-var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
2829
2930 type issueCommentOut struct {
3031 ID string `json:"id"`
@@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
106107 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107108 type showOut struct {
108109 issueOut
109- Comments []issueCommentOut `json:"comments"`
110+ Comments []issueCommentOut `json:"comments"`
111+ References []json.RawMessage `json:"references"`
110112 }
111113 show := decode[showOut](t, res.stdout)
112114 assert.Equal(t, 2, show.CommentCount)
@@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
115117 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116118 assert.Contains(t, res.stdout, "Crash on start")
117119 assert.Contains(t, res.stdout, "comments (2)")
120+ assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue")
121+ assert.NotNil(t, show.References, "references is always present")
122+ assert.Empty(t, show.References)
118123
119124 // close and reopen.
120125 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "encoding/json"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 9
@@ -24,7 +25,7 @@ type issueOut struct {
24 CommentCount int `json:"comment_count"`25 CommentCount int `json:"comment_count"`
25 }26 }
26 27
27-var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}28+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28 29
29 type issueCommentOut struct {30 type issueCommentOut struct {
30 ID string `json:"id"`31 ID string `json:"id"`
@@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
106 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)107 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107 type showOut struct {108 type showOut struct {
108 issueOut109 issueOut
109- Comments []issueCommentOut `json:"comments"`110+ Comments []issueCommentOut `json:"comments"`
111+ References []json.RawMessage `json:"references"`
110 }112 }
111 show := decode[showOut](t, res.stdout)113 show := decode[showOut](t, res.stdout)
112 assert.Equal(t, 2, show.CommentCount)114 assert.Equal(t, 2, show.CommentCount)
@@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
115 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)117 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116 assert.Contains(t, res.stdout, "Crash on start")118 assert.Contains(t, res.stdout, "Crash on start")
117 assert.Contains(t, res.stdout, "comments (2)")119 assert.Contains(t, res.stdout, "comments (2)")
120+ assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue")
121+ assert.NotNil(t, show.References, "references is always present")
122+ assert.Empty(t, show.References)
118 123
119 // close and reopen.124 // close and reopen.
120 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)125 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
M internal/app/cli_issue_test.go +36 -1
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "os"
88 "path/filepath"
9+ "strings"
910 "testing"
1011
1112 "github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
5152 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
5253
5354 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)
55+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout)
5556
5657 for _, args := range [][]string{
5758 {"issue", "create", "alpha"},
@@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
262263 }
263264 t.Fatal("never completed")
264265 }
266+
267+func TestIssueShowMergeRequestLinks(t *testing.T) {
268+ closed := &ermite.Issue{
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270+ ClosedByMergeRequest: 5,
271+ }
272+ f := &fakeAPI{
273+ getVersion: withIssues,
274+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275+ return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276+ {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277+ {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278+ }}, nil
279+ },
280+ }
281+ url := f.serve(t)
282+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283+ assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285+ assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286+
287+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289+
290+ // No references: no block in text, an empty array in JSON.
291+ f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292+ return &ermite.GetIssueResponse{Issue: issueOne}, nil
293+ }
294+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295+ assert.NotContains(t, res.stdout, "merge requests")
296+ assert.NotContains(t, res.stdout, "closed by")
297+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298+ assert.Contains(t, res.stdout, `"references": []`)
299+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "testing"10 "testing"
10 11
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
51 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)52 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52 53
53 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)54 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)55+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout)
55 56
56 for _, args := range [][]string{57 for _, args := range [][]string{
57 {"issue", "create", "alpha"},58 {"issue", "create", "alpha"},
@@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
262 }263 }
263 t.Fatal("never completed")264 t.Fatal("never completed")
264 }265 }
266+
267+func TestIssueShowMergeRequestLinks(t *testing.T) {
268+ closed := &ermite.Issue{
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270+ ClosedByMergeRequest: 5,
271+ }
272+ f := &fakeAPI{
273+ getVersion: withIssues,
274+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275+ return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276+ {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277+ {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278+ }}, nil
279+ },
280+ }
281+ url := f.serve(t)
282+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283+ assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285+ assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286+
287+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289+
290+ // No references: no block in text, an empty array in JSON.
291+ f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292+ return &ermite.GetIssueResponse{Issue: issueOne}, nil
293+ }
294+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295+ assert.NotContains(t, res.stdout, "merge requests")
296+ assert.NotContains(t, res.stdout, "closed by")
297+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298+ assert.Contains(t, res.stdout, `"references": []`)
299+}
M internal/app/forge.go +4 -2
@@ -363,15 +363,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364364 })
365365 f.Pushes.WithMergeability(f.Mergeability)
366+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
367+ f.Pushes.WithIssueLinks(links)
366368 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367- Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
369+ Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370372 })
371373 f.Mergeability.Start()
372374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374- Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
376+ Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375377 })
376378 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377379 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
@@ -363,15 +363,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364 })364 })
365 f.Pushes.WithMergeability(f.Mergeability)365 f.Pushes.WithMergeability(f.Mergeability)
366+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
367+ f.Pushes.WithIssueLinks(links)
366 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{368 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367- Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,369+ Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370 })372 })
371 f.Mergeability.Start()373 f.Mergeability.Start()
372 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374- Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,376+ Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375 })377 })
376 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{378 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,379 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
M internal/app/issue_api_test.go +52 -0
@@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
243243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244244 require.NoError(t, err)
245245 }
246+
247+func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
248+ r := newMRRig(t, domain.VisibilityPublic)
249+ ctx := r.ctx
250+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
251+ require.NoError(t, err)
252+ for _, title := range []string{"Crash", "Slow"} {
253+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
254+ require.NoError(t, err)
255+ }
256+ // The merge request mentions #1 (closing) and #2 (plain).
257+ created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
258+ Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
259+ }))
260+ require.NoError(t, err)
261+ number := created.Msg.GetMergeRequest().GetNumber()
262+
263+ get := func(n int64) *pb.GetIssueResponse {
264+ res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
265+ require.NoError(t, err)
266+ return res.Msg
267+ }
268+ one := get(1)
269+ require.Len(t, one.GetReferences(), 1)
270+ ref := one.GetReferences()[0]
271+ assert.Equal(t, number, ref.GetNumber())
272+ assert.Equal(t, "Change a", ref.GetTitle())
273+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
274+ assert.True(t, ref.GetCloses())
275+ two := get(2)
276+ require.Len(t, two.GetReferences(), 1)
277+ assert.False(t, two.GetReferences()[0].GetCloses())
278+ assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
279+
280+ _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
281+ require.NoError(t, err)
282+ r.forge.MergeRequests.Wait(ctx)
283+
284+ one = get(1)
285+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
286+ assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
287+ require.Len(t, one.GetReferences(), 1)
288+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
289+ two = get(2)
290+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
291+ assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
292+
293+ // Reopening by hand clears "closed by".
294+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
295+ require.NoError(t, err)
296+ assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
297+}
@@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244 require.NoError(t, err)244 require.NoError(t, err)
245 }245 }
246+
247+func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
248+ r := newMRRig(t, domain.VisibilityPublic)
249+ ctx := r.ctx
250+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
251+ require.NoError(t, err)
252+ for _, title := range []string{"Crash", "Slow"} {
253+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
254+ require.NoError(t, err)
255+ }
256+ // The merge request mentions #1 (closing) and #2 (plain).
257+ created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
258+ Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
259+ }))
260+ require.NoError(t, err)
261+ number := created.Msg.GetMergeRequest().GetNumber()
262+
263+ get := func(n int64) *pb.GetIssueResponse {
264+ res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
265+ require.NoError(t, err)
266+ return res.Msg
267+ }
268+ one := get(1)
269+ require.Len(t, one.GetReferences(), 1)
270+ ref := one.GetReferences()[0]
271+ assert.Equal(t, number, ref.GetNumber())
272+ assert.Equal(t, "Change a", ref.GetTitle())
273+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
274+ assert.True(t, ref.GetCloses())
275+ two := get(2)
276+ require.Len(t, two.GetReferences(), 1)
277+ assert.False(t, two.GetReferences()[0].GetCloses())
278+ assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
279+
280+ _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
281+ require.NoError(t, err)
282+ r.forge.MergeRequests.Wait(ctx)
283+
284+ one = get(1)
285+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
286+ assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
287+ require.Len(t, one.GetReferences(), 1)
288+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
289+ two = get(2)
290+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
291+ assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
292+
293+ // Reopening by hand clears "closed by".
294+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
295+ require.NoError(t, err)
296+ assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
297+}
M internal/domain/event.go +12 -0
@@ -186,6 +186,18 @@ type IssueOpened struct {
186186 // EventKind implements Event.
187187 func (IssueOpened) EventKind() string { return "issue.opened" }
188188
189+// IssueReferenced refreshes an issue's merge request back-references.
190+type IssueReferenced struct {
191+ Repo RepoName
192+ Issue, MergeRequest int64
193+ At time.Time
194+ Visibility Visibility
195+ CreatedAt time.Time
196+}
197+
198+// EventKind implements Event.
199+func (IssueReferenced) EventKind() string { return "issue.referenced" }
200+
189201 // IssueClosed is published when an issue was closed.
190202 type IssueClosed struct {
191203 Issue Issue
@@ -186,6 +186,18 @@ type IssueOpened struct {
186 // EventKind implements Event.186 // EventKind implements Event.
187 func (IssueOpened) EventKind() string { return "issue.opened" }187 func (IssueOpened) EventKind() string { return "issue.opened" }
188 188
189+// IssueReferenced refreshes an issue's merge request back-references.
190+type IssueReferenced struct {
191+ Repo RepoName
192+ Issue, MergeRequest int64
193+ At time.Time
194+ Visibility Visibility
195+ CreatedAt time.Time
196+}
197+
198+// EventKind implements Event.
199+func (IssueReferenced) EventKind() string { return "issue.referenced" }
200+
189 // IssueClosed is published when an issue was closed.201 // IssueClosed is published when an issue was closed.
190 type IssueClosed struct {202 type IssueClosed struct {
191 Issue Issue203 Issue Issue
M internal/domain/issue.go +8 -0
@@ -73,11 +73,19 @@ type Issue struct {
7373 // External is true for an issue approved from the public form: its text
7474 // comes from an anonymous visitor and is rendered as untrusted.
7575 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
7678 // Version is set by the store on read: Update fails with ErrConflict when
7779 // the stored issue changed since. Opaque to everyone else.
7880 Version uint64
7981 }
8082
83+// IssueLink is a merge request naming an issue in the same repository.
84+type IssueLink struct {
85+ Issue, MergeRequest int64
86+ Closes bool
87+}
88+
8189 // IsOpen reports whether the issue is open.
8290 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
8391
@@ -73,11 +73,19 @@ type Issue struct {
73 // External is true for an issue approved from the public form: its text73 // External is true for an issue approved from the public form: its text
74 // comes from an anonymous visitor and is rendered as untrusted.74 // comes from an anonymous visitor and is rendered as untrusted.
75 External bool75 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
76 // Version is set by the store on read: Update fails with ErrConflict when78 // Version is set by the store on read: Update fails with ErrConflict when
77 // the stored issue changed since. Opaque to everyone else.79 // the stored issue changed since. Opaque to everyone else.
78 Version uint6480 Version uint64
79 }81 }
80 82
83+// IssueLink is a merge request naming an issue in the same repository.
84+type IssueLink struct {
85+ Issue, MergeRequest int64
86+ Closes bool
87+}
88+
81 // IsOpen reports whether the issue is open.89 // IsOpen reports whether the issue is open.
82 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }90 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
83 91
A internal/domain/reference.go +112 -0
@@ -0,0 +1,112 @@
1+package domain
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "strconv"
7+)
8+
9+// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
10+const MaxReferenceTextBytes = 16 << 10
11+
12+var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
13+var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`)
14+
15+// FindReferences returns the positive #N numbers in text, sorted and unique.
16+// Numbers glued to words, entities or another # are excluded, as are zero,
17+// leading zeroes and numbers longer than ten digits. Code is scanned as text;
18+// cross-repository references and !N are not supported.
19+func FindReferences(text string) []int64 {
20+ numbers := make(map[int64]struct{})
21+ // Resume at the end of the number, retaining its following delimiter for the
22+ // next match (for example, both references in "#1,#2").
23+ for offset := 0; offset < len(text); {
24+ match := numberRef.FindStringSubmatchIndex(text[offset:])
25+ if match == nil {
26+ break
27+ }
28+ start, end := offset+match[4], offset+match[5]
29+ // A sliced search must not manufacture a beginning-of-text boundary.
30+ if start < 2 || !referenceWord(text[start-2]) {
31+ n, _ := strconv.ParseInt(text[start:end], 10, 64)
32+ numbers[n] = struct{}{}
33+ }
34+ offset = end
35+ }
36+ return sortedReferences(numbers)
37+}
38+
39+func referenceWord(b byte) bool {
40+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
41+}
42+
43+// FindClosingReferences returns sorted, unique references following close,
44+// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
45+// insensitive). A colon is optional; lists use commas or "and", not spaces.
46+// Like FindReferences, this scans code spans and fences as ordinary text.
47+func FindClosingReferences(text string) []int64 {
48+ numbers := make(map[int64]struct{})
49+ for _, match := range closingRef.FindAllString(text, -1) {
50+ for _, n := range FindReferences(match) {
51+ numbers[n] = struct{}{}
52+ }
53+ }
54+ return sortedReferences(numbers)
55+}
56+
57+func sortedReferences(numbers map[int64]struct{}) []int64 {
58+ var result []int64
59+ for n := range numbers {
60+ result = append(result, n)
61+ }
62+ slices.Sort(result)
63+ return result
64+}
65+
66+// MergeRequestReferences describes the issues named by a merge request.
67+type MergeRequestReferences struct {
68+ Mentions []int64
69+ Closes []int64
70+}
71+
72+// ScanMergeRequest scans titles, descriptions, notes and commit messages for
73+// references. Notes can mention issues but cannot close them. Each individual
74+// text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or
75+// cross-repository interpretation is performed.
76+func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
77+ mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
78+ scan := func(text string, closing bool) {
79+ if len(text) > MaxReferenceTextBytes {
80+ // Drop the trailing partial token: back off to the last ASCII
81+ // whitespace at or before the limit.
82+ cut := -1
83+ for i := MaxReferenceTextBytes; i >= 0; i-- {
84+ if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') {
85+ cut = i
86+ break
87+ }
88+ }
89+ if cut < 0 {
90+ return
91+ }
92+ text = text[:cut]
93+ }
94+ for _, n := range FindReferences(text) {
95+ mentions[n] = struct{}{}
96+ }
97+ if closing {
98+ for _, n := range FindClosingReferences(text) {
99+ closes[n] = struct{}{}
100+ }
101+ }
102+ }
103+ scan(title, true)
104+ scan(description, true)
105+ for _, text := range notes {
106+ scan(text, false)
107+ }
108+ for _, text := range commitMessages {
109+ scan(text, true)
110+ }
111+ return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
112+}
@@ -0,0 +1,112 @@
1+package domain
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "strconv"
7+)
8+
9+// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
10+const MaxReferenceTextBytes = 16 << 10
11+
12+var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
13+var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`)
14+
15+// FindReferences returns the positive #N numbers in text, sorted and unique.
16+// Numbers glued to words, entities or another # are excluded, as are zero,
17+// leading zeroes and numbers longer than ten digits. Code is scanned as text;
18+// cross-repository references and !N are not supported.
19+func FindReferences(text string) []int64 {
20+ numbers := make(map[int64]struct{})
21+ // Resume at the end of the number, retaining its following delimiter for the
22+ // next match (for example, both references in "#1,#2").
23+ for offset := 0; offset < len(text); {
24+ match := numberRef.FindStringSubmatchIndex(text[offset:])
25+ if match == nil {
26+ break
27+ }
28+ start, end := offset+match[4], offset+match[5]
29+ // A sliced search must not manufacture a beginning-of-text boundary.
30+ if start < 2 || !referenceWord(text[start-2]) {
31+ n, _ := strconv.ParseInt(text[start:end], 10, 64)
32+ numbers[n] = struct{}{}
33+ }
34+ offset = end
35+ }
36+ return sortedReferences(numbers)
37+}
38+
39+func referenceWord(b byte) bool {
40+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
41+}
42+
43+// FindClosingReferences returns sorted, unique references following close,
44+// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
45+// insensitive). A colon is optional; lists use commas or "and", not spaces.
46+// Like FindReferences, this scans code spans and fences as ordinary text.
47+func FindClosingReferences(text string) []int64 {
48+ numbers := make(map[int64]struct{})
49+ for _, match := range closingRef.FindAllString(text, -1) {
50+ for _, n := range FindReferences(match) {
51+ numbers[n] = struct{}{}
52+ }
53+ }
54+ return sortedReferences(numbers)
55+}
56+
57+func sortedReferences(numbers map[int64]struct{}) []int64 {
58+ var result []int64
59+ for n := range numbers {
60+ result = append(result, n)
61+ }
62+ slices.Sort(result)
63+ return result
64+}
65+
66+// MergeRequestReferences describes the issues named by a merge request.
67+type MergeRequestReferences struct {
68+ Mentions []int64
69+ Closes []int64
70+}
71+
72+// ScanMergeRequest scans titles, descriptions, notes and commit messages for
73+// references. Notes can mention issues but cannot close them. Each individual
74+// text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or
75+// cross-repository interpretation is performed.
76+func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
77+ mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
78+ scan := func(text string, closing bool) {
79+ if len(text) > MaxReferenceTextBytes {
80+ // Drop the trailing partial token: back off to the last ASCII
81+ // whitespace at or before the limit.
82+ cut := -1
83+ for i := MaxReferenceTextBytes; i >= 0; i-- {
84+ if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') {
85+ cut = i
86+ break
87+ }
88+ }
89+ if cut < 0 {
90+ return
91+ }
92+ text = text[:cut]
93+ }
94+ for _, n := range FindReferences(text) {
95+ mentions[n] = struct{}{}
96+ }
97+ if closing {
98+ for _, n := range FindClosingReferences(text) {
99+ closes[n] = struct{}{}
100+ }
101+ }
102+ }
103+ scan(title, true)
104+ scan(description, true)
105+ for _, text := range notes {
106+ scan(text, false)
107+ }
108+ for _, text := range commitMessages {
109+ scan(text, true)
110+ }
111+ return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
112+}
A internal/domain/reference_test.go +78 -0
@@ -0,0 +1,78 @@
1+package domain
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestFindReferences(t *testing.T) {
10+ tests := []struct {
11+ text string
12+ mentions, closes []int64
13+ }{
14+ {"abc#3 a_b#3 1#3 &#39; ##3 #3abc #03 #0", nil, nil},
15+ {"(#3) see #3, \"#3\"", []int64{3}, nil},
16+ {"fixes #3.", []int64{3}, []int64{3}},
17+ {"Fixes: #3", []int64{3}, []int64{3}},
18+ {"fixes:#3", []int64{3}, []int64{3}},
19+ {"Closes #3", []int64{3}, []int64{3}},
20+ {"RESOLVED #3", []int64{3}, []int64{3}},
21+ {"fix #3", []int64{3}, []int64{3}},
22+ {"prefixes #3", []int64{3}, nil},
23+ {"fixes 3 fixes#3", nil, nil},
24+ {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}},
25+ {"fixes #3 #4", []int64{3, 4}, []int64{3}},
26+ {"#5,#3,#5", []int64{3, 5}, nil},
27+ {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil},
28+ {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}},
29+ {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.text, func(t *testing.T) {
33+ if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) {
34+ t.Errorf("mentions = %v, want %v", got, tt.mentions)
35+ }
36+ if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) {
37+ t.Errorf("closes = %v, want %v", got, tt.closes)
38+ }
39+ })
40+ }
41+}
42+
43+func TestScanMergeRequest(t *testing.T) {
44+ got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"})
45+ want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}}
46+ if !reflect.DeepEqual(got, want) {
47+ t.Fatalf("got %+v, want %+v", got, want)
48+ }
49+ for _, scan := range []func(string) MergeRequestReferences{
50+ func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) },
51+ func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) },
52+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) },
53+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) },
54+ } {
55+ if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 {
56+ t.Errorf("text beyond limit scanned: %+v", got)
57+ }
58+ }
59+}
60+
61+func TestScanMergeRequestTruncationDropsPartialToken(t *testing.T) {
62+ prefix := strings.Repeat(" ", MaxReferenceTextBytes-len("fixes #123"))
63+ got := ScanMergeRequest("", "", nil, []string{prefix + "fixes #12345"})
64+ if len(got.Mentions) != 0 || len(got.Closes) != 0 {
65+ t.Errorf("split number reported: %+v", got)
66+ }
67+ // Fully before the limit, followed by a split token.
68+ full := "fixes #7 " + strings.Repeat("a", MaxReferenceTextBytes)
69+ got = ScanMergeRequest("", "", nil, []string{full})
70+ if !reflect.DeepEqual(got, MergeRequestReferences{Mentions: []int64{7}, Closes: []int64{7}}) {
71+ t.Errorf("got %+v", got)
72+ }
73+ // No whitespace at all: nothing scanned.
74+ got = ScanMergeRequest("", "", nil, []string{strings.Repeat("a", MaxReferenceTextBytes-3) + " #1" + "2"})
75+ if len(got.Mentions) != 0 {
76+ t.Errorf("got %+v", got)
77+ }
78+}
@@ -0,0 +1,78 @@
1+package domain
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestFindReferences(t *testing.T) {
10+ tests := []struct {
11+ text string
12+ mentions, closes []int64
13+ }{
14+ {"abc#3 a_b#3 1#3 &#39; ##3 #3abc #03 #0", nil, nil},
15+ {"(#3) see #3, \"#3\"", []int64{3}, nil},
16+ {"fixes #3.", []int64{3}, []int64{3}},
17+ {"Fixes: #3", []int64{3}, []int64{3}},
18+ {"fixes:#3", []int64{3}, []int64{3}},
19+ {"Closes #3", []int64{3}, []int64{3}},
20+ {"RESOLVED #3", []int64{3}, []int64{3}},
21+ {"fix #3", []int64{3}, []int64{3}},
22+ {"prefixes #3", []int64{3}, nil},
23+ {"fixes 3 fixes#3", nil, nil},
24+ {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}},
25+ {"fixes #3 #4", []int64{3, 4}, []int64{3}},
26+ {"#5,#3,#5", []int64{3, 5}, nil},
27+ {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil},
28+ {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}},
29+ {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.text, func(t *testing.T) {
33+ if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) {
34+ t.Errorf("mentions = %v, want %v", got, tt.mentions)
35+ }
36+ if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) {
37+ t.Errorf("closes = %v, want %v", got, tt.closes)
38+ }
39+ })
40+ }
41+}
42+
43+func TestScanMergeRequest(t *testing.T) {
44+ got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"})
45+ want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}}
46+ if !reflect.DeepEqual(got, want) {
47+ t.Fatalf("got %+v, want %+v", got, want)
48+ }
49+ for _, scan := range []func(string) MergeRequestReferences{
50+ func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) },
51+ func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) },
52+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) },
53+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) },
54+ } {
55+ if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 {
56+ t.Errorf("text beyond limit scanned: %+v", got)
57+ }
58+ }
59+}
60+
61+func TestScanMergeRequestTruncationDropsPartialToken(t *testing.T) {
62+ prefix := strings.Repeat(" ", MaxReferenceTextBytes-len("fixes #123"))
63+ got := ScanMergeRequest("", "", nil, []string{prefix + "fixes #12345"})
64+ if len(got.Mentions) != 0 || len(got.Closes) != 0 {
65+ t.Errorf("split number reported: %+v", got)
66+ }
67+ // Fully before the limit, followed by a split token.
68+ full := "fixes #7 " + strings.Repeat("a", MaxReferenceTextBytes)
69+ got = ScanMergeRequest("", "", nil, []string{full})
70+ if !reflect.DeepEqual(got, MergeRequestReferences{Mentions: []int64{7}, Closes: []int64{7}}) {
71+ t.Errorf("got %+v", got)
72+ }
73+ // No whitespace at all: nothing scanned.
74+ got = ScanMergeRequest("", "", nil, []string{strings.Repeat("a", MaxReferenceTextBytes-3) + " #1" + "2"})
75+ if len(got.Mentions) != 0 {
76+ t.Errorf("got %+v", got)
77+ }
78+}
M internal/ports/dto.go +12 -2
@@ -242,9 +242,19 @@ type CreateIssueParams struct {
242242 Body string
243243 }
244244
245-// IssueDetail is an issue with its comments.
245+// IssueReference is a merge request of the same repository naming an issue.
246+type IssueReference struct {
247+ Number int64
248+ Title domain.MRTitle
249+ State domain.MRState
250+ Closes bool
251+}
252+
253+// IssueDetail is an issue with its comments and referencing merge requests.
246254 type IssueDetail struct {
247- Issue domain.Issue
255+ // References are ascending by number, at most 100.
256+ References []IssueReference
257+ Issue domain.Issue
248258 // Comments are oldest first.
249259 Comments []domain.IssueComment
250260 }
@@ -242,9 +242,19 @@ type CreateIssueParams struct {
242 Body string242 Body string
243 }243 }
244 244
245-// IssueDetail is an issue with its comments.245+// IssueReference is a merge request of the same repository naming an issue.
246+type IssueReference struct {
247+ Number int64
248+ Title domain.MRTitle
249+ State domain.MRState
250+ Closes bool
251+}
252+
253+// IssueDetail is an issue with its comments and referencing merge requests.
246 type IssueDetail struct {254 type IssueDetail struct {
247- Issue domain.Issue255+ // References are ascending by number, at most 100.
256+ References []IssueReference
257+ Issue domain.Issue
248 // Comments are oldest first.258 // Comments are oldest first.
249 Comments []domain.IssueComment259 Comments []domain.IssueComment
250 }260 }
A internal/ports/mocks/IssueLinkStore.go +296 -0
@@ -0,0 +1,296 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkStore is an autogenerated mock type for the IssueLinkStore type
38+type IssueLinkStore struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter {
47+ return &IssueLinkStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// LinksOfIssue provides a mock function for the type IssueLinkStore
51+func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
52+ ret := _mock.Called(ctx, repo, issue, limit)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for LinksOfIssue")
56+ }
57+
58+ var r0 []domain.IssueLink
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok {
61+ return returnFunc(ctx, repo, issue, limit)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok {
64+ r0 = returnFunc(ctx, repo, issue, limit)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.IssueLink)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok {
71+ r1 = returnFunc(ctx, repo, issue, limit)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue'
79+type IssueLinkStore_LinksOfIssue_Call struct {
80+ *mock.Call
81+}
82+
83+// LinksOfIssue is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - issue int64
87+// - limit int
88+func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call {
89+ return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)}
90+}
91+
92+func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 int64
103+ if args[2] != nil {
104+ arg2 = args[2].(int64)
105+ }
106+ var arg3 int
107+ if args[3] != nil {
108+ arg3 = args[3].(int)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call {
121+ _c.Call.Return(issueLinks, err)
122+ return _c
123+}
124+
125+func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// LinksOfMergeRequest provides a mock function for the type IssueLinkStore
131+func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
132+ ret := _mock.Called(ctx, repo, mr)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for LinksOfMergeRequest")
136+ }
137+
138+ var r0 []domain.IssueLink
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok {
141+ return returnFunc(ctx, repo, mr)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok {
144+ r0 = returnFunc(ctx, repo, mr)
145+ } else {
146+ if ret.Get(0) != nil {
147+ r0 = ret.Get(0).([]domain.IssueLink)
148+ }
149+ }
150+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
151+ r1 = returnFunc(ctx, repo, mr)
152+ } else {
153+ r1 = ret.Error(1)
154+ }
155+ return r0, r1
156+}
157+
158+// IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest'
159+type IssueLinkStore_LinksOfMergeRequest_Call struct {
160+ *mock.Call
161+}
162+
163+// LinksOfMergeRequest is a helper method to define mock.On call
164+// - ctx context.Context
165+// - repo domain.RepoName
166+// - mr int64
167+func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call {
168+ return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)}
169+}
170+
171+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ var arg0 context.Context
174+ if args[0] != nil {
175+ arg0 = args[0].(context.Context)
176+ }
177+ var arg1 domain.RepoName
178+ if args[1] != nil {
179+ arg1 = args[1].(domain.RepoName)
180+ }
181+ var arg2 int64
182+ if args[2] != nil {
183+ arg2 = args[2].(int64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ )
190+ })
191+ return _c
192+}
193+
194+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call {
195+ _c.Call.Return(issueLinks, err)
196+ return _c
197+}
198+
199+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
204+// ReplaceLinks provides a mock function for the type IssueLinkStore
205+func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) {
206+ ret := _mock.Called(ctx, repo, mr, issues, closes)
207+
208+ if len(ret) == 0 {
209+ panic("no return value specified for ReplaceLinks")
210+ }
211+
212+ var r0 []domain.IssueLink
213+ var r1 []domain.IssueLink
214+ var r2 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok {
216+ return returnFunc(ctx, repo, mr, issues, closes)
217+ }
218+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
219+ r0 = returnFunc(ctx, repo, mr, issues, closes)
220+ } else {
221+ if ret.Get(0) != nil {
222+ r0 = ret.Get(0).([]domain.IssueLink)
223+ }
224+ }
225+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
226+ r1 = returnFunc(ctx, repo, mr, issues, closes)
227+ } else {
228+ if ret.Get(1) != nil {
229+ r1 = ret.Get(1).([]domain.IssueLink)
230+ }
231+ }
232+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok {
233+ r2 = returnFunc(ctx, repo, mr, issues, closes)
234+ } else {
235+ r2 = ret.Error(2)
236+ }
237+ return r0, r1, r2
238+}
239+
240+// IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks'
241+type IssueLinkStore_ReplaceLinks_Call struct {
242+ *mock.Call
243+}
244+
245+// ReplaceLinks is a helper method to define mock.On call
246+// - ctx context.Context
247+// - repo domain.RepoName
248+// - mr int64
249+// - issues []int64
250+// - closes []int64
251+func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call {
252+ return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)}
253+}
254+
255+func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call {
256+ _c.Call.Run(func(args mock.Arguments) {
257+ var arg0 context.Context
258+ if args[0] != nil {
259+ arg0 = args[0].(context.Context)
260+ }
261+ var arg1 domain.RepoName
262+ if args[1] != nil {
263+ arg1 = args[1].(domain.RepoName)
264+ }
265+ var arg2 int64
266+ if args[2] != nil {
267+ arg2 = args[2].(int64)
268+ }
269+ var arg3 []int64
270+ if args[3] != nil {
271+ arg3 = args[3].([]int64)
272+ }
273+ var arg4 []int64
274+ if args[4] != nil {
275+ arg4 = args[4].([]int64)
276+ }
277+ run(
278+ arg0,
279+ arg1,
280+ arg2,
281+ arg3,
282+ arg4,
283+ )
284+ })
285+ return _c
286+}
287+
288+func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call {
289+ _c.Call.Return(added, removed, err)
290+ return _c
291+}
292+
293+func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call {
294+ _c.Call.Return(run)
295+ return _c
296+}
@@ -0,0 +1,296 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkStore is an autogenerated mock type for the IssueLinkStore type
38+type IssueLinkStore struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter {
47+ return &IssueLinkStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// LinksOfIssue provides a mock function for the type IssueLinkStore
51+func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
52+ ret := _mock.Called(ctx, repo, issue, limit)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for LinksOfIssue")
56+ }
57+
58+ var r0 []domain.IssueLink
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok {
61+ return returnFunc(ctx, repo, issue, limit)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok {
64+ r0 = returnFunc(ctx, repo, issue, limit)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.IssueLink)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok {
71+ r1 = returnFunc(ctx, repo, issue, limit)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue'
79+type IssueLinkStore_LinksOfIssue_Call struct {
80+ *mock.Call
81+}
82+
83+// LinksOfIssue is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - issue int64
87+// - limit int
88+func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call {
89+ return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)}
90+}
91+
92+func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 int64
103+ if args[2] != nil {
104+ arg2 = args[2].(int64)
105+ }
106+ var arg3 int
107+ if args[3] != nil {
108+ arg3 = args[3].(int)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call {
121+ _c.Call.Return(issueLinks, err)
122+ return _c
123+}
124+
125+func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// LinksOfMergeRequest provides a mock function for the type IssueLinkStore
131+func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
132+ ret := _mock.Called(ctx, repo, mr)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for LinksOfMergeRequest")
136+ }
137+
138+ var r0 []domain.IssueLink
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok {
141+ return returnFunc(ctx, repo, mr)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok {
144+ r0 = returnFunc(ctx, repo, mr)
145+ } else {
146+ if ret.Get(0) != nil {
147+ r0 = ret.Get(0).([]domain.IssueLink)
148+ }
149+ }
150+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
151+ r1 = returnFunc(ctx, repo, mr)
152+ } else {
153+ r1 = ret.Error(1)
154+ }
155+ return r0, r1
156+}
157+
158+// IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest'
159+type IssueLinkStore_LinksOfMergeRequest_Call struct {
160+ *mock.Call
161+}
162+
163+// LinksOfMergeRequest is a helper method to define mock.On call
164+// - ctx context.Context
165+// - repo domain.RepoName
166+// - mr int64
167+func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call {
168+ return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)}
169+}
170+
171+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ var arg0 context.Context
174+ if args[0] != nil {
175+ arg0 = args[0].(context.Context)
176+ }
177+ var arg1 domain.RepoName
178+ if args[1] != nil {
179+ arg1 = args[1].(domain.RepoName)
180+ }
181+ var arg2 int64
182+ if args[2] != nil {
183+ arg2 = args[2].(int64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ )
190+ })
191+ return _c
192+}
193+
194+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call {
195+ _c.Call.Return(issueLinks, err)
196+ return _c
197+}
198+
199+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
204+// ReplaceLinks provides a mock function for the type IssueLinkStore
205+func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) {
206+ ret := _mock.Called(ctx, repo, mr, issues, closes)
207+
208+ if len(ret) == 0 {
209+ panic("no return value specified for ReplaceLinks")
210+ }
211+
212+ var r0 []domain.IssueLink
213+ var r1 []domain.IssueLink
214+ var r2 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok {
216+ return returnFunc(ctx, repo, mr, issues, closes)
217+ }
218+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
219+ r0 = returnFunc(ctx, repo, mr, issues, closes)
220+ } else {
221+ if ret.Get(0) != nil {
222+ r0 = ret.Get(0).([]domain.IssueLink)
223+ }
224+ }
225+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
226+ r1 = returnFunc(ctx, repo, mr, issues, closes)
227+ } else {
228+ if ret.Get(1) != nil {
229+ r1 = ret.Get(1).([]domain.IssueLink)
230+ }
231+ }
232+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok {
233+ r2 = returnFunc(ctx, repo, mr, issues, closes)
234+ } else {
235+ r2 = ret.Error(2)
236+ }
237+ return r0, r1, r2
238+}
239+
240+// IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks'
241+type IssueLinkStore_ReplaceLinks_Call struct {
242+ *mock.Call
243+}
244+
245+// ReplaceLinks is a helper method to define mock.On call
246+// - ctx context.Context
247+// - repo domain.RepoName
248+// - mr int64
249+// - issues []int64
250+// - closes []int64
251+func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call {
252+ return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)}
253+}
254+
255+func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call {
256+ _c.Call.Run(func(args mock.Arguments) {
257+ var arg0 context.Context
258+ if args[0] != nil {
259+ arg0 = args[0].(context.Context)
260+ }
261+ var arg1 domain.RepoName
262+ if args[1] != nil {
263+ arg1 = args[1].(domain.RepoName)
264+ }
265+ var arg2 int64
266+ if args[2] != nil {
267+ arg2 = args[2].(int64)
268+ }
269+ var arg3 []int64
270+ if args[3] != nil {
271+ arg3 = args[3].([]int64)
272+ }
273+ var arg4 []int64
274+ if args[4] != nil {
275+ arg4 = args[4].([]int64)
276+ }
277+ run(
278+ arg0,
279+ arg1,
280+ arg2,
281+ arg3,
282+ arg4,
283+ )
284+ })
285+ return _c
286+}
287+
288+func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call {
289+ _c.Call.Return(added, removed, err)
290+ return _c
291+}
292+
293+func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call {
294+ _c.Call.Return(run)
295+ return _c
296+}
A internal/ports/mocks/IssueLinkTrigger.go +262 -0
@@ -0,0 +1,262 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type
38+type IssueLinkTrigger struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter {
47+ return &IssueLinkTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type IssueLinkTrigger
51+func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) {
52+ _mock.Called(context1, repoName, refUpdates)
53+ return
54+}
55+
56+// IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type IssueLinkTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - context1 context.Context
63+// - repoName domain.RepoName
64+// - refUpdates []domain.RefUpdate
65+func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call {
66+ return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)}
67+}
68+
69+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger
103+func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) {
104+ _mock.Called(context1, repo, n)
105+ return
106+}
107+
108+// IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged'
109+type IssueLinkTrigger_OnMergeRequestChanged_Call struct {
110+ *mock.Call
111+}
112+
113+// OnMergeRequestChanged is a helper method to define mock.On call
114+// - context1 context.Context
115+// - repo domain.Repo
116+// - n int64
117+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call {
118+ return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)}
119+}
120+
121+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ var arg0 context.Context
124+ if args[0] != nil {
125+ arg0 = args[0].(context.Context)
126+ }
127+ var arg1 domain.Repo
128+ if args[1] != nil {
129+ arg1 = args[1].(domain.Repo)
130+ }
131+ var arg2 int64
132+ if args[2] != nil {
133+ arg2 = args[2].(int64)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ arg2,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call {
145+ _c.Call.Return()
146+ return _c
147+}
148+
149+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
150+ _c.Run(run)
151+ return _c
152+}
153+
154+// OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger
155+func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) {
156+ _mock.Called(context1, repo, mergeRequest)
157+ return
158+}
159+
160+// IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled'
161+type IssueLinkTrigger_OnMergeRequestSettled_Call struct {
162+ *mock.Call
163+}
164+
165+// OnMergeRequestSettled is a helper method to define mock.On call
166+// - context1 context.Context
167+// - repo domain.Repo
168+// - mergeRequest domain.MergeRequest
169+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call {
170+ return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)}
171+}
172+
173+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 context.Context
176+ if args[0] != nil {
177+ arg0 = args[0].(context.Context)
178+ }
179+ var arg1 domain.Repo
180+ if args[1] != nil {
181+ arg1 = args[1].(domain.Repo)
182+ }
183+ var arg2 domain.MergeRequest
184+ if args[2] != nil {
185+ arg2 = args[2].(domain.MergeRequest)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call {
197+ _c.Call.Return()
198+ return _c
199+}
200+
201+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
202+ _c.Run(run)
203+ return _c
204+}
205+
206+// OnMerged provides a mock function for the type IssueLinkTrigger
207+func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) {
208+ _mock.Called(context1, principal, repo, mergeRequest)
209+ return
210+}
211+
212+// IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged'
213+type IssueLinkTrigger_OnMerged_Call struct {
214+ *mock.Call
215+}
216+
217+// OnMerged is a helper method to define mock.On call
218+// - context1 context.Context
219+// - principal domain.Principal
220+// - repo domain.Repo
221+// - mergeRequest domain.MergeRequest
222+func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call {
223+ return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)}
224+}
225+
226+func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
227+ _c.Call.Run(func(args mock.Arguments) {
228+ var arg0 context.Context
229+ if args[0] != nil {
230+ arg0 = args[0].(context.Context)
231+ }
232+ var arg1 domain.Principal
233+ if args[1] != nil {
234+ arg1 = args[1].(domain.Principal)
235+ }
236+ var arg2 domain.Repo
237+ if args[2] != nil {
238+ arg2 = args[2].(domain.Repo)
239+ }
240+ var arg3 domain.MergeRequest
241+ if args[3] != nil {
242+ arg3 = args[3].(domain.MergeRequest)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ arg2,
248+ arg3,
249+ )
250+ })
251+ return _c
252+}
253+
254+func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call {
255+ _c.Call.Return()
256+ return _c
257+}
258+
259+func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
260+ _c.Run(run)
261+ return _c
262+}
@@ -0,0 +1,262 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type
38+type IssueLinkTrigger struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter {
47+ return &IssueLinkTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type IssueLinkTrigger
51+func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) {
52+ _mock.Called(context1, repoName, refUpdates)
53+ return
54+}
55+
56+// IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type IssueLinkTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - context1 context.Context
63+// - repoName domain.RepoName
64+// - refUpdates []domain.RefUpdate
65+func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call {
66+ return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)}
67+}
68+
69+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger
103+func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) {
104+ _mock.Called(context1, repo, n)
105+ return
106+}
107+
108+// IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged'
109+type IssueLinkTrigger_OnMergeRequestChanged_Call struct {
110+ *mock.Call
111+}
112+
113+// OnMergeRequestChanged is a helper method to define mock.On call
114+// - context1 context.Context
115+// - repo domain.Repo
116+// - n int64
117+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call {
118+ return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)}
119+}
120+
121+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ var arg0 context.Context
124+ if args[0] != nil {
125+ arg0 = args[0].(context.Context)
126+ }
127+ var arg1 domain.Repo
128+ if args[1] != nil {
129+ arg1 = args[1].(domain.Repo)
130+ }
131+ var arg2 int64
132+ if args[2] != nil {
133+ arg2 = args[2].(int64)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ arg2,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call {
145+ _c.Call.Return()
146+ return _c
147+}
148+
149+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
150+ _c.Run(run)
151+ return _c
152+}
153+
154+// OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger
155+func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) {
156+ _mock.Called(context1, repo, mergeRequest)
157+ return
158+}
159+
160+// IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled'
161+type IssueLinkTrigger_OnMergeRequestSettled_Call struct {
162+ *mock.Call
163+}
164+
165+// OnMergeRequestSettled is a helper method to define mock.On call
166+// - context1 context.Context
167+// - repo domain.Repo
168+// - mergeRequest domain.MergeRequest
169+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call {
170+ return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)}
171+}
172+
173+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 context.Context
176+ if args[0] != nil {
177+ arg0 = args[0].(context.Context)
178+ }
179+ var arg1 domain.Repo
180+ if args[1] != nil {
181+ arg1 = args[1].(domain.Repo)
182+ }
183+ var arg2 domain.MergeRequest
184+ if args[2] != nil {
185+ arg2 = args[2].(domain.MergeRequest)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call {
197+ _c.Call.Return()
198+ return _c
199+}
200+
201+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
202+ _c.Run(run)
203+ return _c
204+}
205+
206+// OnMerged provides a mock function for the type IssueLinkTrigger
207+func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) {
208+ _mock.Called(context1, principal, repo, mergeRequest)
209+ return
210+}
211+
212+// IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged'
213+type IssueLinkTrigger_OnMerged_Call struct {
214+ *mock.Call
215+}
216+
217+// OnMerged is a helper method to define mock.On call
218+// - context1 context.Context
219+// - principal domain.Principal
220+// - repo domain.Repo
221+// - mergeRequest domain.MergeRequest
222+func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call {
223+ return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)}
224+}
225+
226+func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
227+ _c.Call.Run(func(args mock.Arguments) {
228+ var arg0 context.Context
229+ if args[0] != nil {
230+ arg0 = args[0].(context.Context)
231+ }
232+ var arg1 domain.Principal
233+ if args[1] != nil {
234+ arg1 = args[1].(domain.Principal)
235+ }
236+ var arg2 domain.Repo
237+ if args[2] != nil {
238+ arg2 = args[2].(domain.Repo)
239+ }
240+ var arg3 domain.MergeRequest
241+ if args[3] != nil {
242+ arg3 = args[3].(domain.MergeRequest)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ arg2,
248+ arg3,
249+ )
250+ })
251+ return _c
252+}
253+
254+func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call {
255+ _c.Call.Return()
256+ return _c
257+}
258+
259+func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
260+ _c.Run(run)
261+ return _c
262+}
M internal/ports/ports.go +20 -0
@@ -536,7 +536,27 @@ type EventBus interface {
536536 Subscribe(ctx context.Context) <-chan domain.Event
537537 }
538538
539+// IssueLinkStore keeps which merge requests name which issues.
540+type IssueLinkStore interface {
541+ // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues).
542+ // Added includes new links and links whose closes flag turned on.
543+ // Returns ErrNotFound for a missing repository, ErrConflict on a race.
544+ ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error)
545+ // LinksOfIssue returns links ascending by MR number, at most limit.
546+ LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)
547+ // LinksOfMergeRequest returns the issues named by an MR.
548+ LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)
549+}
550+
539551 // Clock tells the time.
540552 type Clock interface {
541553 Now() time.Time
542554 }
555+
556+// IssueLinkTrigger updates derived links without failing the primary operation.
557+type IssueLinkTrigger interface {
558+ OnMergeRequestChanged(context.Context, domain.Repo, int64)
559+ OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest)
560+ OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate)
561+ OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest)
562+}
@@ -536,7 +536,27 @@ type EventBus interface {
536 Subscribe(ctx context.Context) <-chan domain.Event536 Subscribe(ctx context.Context) <-chan domain.Event
537 }537 }
538 538
539+// IssueLinkStore keeps which merge requests name which issues.
540+type IssueLinkStore interface {
541+ // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues).
542+ // Added includes new links and links whose closes flag turned on.
543+ // Returns ErrNotFound for a missing repository, ErrConflict on a race.
544+ ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error)
545+ // LinksOfIssue returns links ascending by MR number, at most limit.
546+ LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)
547+ // LinksOfMergeRequest returns the issues named by an MR.
548+ LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)
549+}
550+
539 // Clock tells the time.551 // Clock tells the time.
540 type Clock interface {552 type Clock interface {
541 Now() time.Time553 Now() time.Time
542 }554 }
555+
556+// IssueLinkTrigger updates derived links without failing the primary operation.
557+type IssueLinkTrigger interface {
558+ OnMergeRequestChanged(context.Context, domain.Repo, int64)
559+ OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest)
560+ OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate)
561+ OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest)
562+}
M internal/ports/services.go +2 -1
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203203 // implements it.
204204 type IssueNumberResolver interface {
205205 // Kind needs the access of reading the repository and answers
206- // domain.ErrNotFound otherwise, also when issues are off.
206+ // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
207+ // requests are resolved even when issues are off.
207208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208209 }
209210
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203 // implements it.203 // implements it.
204 type IssueNumberResolver interface {204 type IssueNumberResolver interface {
205 // Kind needs the access of reading the repository and answers205 // Kind needs the access of reading the repository and answers
206- // domain.ErrNotFound otherwise, also when issues are off.206+ // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
207+ // requests are resolved even when issues are off.
207 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208 }209 }
209 210
M internal/usecase/events.go +7 -1
@@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
186186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187187 case domain.CommentAdded:
188188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189191 case domain.IssueOpened:
190192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538540 case domain.CommentAdded:
539541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.IssueReferenced:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540544 case domain.IssueOpened:
541545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637641 // isIssueEvent reports whether ev is about an issue.
638642 func isIssueEvent(ev domain.Event) bool {
639643 switch ev.(type) {
640- case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
644+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
641645 return true
642646 }
643647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656660 // isItemEvent reports whether ev is about a merge request or an issue.
657661 func isItemEvent(ev domain.Event) bool {
658662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
@@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
186 return repoSnapshot(ev.Visibility, ev.CreatedAt)186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187 case domain.CommentAdded:187 case domain.CommentAdded:
188 return repoSnapshot(ev.Visibility, ev.CreatedAt)188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189 case domain.IssueOpened:191 case domain.IssueOpened:
190 return repoSnapshot(ev.Visibility, ev.CreatedAt)192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191 case domain.IssueClosed:193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538 case domain.CommentAdded:540 case domain.CommentAdded:
539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.IssueReferenced:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540 case domain.IssueOpened:544 case domain.IssueOpened:
541 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542 case domain.IssueClosed:546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637 // isIssueEvent reports whether ev is about an issue.641 // isIssueEvent reports whether ev is about an issue.
638 func isIssueEvent(ev domain.Event) bool {642 func isIssueEvent(ev domain.Event) bool {
639 switch ev.(type) {643 switch ev.(type) {
640- case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:644+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
641 return true645 return true
642 }646 }
643 return false647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656 // isItemEvent reports whether ev is about a merge request or an issue.660 // isItemEvent reports whether ev is about a merge request or an issue.
657 func isItemEvent(ev domain.Event) bool {661 func isItemEvent(ev domain.Event) bool {
658 switch ev.(type) {662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
M internal/usecase/events_test.go +24 -0
@@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
10101010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
10111011 })
10121012 }
1013+
1014+func TestIssueReferencedRepoOnly(t *testing.T) {
1015+ synctest.Test(t, func(t *testing.T) {
1016+ r := newEventRig(t, 16)
1017+ n := name(t, "linked")
1018+ settings := domain.DefaultRepoSettings()
1019+ settings.Issues = domain.IssuesOwner
1020+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe()
1021+ ctx, cancel := context.WithCancel(context.Background())
1022+ defer cancel()
1023+ repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}})
1024+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
1025+ synctest.Wait()
1026+ ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
1027+ r.feed <- ev
1028+ assert.Equal(t, ev, repo.next(t).Event)
1029+ synctest.Wait()
1030+ select {
1031+ case got := <-owner.got:
1032+ t.Fatalf("owner received %+v", got)
1033+ default:
1034+ }
1035+ })
1036+}
@@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)1010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011 })1011 })
1012 }1012 }
1013+
1014+func TestIssueReferencedRepoOnly(t *testing.T) {
1015+ synctest.Test(t, func(t *testing.T) {
1016+ r := newEventRig(t, 16)
1017+ n := name(t, "linked")
1018+ settings := domain.DefaultRepoSettings()
1019+ settings.Issues = domain.IssuesOwner
1020+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe()
1021+ ctx, cancel := context.WithCancel(context.Background())
1022+ defer cancel()
1023+ repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}})
1024+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
1025+ synctest.Wait()
1026+ ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
1027+ r.feed <- ev
1028+ assert.Equal(t, ev, repo.next(t).Event)
1029+ synctest.Wait()
1030+ select {
1031+ case got := <-owner.got:
1032+ t.Fatalf("owner received %+v", got)
1033+ default:
1034+ }
1035+ })
1036+}
A internal/usecase/issue_links.go +286 -0
@@ -0,0 +1,286 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "github.com/bnema/zerowrap"
16+)
17+
18+const MaxIssueLinkCommits = ports.MaxMergeRequestCommits
19+const MaxIssueLinksPerIssue = 100
20+const maxLinkedMRsPerPush = 50
21+
22+type IssueLinksConfig struct {
23+ Meta ports.RepoMetaStore
24+ Issues ports.IssueStore
25+ MRs ports.MergeRequestStore
26+ Links ports.IssueLinkStore
27+ Git ports.MergeGit
28+ Bus ports.EventBus
29+ Clock ports.Clock
30+}
31+
32+// IssueLinks maintains the derived index and applies closing references on merge.
33+type IssueLinks struct {
34+ cfg IssueLinksConfig
35+ mu sync.Mutex
36+ locks map[linkKey]*linkLock
37+}
38+
39+type linkKey struct {
40+ repo domain.RepoName
41+ number int64
42+}
43+
44+type linkLock struct {
45+ ch chan struct{}
46+ refs int
47+}
48+
49+// lockMR serializes reindexes of one merge request, waiting until ctx ends.
50+// Entries are removed when no one holds or waits for them.
51+func (l *IssueLinks) lockMR(ctx context.Context, repo domain.RepoName, number int64) (func(), error) {
52+ key := linkKey{repo, number}
53+ l.mu.Lock()
54+ e, ok := l.locks[key]
55+ if !ok {
56+ e = &linkLock{ch: make(chan struct{}, 1)}
57+ l.locks[key] = e
58+ }
59+ e.refs++
60+ l.mu.Unlock()
61+ release := func() {
62+ l.mu.Lock()
63+ if e.refs--; e.refs == 0 {
64+ delete(l.locks, key)
65+ }
66+ l.mu.Unlock()
67+ }
68+ select {
69+ case e.ch <- struct{}{}:
70+ return func() { <-e.ch; release() }, nil
71+ case <-ctx.Done():
72+ release()
73+ return nil, ctx.Err()
74+ }
75+}
76+
77+var _ ports.IssueLinkTrigger = (*IssueLinks)(nil)
78+
79+func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks {
80+ return &IssueLinks{cfg: cfg, locks: map[linkKey]*linkLock{}}
81+}
82+
83+// Reindex replaces links from the current texts and bounded commit range.
84+func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error {
85+ _, err := l.reindex(ctx, repo, number)
86+ return err
87+}
88+func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) {
89+ unlock, err := l.lockMR(ctx, repo.Name, number)
90+ if err != nil {
91+ return nil, err
92+ }
93+ defer unlock()
94+ mr, err := l.cfg.MRs.Get(ctx, repo.Name, number)
95+ if err != nil {
96+ return nil, err
97+ }
98+ notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number)
99+ if err != nil {
100+ return nil, err
101+ }
102+ var texts, messages []string
103+ for _, note := range notes {
104+ if !note.Deleted {
105+ texts = append(texts, note.Text.String())
106+ }
107+ }
108+ if mr.IsOpen() {
109+ mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source)
110+ if errors.Is(err, domain.ErrNotFound) {
111+ mr.HeadID = domain.ObjectID{}
112+ } else if err != nil {
113+ return nil, err
114+ }
115+ }
116+ exclude, _, err := mrRange(ctx, l.cfg.Git, mr)
117+ if err != nil {
118+ return nil, err
119+ }
120+ has := !mr.HeadID.IsZero()
121+ if has && !mr.IsOpen() {
122+ has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID)
123+ if err != nil {
124+ return nil, err
125+ }
126+ }
127+ if has {
128+ commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits)
129+ if err != nil {
130+ return nil, err
131+ }
132+ if more {
133+ log := zerowrap.FromCtx(ctx)
134+ log.Debug().Msg("issue link commit range truncated")
135+ }
136+ for _, commit := range commits {
137+ messages = append(messages, commit.Message)
138+ }
139+ }
140+ refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages)
141+ var mentions, closes []int64
142+ for _, n := range refs.Mentions {
143+ _, err := l.cfg.Issues.Get(ctx, repo.Name, n)
144+ if errors.Is(err, domain.ErrNotFound) {
145+ continue
146+ }
147+ if err != nil {
148+ return nil, err
149+ }
150+ mentions = append(mentions, n)
151+ if slices.Contains(refs.Closes, n) {
152+ closes = append(closes, n)
153+ }
154+ }
155+ for range maxWriteRetries {
156+ added, removed, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes)
157+ if errors.Is(err, domain.ErrConflict) {
158+ continue
159+ }
160+ if err != nil {
161+ return nil, err
162+ }
163+ type pair struct{ issue, mr int64 }
164+ sent := map[pair]struct{}{}
165+ for _, link := range slices.Concat(added, removed) {
166+ p := pair{link.Issue, link.MergeRequest}
167+ if _, ok := sent[p]; ok {
168+ continue
169+ }
170+ sent[p] = struct{}{}
171+ l.notify(ctx, repo, link)
172+ }
173+ return closes, nil
174+ }
175+ return nil, domain.ErrConflict
176+}
177+func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) {
178+ l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
179+}
180+func linkContext(ctx context.Context) (context.Context, context.CancelFunc) {
181+ return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
182+}
183+func linkWarning(ctx context.Context, err error) {
184+ if err != nil {
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Msg("issue links not updated")
187+ }
188+}
189+func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) {
190+ ctx, cancel := linkContext(ctx)
191+ defer cancel()
192+ linkWarning(ctx, l.Reindex(ctx, repo, number))
193+}
194+func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
195+ links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number)
196+ linkWarning(ctx, err)
197+ if err == nil {
198+ for _, link := range links {
199+ l.notify(ctx, repo, link)
200+ }
201+ }
202+}
203+func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
204+ ctx, cancel := linkContext(ctx)
205+ defer cancel()
206+ l.settled(ctx, repo, mr)
207+}
208+func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
209+ ctx, cancel := linkContext(ctx)
210+ defer cancel()
211+ var branches []string
212+ for _, up := range updates {
213+ if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() {
214+ branches = append(branches, b)
215+ }
216+ }
217+ if len(branches) == 0 {
218+ return
219+ }
220+ repo, err := l.cfg.Meta.Get(ctx, name)
221+ if err != nil {
222+ linkWarning(ctx, err)
223+ return
224+ }
225+ numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush)
226+ if err != nil {
227+ linkWarning(ctx, err)
228+ return
229+ }
230+ if truncated {
231+ log := zerowrap.FromCtx(ctx)
232+ log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated")
233+ }
234+ for _, n := range numbers {
235+ linkWarning(ctx, l.Reindex(ctx, repo, n))
236+ }
237+}
238+func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) {
239+ ctx, cancel := linkContext(ctx)
240+ defer cancel()
241+ defer l.settled(ctx, repo, mr)
242+ closes, err := l.reindex(ctx, repo, mr.Number)
243+ if err != nil {
244+ linkWarning(ctx, err)
245+ return
246+ }
247+ if !repo.Settings.IssuesEnabled() {
248+ log := zerowrap.FromCtx(ctx)
249+ log.Debug().Msg("merge issue closing disabled")
250+ return
251+ }
252+ for _, n := range closes {
253+ err := l.close(ctx, p, repo, mr.Number, n)
254+ if err != nil {
255+ log := zerowrap.FromCtx(ctx)
256+ log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge")
257+ }
258+ }
259+}
260+func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error {
261+ for range maxWriteRetries {
262+ issue, err := l.cfg.Issues.Get(ctx, repo.Name, n)
263+ if err != nil {
264+ return err
265+ }
266+ if !issue.IsOpen() {
267+ return nil
268+ }
269+ issue.State = domain.IssueStateClosed
270+ issue.ClosedByMergeRequest = mr
271+ issue.UpdatedAt = l.cfg.Clock.Now().UTC()
272+ err = l.cfg.Issues.Update(ctx, issue)
273+ if errors.Is(err, domain.ErrConflict) {
274+ continue
275+ }
276+ if err != nil {
277+ return err
278+ }
279+ recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt)
280+ log := zerowrap.FromCtx(ctx)
281+ log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge")
282+ l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
283+ return nil
284+ }
285+ return domain.ErrConflict
286+}
@@ -0,0 +1,286 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "github.com/bnema/zerowrap"
16+)
17+
18+const MaxIssueLinkCommits = ports.MaxMergeRequestCommits
19+const MaxIssueLinksPerIssue = 100
20+const maxLinkedMRsPerPush = 50
21+
22+type IssueLinksConfig struct {
23+ Meta ports.RepoMetaStore
24+ Issues ports.IssueStore
25+ MRs ports.MergeRequestStore
26+ Links ports.IssueLinkStore
27+ Git ports.MergeGit
28+ Bus ports.EventBus
29+ Clock ports.Clock
30+}
31+
32+// IssueLinks maintains the derived index and applies closing references on merge.
33+type IssueLinks struct {
34+ cfg IssueLinksConfig
35+ mu sync.Mutex
36+ locks map[linkKey]*linkLock
37+}
38+
39+type linkKey struct {
40+ repo domain.RepoName
41+ number int64
42+}
43+
44+type linkLock struct {
45+ ch chan struct{}
46+ refs int
47+}
48+
49+// lockMR serializes reindexes of one merge request, waiting until ctx ends.
50+// Entries are removed when no one holds or waits for them.
51+func (l *IssueLinks) lockMR(ctx context.Context, repo domain.RepoName, number int64) (func(), error) {
52+ key := linkKey{repo, number}
53+ l.mu.Lock()
54+ e, ok := l.locks[key]
55+ if !ok {
56+ e = &linkLock{ch: make(chan struct{}, 1)}
57+ l.locks[key] = e
58+ }
59+ e.refs++
60+ l.mu.Unlock()
61+ release := func() {
62+ l.mu.Lock()
63+ if e.refs--; e.refs == 0 {
64+ delete(l.locks, key)
65+ }
66+ l.mu.Unlock()
67+ }
68+ select {
69+ case e.ch <- struct{}{}:
70+ return func() { <-e.ch; release() }, nil
71+ case <-ctx.Done():
72+ release()
73+ return nil, ctx.Err()
74+ }
75+}
76+
77+var _ ports.IssueLinkTrigger = (*IssueLinks)(nil)
78+
79+func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks {
80+ return &IssueLinks{cfg: cfg, locks: map[linkKey]*linkLock{}}
81+}
82+
83+// Reindex replaces links from the current texts and bounded commit range.
84+func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error {
85+ _, err := l.reindex(ctx, repo, number)
86+ return err
87+}
88+func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) {
89+ unlock, err := l.lockMR(ctx, repo.Name, number)
90+ if err != nil {
91+ return nil, err
92+ }
93+ defer unlock()
94+ mr, err := l.cfg.MRs.Get(ctx, repo.Name, number)
95+ if err != nil {
96+ return nil, err
97+ }
98+ notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number)
99+ if err != nil {
100+ return nil, err
101+ }
102+ var texts, messages []string
103+ for _, note := range notes {
104+ if !note.Deleted {
105+ texts = append(texts, note.Text.String())
106+ }
107+ }
108+ if mr.IsOpen() {
109+ mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source)
110+ if errors.Is(err, domain.ErrNotFound) {
111+ mr.HeadID = domain.ObjectID{}
112+ } else if err != nil {
113+ return nil, err
114+ }
115+ }
116+ exclude, _, err := mrRange(ctx, l.cfg.Git, mr)
117+ if err != nil {
118+ return nil, err
119+ }
120+ has := !mr.HeadID.IsZero()
121+ if has && !mr.IsOpen() {
122+ has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID)
123+ if err != nil {
124+ return nil, err
125+ }
126+ }
127+ if has {
128+ commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits)
129+ if err != nil {
130+ return nil, err
131+ }
132+ if more {
133+ log := zerowrap.FromCtx(ctx)
134+ log.Debug().Msg("issue link commit range truncated")
135+ }
136+ for _, commit := range commits {
137+ messages = append(messages, commit.Message)
138+ }
139+ }
140+ refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages)
141+ var mentions, closes []int64
142+ for _, n := range refs.Mentions {
143+ _, err := l.cfg.Issues.Get(ctx, repo.Name, n)
144+ if errors.Is(err, domain.ErrNotFound) {
145+ continue
146+ }
147+ if err != nil {
148+ return nil, err
149+ }
150+ mentions = append(mentions, n)
151+ if slices.Contains(refs.Closes, n) {
152+ closes = append(closes, n)
153+ }
154+ }
155+ for range maxWriteRetries {
156+ added, removed, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes)
157+ if errors.Is(err, domain.ErrConflict) {
158+ continue
159+ }
160+ if err != nil {
161+ return nil, err
162+ }
163+ type pair struct{ issue, mr int64 }
164+ sent := map[pair]struct{}{}
165+ for _, link := range slices.Concat(added, removed) {
166+ p := pair{link.Issue, link.MergeRequest}
167+ if _, ok := sent[p]; ok {
168+ continue
169+ }
170+ sent[p] = struct{}{}
171+ l.notify(ctx, repo, link)
172+ }
173+ return closes, nil
174+ }
175+ return nil, domain.ErrConflict
176+}
177+func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) {
178+ l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
179+}
180+func linkContext(ctx context.Context) (context.Context, context.CancelFunc) {
181+ return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
182+}
183+func linkWarning(ctx context.Context, err error) {
184+ if err != nil {
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Msg("issue links not updated")
187+ }
188+}
189+func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) {
190+ ctx, cancel := linkContext(ctx)
191+ defer cancel()
192+ linkWarning(ctx, l.Reindex(ctx, repo, number))
193+}
194+func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
195+ links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number)
196+ linkWarning(ctx, err)
197+ if err == nil {
198+ for _, link := range links {
199+ l.notify(ctx, repo, link)
200+ }
201+ }
202+}
203+func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
204+ ctx, cancel := linkContext(ctx)
205+ defer cancel()
206+ l.settled(ctx, repo, mr)
207+}
208+func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
209+ ctx, cancel := linkContext(ctx)
210+ defer cancel()
211+ var branches []string
212+ for _, up := range updates {
213+ if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() {
214+ branches = append(branches, b)
215+ }
216+ }
217+ if len(branches) == 0 {
218+ return
219+ }
220+ repo, err := l.cfg.Meta.Get(ctx, name)
221+ if err != nil {
222+ linkWarning(ctx, err)
223+ return
224+ }
225+ numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush)
226+ if err != nil {
227+ linkWarning(ctx, err)
228+ return
229+ }
230+ if truncated {
231+ log := zerowrap.FromCtx(ctx)
232+ log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated")
233+ }
234+ for _, n := range numbers {
235+ linkWarning(ctx, l.Reindex(ctx, repo, n))
236+ }
237+}
238+func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) {
239+ ctx, cancel := linkContext(ctx)
240+ defer cancel()
241+ defer l.settled(ctx, repo, mr)
242+ closes, err := l.reindex(ctx, repo, mr.Number)
243+ if err != nil {
244+ linkWarning(ctx, err)
245+ return
246+ }
247+ if !repo.Settings.IssuesEnabled() {
248+ log := zerowrap.FromCtx(ctx)
249+ log.Debug().Msg("merge issue closing disabled")
250+ return
251+ }
252+ for _, n := range closes {
253+ err := l.close(ctx, p, repo, mr.Number, n)
254+ if err != nil {
255+ log := zerowrap.FromCtx(ctx)
256+ log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge")
257+ }
258+ }
259+}
260+func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error {
261+ for range maxWriteRetries {
262+ issue, err := l.cfg.Issues.Get(ctx, repo.Name, n)
263+ if err != nil {
264+ return err
265+ }
266+ if !issue.IsOpen() {
267+ return nil
268+ }
269+ issue.State = domain.IssueStateClosed
270+ issue.ClosedByMergeRequest = mr
271+ issue.UpdatedAt = l.cfg.Clock.Now().UTC()
272+ err = l.cfg.Issues.Update(ctx, issue)
273+ if errors.Is(err, domain.ErrConflict) {
274+ continue
275+ }
276+ if err != nil {
277+ return err
278+ }
279+ recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt)
280+ log := zerowrap.FromCtx(ctx)
281+ log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge")
282+ l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
283+ return nil
284+ }
285+ return domain.ErrConflict
286+}
A internal/usecase/issue_links_test.go +235 -0
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/usecase"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
20+ t.Helper()
21+ r := newMRRig(t)
22+ r.seed()
23+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
24+ require.NoError(t, err)
25+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
26+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links})
27+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
28+ return r, links, issues
29+}
30+
31+func TestIssueLinksTextsPushAndReferences(t *testing.T) {
32+ r, links, issues := linkedRig(t)
33+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
34+ require.NoError(t, err)
35+ r.feature("feat")
36+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
37+ require.NoError(t, err)
38+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
39+ require.NoError(t, err)
40+ require.Len(t, got.References, 1)
41+ assert.Equal(t, mr.Title, got.References[0].Title)
42+ other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
43+ require.NoError(t, err)
44+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)})
45+ require.NoError(t, err)
46+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
47+ require.NoError(t, err)
48+ require.Len(t, got.References, 1)
49+ assert.False(t, got.References[0].Closes)
50+ indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
51+ require.NoError(t, err)
52+ require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
53+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
54+ require.NoError(t, err)
55+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
56+ require.NoError(t, err)
57+ assert.Empty(t, got.References)
58+ head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
59+ r.push("feat")
60+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
61+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
62+ require.NoError(t, err)
63+ require.Len(t, got.References, 1)
64+ assert.True(t, got.References[0].Closes)
65+ stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
66+ require.NoError(t, err)
67+ require.Len(t, stored, 2)
68+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
69+ require.NoError(t, err)
70+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
71+ require.NoError(t, err)
72+ assert.True(t, got.Issue.IsOpen())
73+ assert.Equal(t, domain.MRStateClosed, got.References[0].State)
74+}
75+
76+func TestIssueLinksMergeClosing(t *testing.T) {
77+ for _, mode := range []string{"open", "off", "closed"} {
78+ t.Run(mode, func(t *testing.T) {
79+ r, _, issues := linkedRig(t)
80+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
81+ require.NoError(t, err)
82+ if mode == "closed" {
83+ issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
84+ require.NoError(t, err)
85+ }
86+ r.feature("feat")
87+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
88+ r.push("feat")
89+ mr := r.open("feat")
90+ if mode == "off" {
91+ _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
92+ require.NoError(t, err)
93+ }
94+ issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
95+ require.NoError(t, err)
96+ r.drain()
97+ _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
98+ require.NoError(t, err)
99+ events := r.drain()
100+ got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
101+ require.NoError(t, err)
102+ refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
103+ require.NoError(t, err)
104+ require.Len(t, refs, 1)
105+ assert.True(t, refs[0].Closes)
106+ closedEvents, referenced := 0, 0
107+ for _, ev := range events {
108+ switch ev.(type) {
109+ case domain.IssueClosed:
110+ closedEvents++
111+ case domain.IssueReferenced:
112+ referenced++
113+ }
114+ }
115+ assert.Positive(t, referenced)
116+ if mode == "open" {
117+ assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
118+ assert.False(t, got.IsOpen())
119+ assert.Equal(t, 1, closedEvents)
120+ reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
121+ require.NoError(t, err)
122+ assert.Zero(t, reopened.ClosedByMergeRequest)
123+ } else {
124+ assert.Zero(t, got.ClosedByMergeRequest)
125+ assert.Equal(t, 0, closedEvents)
126+ if mode == "closed" {
127+ assert.Equal(t, issue, got)
128+ } else {
129+ assert.True(t, got.IsOpen())
130+ }
131+ }
132+ })
133+ }
134+}
135+
136+type gatedBranchGit struct {
137+ ports.MergeGit
138+ entered atomic.Int32
139+ started chan struct{}
140+ gate chan struct{}
141+}
142+
143+func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
144+ if g.entered.Add(1) == 1 {
145+ close(g.started)
146+ <-g.gate
147+ }
148+ return g.MergeGit.BranchHead(ctx, name, branch)
149+}
150+
151+func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
152+ r, _, issues := linkedRig(t)
153+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
154+ require.NoError(t, err)
155+ r.feature("feat")
156+ mr := r.open("feat")
157+ g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
158+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}})
159+ repo, err := r.store.Get(r.ctx, r.repo)
160+ require.NoError(t, err)
161+ done := make(chan error, 2)
162+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
163+ <-g.started
164+ // The first reindex read the old texts; a note is added afterwards.
165+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
166+ require.NoError(t, err)
167+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
168+ time.Sleep(100 * time.Millisecond)
169+ assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
170+ close(g.gate)
171+ require.NoError(t, <-done)
172+ require.NoError(t, <-done)
173+ got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
174+ require.NoError(t, err)
175+ require.Len(t, got, 1, "the final index reflects the latest content")
176+}
177+
178+func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
179+ r, links, issues := linkedRig(t)
180+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
181+ require.NoError(t, err)
182+ r.feature("feat")
183+ mr := r.open("feat")
184+ referenced := func() (n int) {
185+ for _, ev := range r.drain() {
186+ if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
187+ n++
188+ }
189+ }
190+ return n
191+ }
192+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
193+ require.NoError(t, err)
194+ assert.Equal(t, 1, referenced())
195+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
196+ require.NoError(t, err)
197+ assert.Equal(t, 1, referenced(), "removing the last mention notifies")
198+
199+ // closes true -> false emits one event: a commit message fixes the issue,
200+ // then is rewritten to a plain mention.
201+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
202+ r.push("feat")
203+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
204+ referenced()
205+ r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
206+ r.push("feat")
207+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
208+ assert.Equal(t, 1, referenced(), "a closing flip notifies once")
209+}
210+
211+func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
212+ r, _, issues := linkedRig(t)
213+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
214+ require.NoError(t, err)
215+ r.sh("checkout", "-q", "-B", "x", "main")
216+ r.commit(author, "x.txt", "x", "work on x")
217+ r.push("x")
218+ r.sh("checkout", "-q", "-B", "feat", "x")
219+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
220+ r.push("feat")
221+ b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
222+ require.NoError(t, err)
223+ a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
224+ require.NoError(t, err)
225+ before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
226+ require.NoError(t, err)
227+ require.Empty(t, before)
228+ _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
229+ require.NoError(t, err)
230+ r.mrs.Wait(r.ctx)
231+ after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
232+ require.NoError(t, err)
233+ require.Len(t, after, 1)
234+ assert.Equal(t, issue.Number, after[0].Issue)
235+}
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/usecase"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
20+ t.Helper()
21+ r := newMRRig(t)
22+ r.seed()
23+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
24+ require.NoError(t, err)
25+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
26+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links})
27+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
28+ return r, links, issues
29+}
30+
31+func TestIssueLinksTextsPushAndReferences(t *testing.T) {
32+ r, links, issues := linkedRig(t)
33+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
34+ require.NoError(t, err)
35+ r.feature("feat")
36+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
37+ require.NoError(t, err)
38+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
39+ require.NoError(t, err)
40+ require.Len(t, got.References, 1)
41+ assert.Equal(t, mr.Title, got.References[0].Title)
42+ other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
43+ require.NoError(t, err)
44+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)})
45+ require.NoError(t, err)
46+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
47+ require.NoError(t, err)
48+ require.Len(t, got.References, 1)
49+ assert.False(t, got.References[0].Closes)
50+ indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
51+ require.NoError(t, err)
52+ require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
53+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
54+ require.NoError(t, err)
55+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
56+ require.NoError(t, err)
57+ assert.Empty(t, got.References)
58+ head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
59+ r.push("feat")
60+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
61+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
62+ require.NoError(t, err)
63+ require.Len(t, got.References, 1)
64+ assert.True(t, got.References[0].Closes)
65+ stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
66+ require.NoError(t, err)
67+ require.Len(t, stored, 2)
68+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
69+ require.NoError(t, err)
70+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
71+ require.NoError(t, err)
72+ assert.True(t, got.Issue.IsOpen())
73+ assert.Equal(t, domain.MRStateClosed, got.References[0].State)
74+}
75+
76+func TestIssueLinksMergeClosing(t *testing.T) {
77+ for _, mode := range []string{"open", "off", "closed"} {
78+ t.Run(mode, func(t *testing.T) {
79+ r, _, issues := linkedRig(t)
80+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
81+ require.NoError(t, err)
82+ if mode == "closed" {
83+ issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
84+ require.NoError(t, err)
85+ }
86+ r.feature("feat")
87+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
88+ r.push("feat")
89+ mr := r.open("feat")
90+ if mode == "off" {
91+ _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
92+ require.NoError(t, err)
93+ }
94+ issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
95+ require.NoError(t, err)
96+ r.drain()
97+ _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
98+ require.NoError(t, err)
99+ events := r.drain()
100+ got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
101+ require.NoError(t, err)
102+ refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
103+ require.NoError(t, err)
104+ require.Len(t, refs, 1)
105+ assert.True(t, refs[0].Closes)
106+ closedEvents, referenced := 0, 0
107+ for _, ev := range events {
108+ switch ev.(type) {
109+ case domain.IssueClosed:
110+ closedEvents++
111+ case domain.IssueReferenced:
112+ referenced++
113+ }
114+ }
115+ assert.Positive(t, referenced)
116+ if mode == "open" {
117+ assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
118+ assert.False(t, got.IsOpen())
119+ assert.Equal(t, 1, closedEvents)
120+ reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
121+ require.NoError(t, err)
122+ assert.Zero(t, reopened.ClosedByMergeRequest)
123+ } else {
124+ assert.Zero(t, got.ClosedByMergeRequest)
125+ assert.Equal(t, 0, closedEvents)
126+ if mode == "closed" {
127+ assert.Equal(t, issue, got)
128+ } else {
129+ assert.True(t, got.IsOpen())
130+ }
131+ }
132+ })
133+ }
134+}
135+
136+type gatedBranchGit struct {
137+ ports.MergeGit
138+ entered atomic.Int32
139+ started chan struct{}
140+ gate chan struct{}
141+}
142+
143+func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
144+ if g.entered.Add(1) == 1 {
145+ close(g.started)
146+ <-g.gate
147+ }
148+ return g.MergeGit.BranchHead(ctx, name, branch)
149+}
150+
151+func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
152+ r, _, issues := linkedRig(t)
153+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
154+ require.NoError(t, err)
155+ r.feature("feat")
156+ mr := r.open("feat")
157+ g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
158+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}})
159+ repo, err := r.store.Get(r.ctx, r.repo)
160+ require.NoError(t, err)
161+ done := make(chan error, 2)
162+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
163+ <-g.started
164+ // The first reindex read the old texts; a note is added afterwards.
165+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
166+ require.NoError(t, err)
167+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
168+ time.Sleep(100 * time.Millisecond)
169+ assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
170+ close(g.gate)
171+ require.NoError(t, <-done)
172+ require.NoError(t, <-done)
173+ got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
174+ require.NoError(t, err)
175+ require.Len(t, got, 1, "the final index reflects the latest content")
176+}
177+
178+func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
179+ r, links, issues := linkedRig(t)
180+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
181+ require.NoError(t, err)
182+ r.feature("feat")
183+ mr := r.open("feat")
184+ referenced := func() (n int) {
185+ for _, ev := range r.drain() {
186+ if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
187+ n++
188+ }
189+ }
190+ return n
191+ }
192+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
193+ require.NoError(t, err)
194+ assert.Equal(t, 1, referenced())
195+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
196+ require.NoError(t, err)
197+ assert.Equal(t, 1, referenced(), "removing the last mention notifies")
198+
199+ // closes true -> false emits one event: a commit message fixes the issue,
200+ // then is rewritten to a plain mention.
201+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
202+ r.push("feat")
203+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
204+ referenced()
205+ r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
206+ r.push("feat")
207+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
208+ assert.Equal(t, 1, referenced(), "a closing flip notifies once")
209+}
210+
211+func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
212+ r, _, issues := linkedRig(t)
213+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
214+ require.NoError(t, err)
215+ r.sh("checkout", "-q", "-B", "x", "main")
216+ r.commit(author, "x.txt", "x", "work on x")
217+ r.push("x")
218+ r.sh("checkout", "-q", "-B", "feat", "x")
219+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
220+ r.push("feat")
221+ b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
222+ require.NoError(t, err)
223+ a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
224+ require.NoError(t, err)
225+ before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
226+ require.NoError(t, err)
227+ require.Empty(t, before)
228+ _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
229+ require.NoError(t, err)
230+ r.mrs.Wait(r.ctx)
231+ after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
232+ require.NoError(t, err)
233+ require.Len(t, after, 1)
234+ assert.Equal(t, issue.Number, after[0].Issue)
235+}
M internal/usecase/issues.go +35 -9
@@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000
2626 // issue:write on it, which the branch prefixes of a token do not restrict:
2727 // an issue writes no ref.
2828 type Issues struct {
29+ links ports.IssueLinkStore
2930 meta ports.RepoMetaStore
3031 store ports.IssueStore
3132 mrs ports.MergeRequestStore
@@ -40,6 +41,7 @@ var (
4041
4142 // IssuesConfig holds what the issue use cases need. Every field is required.
4243 type IssuesConfig struct {
44+ Links ports.IssueLinkStore
4345 Meta ports.RepoMetaStore
4446 Store ports.IssueStore
4547 // MergeRequests tells which numbers name a merge request (Kind).
@@ -50,7 +52,7 @@ type IssuesConfig struct {
5052
5153 // NewIssues builds the issue use cases.
5254 func NewIssues(cfg IssuesConfig) *Issues {
53- return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
55+ return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
5456 }
5557
5658 // enabled looks the repository up and hides it when issues are off.
@@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa
159161 if err != nil {
160162 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161163 }
162- return ports.IssueDetail{Issue: issue, Comments: comments}, nil
164+ detail := ports.IssueDetail{Issue: issue, Comments: comments}
165+ if i.links != nil {
166+ links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167+ if err != nil {
168+ return ports.IssueDetail{}, err
169+ }
170+ for _, link := range links {
171+ mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172+ if errors.Is(err, domain.ErrNotFound) {
173+ continue
174+ }
175+ if err != nil {
176+ return ports.IssueDetail{}, err
177+ }
178+ detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179+ }
180+ }
181+ return detail, nil
163182 }
164183
165184 // AddComment adds a comment and publishes IssueCommented. The principal needs
@@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
251270 if issue.State != from {
252271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253272 }
273+ issue.ClosedByMergeRequest = 0
254274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255275 err = i.store.Update(ctx, issue)
256276 if errors.Is(err, domain.ErrConflict) {
@@ -274,16 +294,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
274294 }
275295
276296 // Kind tells what number n of the repository names. The principal needs the
277-// access of reading the repository, and issues must be on there: otherwise it
278-// is domain.ErrNotFound.
297+// access of reading the repository. Issues are resolved only when enabled;
298+// merge requests are resolved even when issues are off.
279299 func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
280- if _, err := i.readable(ctx, p, name); err != nil {
300+ repo, err := lookupRepo(ctx, i.meta, name)
301+ if err != nil {
281302 return domain.NumberNone, err
282303 }
283- if _, err := i.store.Get(ctx, name, n); err == nil {
284- return domain.NumberIssue, nil
285- } else if !errors.Is(err, domain.ErrNotFound) {
286- return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
304+ if !canRead(p, repo) {
305+ return domain.NumberNone, domain.ErrNotFound
306+ }
307+ if repo.Settings.IssuesEnabled() {
308+ if _, err := i.store.Get(ctx, name, n); err == nil {
309+ return domain.NumberIssue, nil
310+ } else if !errors.Is(err, domain.ErrNotFound) {
311+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
312+ }
287313 }
288314 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289315 return domain.NumberMergeRequest, nil
@@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000
26 // issue:write on it, which the branch prefixes of a token do not restrict:26 // issue:write on it, which the branch prefixes of a token do not restrict:
27 // an issue writes no ref.27 // an issue writes no ref.
28 type Issues struct {28 type Issues struct {
29+ links ports.IssueLinkStore
29 meta ports.RepoMetaStore30 meta ports.RepoMetaStore
30 store ports.IssueStore31 store ports.IssueStore
31 mrs ports.MergeRequestStore32 mrs ports.MergeRequestStore
@@ -40,6 +41,7 @@ var (
40 41
41 // IssuesConfig holds what the issue use cases need. Every field is required.42 // IssuesConfig holds what the issue use cases need. Every field is required.
42 type IssuesConfig struct {43 type IssuesConfig struct {
44+ Links ports.IssueLinkStore
43 Meta ports.RepoMetaStore45 Meta ports.RepoMetaStore
44 Store ports.IssueStore46 Store ports.IssueStore
45 // MergeRequests tells which numbers name a merge request (Kind).47 // MergeRequests tells which numbers name a merge request (Kind).
@@ -50,7 +52,7 @@ type IssuesConfig struct {
50 52
51 // NewIssues builds the issue use cases.53 // NewIssues builds the issue use cases.
52 func NewIssues(cfg IssuesConfig) *Issues {54 func NewIssues(cfg IssuesConfig) *Issues {
53- return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}55+ return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54 }56 }
55 57
56 // enabled looks the repository up and hides it when issues are off.58 // enabled looks the repository up and hides it when issues are off.
@@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa
159 if err != nil {161 if err != nil {
160 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)162 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161 }163 }
162- return ports.IssueDetail{Issue: issue, Comments: comments}, nil164+ detail := ports.IssueDetail{Issue: issue, Comments: comments}
165+ if i.links != nil {
166+ links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167+ if err != nil {
168+ return ports.IssueDetail{}, err
169+ }
170+ for _, link := range links {
171+ mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172+ if errors.Is(err, domain.ErrNotFound) {
173+ continue
174+ }
175+ if err != nil {
176+ return ports.IssueDetail{}, err
177+ }
178+ detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179+ }
180+ }
181+ return detail, nil
163 }182 }
164 183
165 // AddComment adds a comment and publishes IssueCommented. The principal needs184 // AddComment adds a comment and publishes IssueCommented. The principal needs
@@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
251 if issue.State != from {270 if issue.State != from {
252 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253 }272 }
273+ issue.ClosedByMergeRequest = 0
254 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255 err = i.store.Update(ctx, issue)275 err = i.store.Update(ctx, issue)
256 if errors.Is(err, domain.ErrConflict) {276 if errors.Is(err, domain.ErrConflict) {
@@ -274,16 +294,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
274 }294 }
275 295
276 // Kind tells what number n of the repository names. The principal needs the296 // Kind tells what number n of the repository names. The principal needs the
277-// access of reading the repository, and issues must be on there: otherwise it297+// access of reading the repository. Issues are resolved only when enabled;
278-// is domain.ErrNotFound.298+// merge requests are resolved even when issues are off.
279 func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {299 func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
280- if _, err := i.readable(ctx, p, name); err != nil {300+ repo, err := lookupRepo(ctx, i.meta, name)
301+ if err != nil {
281 return domain.NumberNone, err302 return domain.NumberNone, err
282 }303 }
283- if _, err := i.store.Get(ctx, name, n); err == nil {304+ if !canRead(p, repo) {
284- return domain.NumberIssue, nil305+ return domain.NumberNone, domain.ErrNotFound
285- } else if !errors.Is(err, domain.ErrNotFound) {306+ }
286- return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)307+ if repo.Settings.IssuesEnabled() {
308+ if _, err := i.store.Get(ctx, name, n); err == nil {
309+ return domain.NumberIssue, nil
310+ } else if !errors.Is(err, domain.ErrNotFound) {
311+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
312+ }
287 }313 }
288 if _, err := i.mrs.Get(ctx, name, n); err == nil {314 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289 return domain.NumberMergeRequest, nil315 return domain.NumberMergeRequest, nil
M internal/usecase/issues_test.go +4 -2
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
6565 assert.ErrorIs(t, err, domain.ErrNotFound)
6666 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
6767 assert.ErrorIs(t, err, domain.ErrNotFound)
68- _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
69- assert.ErrorIs(t, err, domain.ErrNotFound)
68+ u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69+ kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.NumberMergeRequest, kind)
7072 }
7173 }
7274
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
65 assert.ErrorIs(t, err, domain.ErrNotFound)65 assert.ErrorIs(t, err, domain.ErrNotFound)
66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
67 assert.ErrorIs(t, err, domain.ErrNotFound)67 assert.ErrorIs(t, err, domain.ErrNotFound)
68- _, err = u.issues.Kind(ctx, domain.Root(), n, 1)68+ u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69- assert.ErrorIs(t, err, domain.ErrNotFound)69+ kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.NumberMergeRequest, kind)
70 }72 }
71 }73 }
72 74
M internal/usecase/mergerequests.go +33 -3
@@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:"
4343 // merging mr:merge, on the repository (see scopeAccess), and that scope also
4444 // makes a private repository visible for the action (see writable).
4545 type MergeRequests struct {
46+ links ports.IssueLinkTrigger
4647 meta ports.RepoMetaStore
4748 store ports.MergeRequestStore
4849 repos ports.GitRepos
@@ -77,6 +78,7 @@ var (
7778 // MergeRequestsConfig holds what the merge request use cases need. Every field
7879 // is required.
7980 type MergeRequestsConfig struct {
81+ Links ports.IssueLinkTrigger // Optional derived issue links.
8082 Meta ports.RepoMetaStore
8183 Store ports.MergeRequestStore
8284 // Repos gives the default branch of a repository.
@@ -111,7 +113,7 @@ type MergeRequestsConfig struct {
111113 // NewMergeRequests builds the merge request use cases.
112114 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
113115 return &MergeRequests{
114- meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
116+ links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
115117 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
116118 locks: make(map[domain.RepoName]chan struct{}),
117119 }
@@ -288,6 +290,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
288290 }
289291 recordActivity(ctx, m.meta, repo.Name, now)
290292 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
293+ if m.links != nil {
294+ m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
295+ }
291296 if m.mergeab != nil {
292297 m.mergeab.OnOpened(ctx, mr)
293298 }
@@ -430,6 +435,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
430435 }
431436 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
432437 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
438+ if m.links != nil {
439+ m.links.OnMergeRequestChanged(ctx, repo, number)
440+ }
433441 return m.withHead(ctx, mr)
434442 }
435443 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
@@ -647,17 +655,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
647655 // the head; both are zero when the target is gone or shares no history with
648656 // the head. A merged or closed request keeps the base it stored for both.
649657 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
658+ return mrRange(ctx, m.git, mr)
659+}
660+
661+func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
650662 if !mr.IsOpen() {
651663 return mr.BaseID, mr.BaseID, nil
652664 }
653665 if mr.HeadID.IsZero() {
654666 return domain.ObjectID{}, domain.ObjectID{}, nil
655667 }
656- tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)
668+ tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
669+ if errors.Is(err, domain.ErrNotFound) {
670+ return domain.ObjectID{}, domain.ObjectID{}, nil
671+ }
657672 if err != nil || tip.IsZero() {
658673 return domain.ObjectID{}, domain.ObjectID{}, err
659674 }
660- base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
675+ base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
661676 if errors.Is(err, domain.ErrNotFound) {
662677 return tip, domain.ObjectID{}, nil
663678 }
@@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
777792 m.bus.Publish(ctx, domain.CommentAdded{
778793 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
779794 })
795+ if m.links != nil {
796+ m.links.OnMergeRequestChanged(ctx, repo, number)
797+ }
780798 return c, nil
781799 }
782800
@@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
933+ if m.links != nil {
934+ m.links.OnMergeRequestSettled(ctx, repo, mr)
935+ }
915936 if m.pulls != nil {
916937 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917938 }
@@ -1168,6 +1189,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11681189 // never returned. mr is the merged request, tip the old target and merged the
11691190 // new one.
11701191 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1192+ if m.links != nil {
1193+ m.links.OnMerged(ctx, p, repo, mr)
1194+ }
11711195 name := repo.Name
11721196 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
11731197 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
@@ -1193,6 +1217,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11931217 if m.mergeab != nil {
11941218 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
11951219 }
1220+ if m.links != nil {
1221+ m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1222+ }
11961223 }
11971224
11981225 // deleteSource deletes the source branch of a merged request if it is still
@@ -1453,6 +1480,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
14531480 } else {
14541481 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
14551482 }
1483+ if m.links != nil {
1484+ m.links.OnMergeRequestChanged(ctx, repo, number)
1485+ }
14561486 return c, nil
14571487 }
14581488 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
@@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:"
43 // merging mr:merge, on the repository (see scopeAccess), and that scope also43 // merging mr:merge, on the repository (see scopeAccess), and that scope also
44 // makes a private repository visible for the action (see writable).44 // makes a private repository visible for the action (see writable).
45 type MergeRequests struct {45 type MergeRequests struct {
46+ links ports.IssueLinkTrigger
46 meta ports.RepoMetaStore47 meta ports.RepoMetaStore
47 store ports.MergeRequestStore48 store ports.MergeRequestStore
48 repos ports.GitRepos49 repos ports.GitRepos
@@ -77,6 +78,7 @@ var (
77 // MergeRequestsConfig holds what the merge request use cases need. Every field78 // MergeRequestsConfig holds what the merge request use cases need. Every field
78 // is required.79 // is required.
79 type MergeRequestsConfig struct {80 type MergeRequestsConfig struct {
81+ Links ports.IssueLinkTrigger // Optional derived issue links.
80 Meta ports.RepoMetaStore82 Meta ports.RepoMetaStore
81 Store ports.MergeRequestStore83 Store ports.MergeRequestStore
82 // Repos gives the default branch of a repository.84 // Repos gives the default branch of a repository.
@@ -111,7 +113,7 @@ type MergeRequestsConfig struct {
111 // NewMergeRequests builds the merge request use cases.113 // NewMergeRequests builds the merge request use cases.
112 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {114 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
113 return &MergeRequests{115 return &MergeRequests{
114- meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,116+ links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
115 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,117 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
116 locks: make(map[domain.RepoName]chan struct{}),118 locks: make(map[domain.RepoName]chan struct{}),
117 }119 }
@@ -288,6 +290,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
288 }290 }
289 recordActivity(ctx, m.meta, repo.Name, now)291 recordActivity(ctx, m.meta, repo.Name, now)
290 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})292 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
293+ if m.links != nil {
294+ m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
295+ }
291 if m.mergeab != nil {296 if m.mergeab != nil {
292 m.mergeab.OnOpened(ctx, mr)297 m.mergeab.OnOpened(ctx, mr)
293 }298 }
@@ -430,6 +435,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
430 }435 }
431 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)436 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
432 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})437 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
438+ if m.links != nil {
439+ m.links.OnMergeRequestChanged(ctx, repo, number)
440+ }
433 return m.withHead(ctx, mr)441 return m.withHead(ctx, mr)
434 }442 }
435 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)443 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
@@ -647,17 +655,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
647 // the head; both are zero when the target is gone or shares no history with655 // the head; both are zero when the target is gone or shares no history with
648 // the head. A merged or closed request keeps the base it stored for both.656 // the head. A merged or closed request keeps the base it stored for both.
649 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {657 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
658+ return mrRange(ctx, m.git, mr)
659+}
660+
661+func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
650 if !mr.IsOpen() {662 if !mr.IsOpen() {
651 return mr.BaseID, mr.BaseID, nil663 return mr.BaseID, mr.BaseID, nil
652 }664 }
653 if mr.HeadID.IsZero() {665 if mr.HeadID.IsZero() {
654 return domain.ObjectID{}, domain.ObjectID{}, nil666 return domain.ObjectID{}, domain.ObjectID{}, nil
655 }667 }
656- tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)668+ tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
669+ if errors.Is(err, domain.ErrNotFound) {
670+ return domain.ObjectID{}, domain.ObjectID{}, nil
671+ }
657 if err != nil || tip.IsZero() {672 if err != nil || tip.IsZero() {
658 return domain.ObjectID{}, domain.ObjectID{}, err673 return domain.ObjectID{}, domain.ObjectID{}, err
659 }674 }
660- base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)675+ base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
661 if errors.Is(err, domain.ErrNotFound) {676 if errors.Is(err, domain.ErrNotFound) {
662 return tip, domain.ObjectID{}, nil677 return tip, domain.ObjectID{}, nil
663 }678 }
@@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
777 m.bus.Publish(ctx, domain.CommentAdded{792 m.bus.Publish(ctx, domain.CommentAdded{
778 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,793 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
779 })794 })
795+ if m.links != nil {
796+ m.links.OnMergeRequestChanged(ctx, repo, number)
797+ }
780 return c, nil798 return c, nil
781 }799 }
782 800
@@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
933+ if m.links != nil {
934+ m.links.OnMergeRequestSettled(ctx, repo, mr)
935+ }
915 if m.pulls != nil {936 if m.pulls != nil {
916 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)937 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917 }938 }
@@ -1168,6 +1189,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1168 // never returned. mr is the merged request, tip the old target and merged the1189 // never returned. mr is the merged request, tip the old target and merged the
1169 // new one.1190 // new one.
1170 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {1191 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1192+ if m.links != nil {
1193+ m.links.OnMerged(ctx, p, repo, mr)
1194+ }
1171 name := repo.Name1195 name := repo.Name
1172 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}1196 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1173 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {1197 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
@@ -1193,6 +1217,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
1193 if m.mergeab != nil {1217 if m.mergeab != nil {
1194 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target1218 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1195 }1219 }
1220+ if m.links != nil {
1221+ m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1222+ }
1196 }1223 }
1197 1224
1198 // deleteSource deletes the source branch of a merged request if it is still1225 // deleteSource deletes the source branch of a merged request if it is still
@@ -1453,6 +1480,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
1453 } else {1480 } else {
1454 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1481 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1455 }1482 }
1483+ if m.links != nil {
1484+ m.links.OnMergeRequestChanged(ctx, repo, number)
1485+ }
1456 return c, nil1486 return c, nil
1457 }1487 }
1458 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)1488 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
M internal/usecase/pushes.go +7 -0
@@ -17,6 +17,7 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20+ links ports.IssueLinkTrigger
2021 mirror ports.MirrorTrigger // nil: no outbound mirror
2122 meta ports.RepoMetaStore
2223 repos *Repos
@@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4445 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4546 }
4647
48+// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49+func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50+
4751 // WithMirror makes Record notify outbound mirrors. Call before the first push.
4852 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
4953 u.mirror = t
@@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
265269 if u.mergeab != nil {
266270 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
267271 }
272+ if u.links != nil {
273+ u.links.OnBranchesMoved(ctx, name, updates)
274+ }
268275 u.bus.Publish(ctx, domain.PushEvent{
269276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
270277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -17,6 +17,7 @@ import (
17 17
18 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
19 type Pushes struct {19 type Pushes struct {
20+ links ports.IssueLinkTrigger
20 mirror ports.MirrorTrigger // nil: no outbound mirror21 mirror ports.MirrorTrigger // nil: no outbound mirror
21 meta ports.RepoMetaStore22 meta ports.RepoMetaStore
22 repos *Repos23 repos *Repos
@@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
44 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}45 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
45 }46 }
46 47
48+// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49+func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50+
47 // WithMirror makes Record notify outbound mirrors. Call before the first push.51 // WithMirror makes Record notify outbound mirrors. Call before the first push.
48 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {52 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
49 u.mirror = t53 u.mirror = t
@@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
265 if u.mergeab != nil {269 if u.mergeab != nil {
266 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only270 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
267 }271 }
272+ if u.links != nil {
273+ u.links.OnBranchesMoved(ctx, name, updates)
274+ }
268 u.bus.Publish(ctx, domain.PushEvent{275 u.bus.Publish(ctx, domain.PushEvent{
269 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M proto/ermite/storage/issue.proto +12 -0
@@ -36,6 +36,18 @@ message Issue {
3636 // True for an issue approved from the public form: its text comes from
3737 // an anonymous visitor and is rendered as untrusted.
3838 bool external = 9;
39+ int64 closed_by_merge_request = 10;
40+}
41+
42+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
43+message IssueLink {
44+ bool closes = 1;
45+}
46+
47+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
48+message MergeRequestReferences {
49+ repeated int64 issues = 1;
50+ repeated int64 closes = 2;
3951 }
4052
4153 // IssueComment is stored under the key
@@ -36,6 +36,18 @@ message Issue {
36 // True for an issue approved from the public form: its text comes from36 // True for an issue approved from the public form: its text comes from
37 // an anonymous visitor and is rendered as untrusted.37 // an anonymous visitor and is rendered as untrusted.
38 bool external = 9;38 bool external = 9;
39+ int64 closed_by_merge_request = 10;
40+}
41+
42+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
43+message IssueLink {
44+ bool closes = 1;
45+}
46+
47+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
48+message MergeRequestReferences {
49+ repeated int64 issues = 1;
50+ repeated int64 closes = 2;
39 }51 }
40 52
41 // IssueComment is stored under the key53 // IssueComment is stored under the key

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 2

Tags 43

↑↓ moveenter openesc close