detached 23c66c0

feat(mr): background mergeability checks, their Watch event and API field

commit
23c66c065d92af4179bd23f31397038a5486c8b7
author
brice · · 2026-10-19 13:05 UTC
committer
brice · · 2026-10-19 18:30 UTC
parent
8f36a2f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +1014 -20
M internal/adapters/client/features.go +3 -0
@@ -54,6 +54,9 @@ const (
5454 // FeatureCommitSignatures is TrustedKeyService and Commit.verification
5555 // (`ermite signing-key trust`).
5656 FeatureCommitSignatures = "commit_signatures"
57+ // FeatureMergeability is MergeRequest.mergeability and the
58+ // merge_request_mergeability event.
59+ FeatureMergeability = "merge_requests.mergeability"
5760 )
5861
5962 // UsedFeatures lists the features the CLI uses, in the order they are reported.
@@ -54,6 +54,9 @@ const (
54 // FeatureCommitSignatures is TrustedKeyService and Commit.verification54 // FeatureCommitSignatures is TrustedKeyService and Commit.verification
55 // (`ermite signing-key trust`).55 // (`ermite signing-key trust`).
56 FeatureCommitSignatures = "commit_signatures"56 FeatureCommitSignatures = "commit_signatures"
57+ // FeatureMergeability is MergeRequest.mergeability and the
58+ // merge_request_mergeability event.
59+ FeatureMergeability = "merge_requests.mergeability"
57 )60 )
58 61
59 // UsedFeatures lists the features the CLI uses, in the order they are reported.62 // UsedFeatures lists the features the CLI uses, in the order they are reported.
M internal/adapters/kvstore/mergerequests.go +1 -0
@@ -679,6 +679,7 @@ func encodeMergeability(m domain.Mergeability) ([]byte, error) {
679679 })
680680 }
681681
682+//hexcheck:ignore suspicious-business-logic-in-adapter decodes the stored record into the domain value; no rule is applied
682683 func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683684 var pb storagepb.Mergeability
684685 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -679,6 +679,7 @@ func encodeMergeability(m domain.Mergeability) ([]byte, error) {
679 })679 })
680 }680 }
681 681
682+//hexcheck:ignore suspicious-business-logic-in-adapter decodes the stored record into the domain value; no rule is applied
682 func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {683 func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683 var pb storagepb.Mergeability684 var pb storagepb.Mergeability
684 if err := proto.Unmarshal(item.Value, &pb); err != nil {685 if err := proto.Unmarshal(item.Value, &pb); err != nil {
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +3 -0
@@ -501,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
501501 case domain.CommentResolved:
502502 out.Time = timestamp(ev.At)
503503 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
504+ case domain.MergeRequestMergeability:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_MergeRequestMergeability{MergeRequestMergeability: &pb.MergeRequestMergeability{Repo: ev.Repo.String(), Number: ev.Number, Mergeability: mergeabilityToPB(ev.Mergeability)}}
504507 case domain.RunUpdated:
505508 out.Time = timestamp(ev.At)
506509 out.Kind = runEventToPB(ev)
@@ -501,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
501 case domain.CommentResolved:501 case domain.CommentResolved:
502 out.Time = timestamp(ev.At)502 out.Time = timestamp(ev.At)
503 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}503 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
504+ case domain.MergeRequestMergeability:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_MergeRequestMergeability{MergeRequestMergeability: &pb.MergeRequestMergeability{Repo: ev.Repo.String(), Number: ev.Number, Mergeability: mergeabilityToPB(ev.Mergeability)}}
504 case domain.RunUpdated:507 case domain.RunUpdated:
505 out.Time = timestamp(ev.At)508 out.Time = timestamp(ev.At)
506 out.Kind = runEventToPB(ev)509 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/mergerequest.go +39 -2
@@ -133,6 +133,43 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
133133 }
134134 }
135135
136+// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
137+// an open request; m is ignored for a request that is not open.
138+func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
139+ out := mergeRequestToPB(mr)
140+ if mr.IsOpen() && m.State != "" {
141+ out.Mergeability = mergeabilityToPB(m)
142+ }
143+ return out
144+}
145+
146+func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
147+ switch s {
148+ case domain.MergeabilityChecking:
149+ return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
150+ case domain.MergeabilityUpToDate:
151+ return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
152+ case domain.MergeabilityBehind:
153+ return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
154+ case domain.MergeabilityConflict:
155+ return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
156+ }
157+ return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
158+}
159+
160+func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
161+ return &pb.Mergeability{
162+ State: mergeabilityStateToPB(m.State),
163+ TargetId: m.TargetID.String(),
164+ HeadId: m.HeadID.String(),
165+ Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
166+ BehindCapped: m.BehindCapped,
167+ ConflictPaths: m.ConflictPaths,
168+ ConflictPathsTruncated: m.ConflictPathsTruncated,
169+ CheckedAt: timestamp(m.CheckedAt),
170+ }
171+}
172+
136173 func commentToPB(c domain.Comment) *pb.Comment {
137174 return &pb.Comment{
138175 Id: strconv.FormatInt(c.ID, 10),
@@ -214,7 +251,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec
214251 }
215252 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
216253 for _, mr := range page.MergeRequests {
217- out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
254+ out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
218255 }
219256 return connect.NewResponse(out), nil
220257 }
@@ -229,7 +266,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
229266 return nil, connectError(ctx, err)
230267 }
231268 out := &pb.GetMergeRequestResponse{
232- MergeRequest: mergeRequestToPB(d.MergeRequest),
269+ MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
233270 CommitsTruncated: d.CommitsTruncated,
234271 DiffBaseId: d.DiffBaseID.String(),
235272 Stats: diffStatsToPB(d.Stats),
@@ -133,6 +133,43 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
133 }133 }
134 }134 }
135 135
136+// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
137+// an open request; m is ignored for a request that is not open.
138+func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
139+ out := mergeRequestToPB(mr)
140+ if mr.IsOpen() && m.State != "" {
141+ out.Mergeability = mergeabilityToPB(m)
142+ }
143+ return out
144+}
145+
146+func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
147+ switch s {
148+ case domain.MergeabilityChecking:
149+ return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
150+ case domain.MergeabilityUpToDate:
151+ return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
152+ case domain.MergeabilityBehind:
153+ return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
154+ case domain.MergeabilityConflict:
155+ return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
156+ }
157+ return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
158+}
159+
160+func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
161+ return &pb.Mergeability{
162+ State: mergeabilityStateToPB(m.State),
163+ TargetId: m.TargetID.String(),
164+ HeadId: m.HeadID.String(),
165+ Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
166+ BehindCapped: m.BehindCapped,
167+ ConflictPaths: m.ConflictPaths,
168+ ConflictPathsTruncated: m.ConflictPathsTruncated,
169+ CheckedAt: timestamp(m.CheckedAt),
170+ }
171+}
172+
136 func commentToPB(c domain.Comment) *pb.Comment {173 func commentToPB(c domain.Comment) *pb.Comment {
137 return &pb.Comment{174 return &pb.Comment{
138 Id: strconv.FormatInt(c.ID, 10),175 Id: strconv.FormatInt(c.ID, 10),
@@ -214,7 +251,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec
214 }251 }
215 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}252 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
216 for _, mr := range page.MergeRequests {253 for _, mr := range page.MergeRequests {
217- out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))254+ out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
218 }255 }
219 return connect.NewResponse(out), nil256 return connect.NewResponse(out), nil
220 }257 }
@@ -229,7 +266,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
229 return nil, connectError(ctx, err)266 return nil, connectError(ctx, err)
230 }267 }
231 out := &pb.GetMergeRequestResponse{268 out := &pb.GetMergeRequestResponse{
232- MergeRequest: mergeRequestToPB(d.MergeRequest),269+ MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
233 CommitsTruncated: d.CommitsTruncated,270 CommitsTruncated: d.CommitsTruncated,
234 DiffBaseId: d.DiffBaseID.String(),271 DiffBaseId: d.DiffBaseID.String(),
235 Stats: diffStatsToPB(d.Stats),272 Stats: diffStatsToPB(d.Stats),
M internal/app/api_helpers_test.go +14 -1
@@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream {
167167 return s
168168 }
169169
170-// next returns the next event or fails after d.
170+// next returns the next event or fails after d. Mergeability events, which
171+// arrive asynchronously after a push or an opening, are skipped: use
172+// nextAny to see them.
171173 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
174+ t.Helper()
175+ for {
176+ ev := s.nextAny(t, d)
177+ if ev.GetMergeRequestMergeability() == nil {
178+ return ev
179+ }
180+ }
181+}
182+
183+// nextAny returns the next event, whatever its kind, or fails after d.
184+func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event {
172185 t.Helper()
173186 select {
174187 case ev, ok := <-s.events:
@@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream {
167 return s167 return s
168 }168 }
169 169
170-// next returns the next event or fails after d.170+// next returns the next event or fails after d. Mergeability events, which
171+// arrive asynchronously after a push or an opening, are skipped: use
172+// nextAny to see them.
171 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {173 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
174+ t.Helper()
175+ for {
176+ ev := s.nextAny(t, d)
177+ if ev.GetMergeRequestMergeability() == nil {
178+ return ev
179+ }
180+ }
181+}
182+
183+// nextAny returns the next event, whatever its kind, or fails after d.
184+func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event {
172 t.Helper()185 t.Helper()
173 select {186 select {
174 case ev, ok := <-s.events:187 case ev, ok := <-s.events:
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +14 -2
@@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa
123123 return w
124124 }
125125
126-// next decodes the next event line or fails after d.
126+// next returns the next event line or fails after d. Mergeability events,
127+// which arrive asynchronously after a push or an opening, are skipped.
127128 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129+ t.Helper()
130+ for {
131+ l := w.nextAny(t, d)
132+ if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133+ return l
134+ }
135+ }
136+}
137+
138+// nextAny returns the next event line, whatever its kind, or fails after d.
139+func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
128140 t.Helper()
129141 select {
130142 case l, ok := <-w.lines:
@@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338350 ver := decode[versionOut](t, res.stdout)
339351 assert.Equal(t, c.url, ver.Server.URL)
340352 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343355 stdout, stderr, code = c.bin(t, secret, "version")
344356 require.Equal(t, 0, code, stderr)
@@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa
123 return w123 return w
124 }124 }
125 125
126-// next decodes the next event line or fails after d.126+// next returns the next event line or fails after d. Mergeability events,
127+// which arrive asynchronously after a push or an opening, are skipped.
127 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {128 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129+ t.Helper()
130+ for {
131+ l := w.nextAny(t, d)
132+ if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133+ return l
134+ }
135+ }
136+}
137+
138+// nextAny returns the next event line, whatever its kind, or fails after d.
139+func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
128 t.Helper()140 t.Helper()
129 select {141 select {
130 case l, ok := <-w.lines:142 case l, ok := <-w.lines:
@@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
339 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
340 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
344 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_watch.go +8 -0
@@ -29,6 +29,8 @@ type jsonEvent struct {
2929 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
3030 Number int64 `json:"number,omitempty"`
3131 Comment *jsonComment `json:"comment,omitempty"`
32+ // Mergeability is the state of a merge_request_mergeability event.
33+ Mergeability string `json:"mergeability,omitempty"`
3234 }
3335
3436 type jsonRefUpdate struct {
@@ -52,6 +54,7 @@ const (
5254 eventCommentEdited = "comment_edited"
5355 eventCommentDeleted = "comment_deleted"
5456 eventCommentResolved = "comment_resolved"
57+ eventMRMergeability = "merge_request_mergeability"
5558 )
5659
5760 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
9497 case *ermite.Event_CommentResolved:
9598 c := toJSONComment(k.CommentResolved.GetComment())
9699 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100+ case *ermite.Event_MergeRequestMergeability:
101+ out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102+ out.Mergeability = k.MergeRequestMergeability.GetMergeability().GetState().String()
97103 case *ermite.Event_Gap:
98104 out.Type = eventGap
99105 default:
@@ -126,6 +132,8 @@ func eventLine(e jsonEvent) string {
126132 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
127133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
128134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135+ case eventMRMergeability:
136+ detail = fmt.Sprintf("%s!%d %s", e.Repo, e.Number, e.Mergeability)
129137 case eventGap:
130138 detail = "events were lost, reload what you display"
131139 }
@@ -29,6 +29,8 @@ type jsonEvent struct {
29 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`29 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
30 Number int64 `json:"number,omitempty"`30 Number int64 `json:"number,omitempty"`
31 Comment *jsonComment `json:"comment,omitempty"`31 Comment *jsonComment `json:"comment,omitempty"`
32+ // Mergeability is the state of a merge_request_mergeability event.
33+ Mergeability string `json:"mergeability,omitempty"`
32 }34 }
33 35
34 type jsonRefUpdate struct {36 type jsonRefUpdate struct {
@@ -52,6 +54,7 @@ const (
52 eventCommentEdited = "comment_edited"54 eventCommentEdited = "comment_edited"
53 eventCommentDeleted = "comment_deleted"55 eventCommentDeleted = "comment_deleted"
54 eventCommentResolved = "comment_resolved"56 eventCommentResolved = "comment_resolved"
57+ eventMRMergeability = "merge_request_mergeability"
55 )58 )
56 59
57 // toJSONEvent maps an event; ok is false for kinds this client does not know,60 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
94 case *ermite.Event_CommentResolved:97 case *ermite.Event_CommentResolved:
95 c := toJSONComment(k.CommentResolved.GetComment())98 c := toJSONComment(k.CommentResolved.GetComment())
96 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c99 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100+ case *ermite.Event_MergeRequestMergeability:
101+ out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102+ out.Mergeability = k.MergeRequestMergeability.GetMergeability().GetState().String()
97 case *ermite.Event_Gap:103 case *ermite.Event_Gap:
98 out.Type = eventGap104 out.Type = eventGap
99 default:105 default:
@@ -126,6 +132,8 @@ func eventLine(e jsonEvent) string {
126 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)132 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
127 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
128 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135+ case eventMRMergeability:
136+ detail = fmt.Sprintf("%s!%d %s", e.Repo, e.Number, e.Mergeability)
129 case eventGap:137 case eventGap:
130 detail = "events were lost, reload what you display"138 detail = "events were lost, reload what you display"
131 }139 }
M internal/app/forge.go +11 -1
@@ -137,6 +137,8 @@ type forge struct {
137137 // Inbox serves the owner's inbox of the web.
138138 Inbox *usecase.Inbox
139139 SigningKey *usecase.SigningKey
140+ // Mergeability keeps the mergeability of the open merge requests up to date.
141+ Mergeability *usecase.Mergeability
140142 // Signatures are the trusted keys and the commit signature checks
141143 // (ADR 0030).
142144 Signatures *usecase.Signatures
@@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
331333 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
332334 return nil, err
333335 }
336+ f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
337+ Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
338+ })
339+ f.Pushes.WithMergeability(f.Mergeability)
334340 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
335341 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
336342 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
337- CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
343+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
338344 })
345+ f.Mergeability.Start()
339346 f.MergeRequests.WithVerifier(f.Signatures)
340347 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
341348 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
@@ -515,6 +522,9 @@ func (f *forge) Close(ctx context.Context) {
515522 if f.CI != nil {
516523 f.CI.Close(ctx)
517524 }
525+ if f.Mergeability != nil {
526+ f.Mergeability.Close(ctx)
527+ }
518528 if f.EventLog != nil {
519529 f.EventLog.Close(ctx)
520530 }
@@ -137,6 +137,8 @@ type forge struct {
137 // Inbox serves the owner's inbox of the web.137 // Inbox serves the owner's inbox of the web.
138 Inbox *usecase.Inbox138 Inbox *usecase.Inbox
139 SigningKey *usecase.SigningKey139 SigningKey *usecase.SigningKey
140+ // Mergeability keeps the mergeability of the open merge requests up to date.
141+ Mergeability *usecase.Mergeability
140 // Signatures are the trusted keys and the commit signature checks142 // Signatures are the trusted keys and the commit signature checks
141 // (ADR 0030).143 // (ADR 0030).
142 Signatures *usecase.Signatures144 Signatures *usecase.Signatures
@@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
331 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {333 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
332 return nil, err334 return nil, err
333 }335 }
336+ f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
337+ Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
338+ })
339+ f.Pushes.WithMergeability(f.Mergeability)
334 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{340 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
335 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,341 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
336 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,342 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
337- CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),343+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
338 })344 })
345+ f.Mergeability.Start()
339 f.MergeRequests.WithVerifier(f.Signatures)346 f.MergeRequests.WithVerifier(f.Signatures)
340 f.Issues = usecase.NewIssues(usecase.IssuesConfig{347 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
341 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,348 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
@@ -515,6 +522,9 @@ func (f *forge) Close(ctx context.Context) {
515 if f.CI != nil {522 if f.CI != nil {
516 f.CI.Close(ctx)523 f.CI.Close(ctx)
517 }524 }
525+ if f.Mergeability != nil {
526+ f.Mergeability.Close(ctx)
527+ }
518 if f.EventLog != nil {528 if f.EventLog != nil {
519 f.EventLog.Close(ctx)529 f.EventLog.Close(ctx)
520 }530 }
M internal/ports/dto.go +3 -0
@@ -198,6 +198,9 @@ type MergeRequestPage struct {
198198 MergeRequests []domain.MergeRequest
199199 // NextPageToken is empty on the last page.
200200 NextPageToken string
201+ // Mergeability holds, by number, the mergeability of the open requests of
202+ // the page (nil when the server does not compute it).
203+ Mergeability map[int64]domain.Mergeability
201204 }
202205
203206 // ListIssuesParams selects a page of issues from an IssueStore.
@@ -198,6 +198,9 @@ type MergeRequestPage struct {
198 MergeRequests []domain.MergeRequest198 MergeRequests []domain.MergeRequest
199 // NextPageToken is empty on the last page.199 // NextPageToken is empty on the last page.
200 NextPageToken string200 NextPageToken string
201+ // Mergeability holds, by number, the mergeability of the open requests of
202+ // the page (nil when the server does not compute it).
203+ Mergeability map[int64]domain.Mergeability
201 }204 }
202 205
203 // ListIssuesParams selects a page of issues from an IssueStore.206 // ListIssuesParams selects a page of issues from an IssueStore.
M internal/usecase/events.go +4 -0
@@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
198198 return repoSnapshot(ev.Visibility, ev.CreatedAt)
199199 case domain.CommentResolved:
200200 return repoSnapshot(ev.Visibility, ev.CreatedAt)
201+ case domain.MergeRequestMergeability:
202+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
201203 case domain.RunUpdated:
202204 return repoSnapshot(ev.Visibility, ev.CreatedAt)
203205 case domain.JobUpdated:
@@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
541543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542544 case domain.CommentResolved:
543545 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
546+ case domain.MergeRequestMergeability:
547+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
544548 case domain.RunUpdated:
545549 // Home shows the CI state of each repository.
546550 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
198 return repoSnapshot(ev.Visibility, ev.CreatedAt)198 return repoSnapshot(ev.Visibility, ev.CreatedAt)
199 case domain.CommentResolved:199 case domain.CommentResolved:
200 return repoSnapshot(ev.Visibility, ev.CreatedAt)200 return repoSnapshot(ev.Visibility, ev.CreatedAt)
201+ case domain.MergeRequestMergeability:
202+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
201 case domain.RunUpdated:203 case domain.RunUpdated:
202 return repoSnapshot(ev.Visibility, ev.CreatedAt)204 return repoSnapshot(ev.Visibility, ev.CreatedAt)
203 case domain.JobUpdated:205 case domain.JobUpdated:
@@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542 case domain.CommentResolved:544 case domain.CommentResolved:
543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)545 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
546+ case domain.MergeRequestMergeability:
547+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
544 case domain.RunUpdated:548 case domain.RunUpdated:
545 // Home shows the CI state of each repository.549 // Home shows the CI state of each repository.
546 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)550 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
A internal/usecase/mergeability.go +359 -0
@@ -0,0 +1,359 @@
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+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+const (
17+ defaultMergeabilityWorkers = 2
18+ defaultMergeabilityQueue = 1024
19+ // mergeabilityAttempts bounds the computations of one check that lose a
20+ // race with a moving branch or a concurrent write; the check is then queued
21+ // again.
22+ mergeabilityAttempts = 3
23+)
24+
25+// MergeabilityConfig holds what the mergeability service needs. Workers and
26+// Queue default to 2 and 1024.
27+type MergeabilityConfig struct {
28+ Store ports.MergeRequestStore
29+ Meta ports.RepoMetaStore
30+ Git ports.MergeGit
31+ Bus ports.EventBus
32+ Clock ports.Clock
33+ // Workers is the number of checks that run at once, Queue the number of
34+ // checks that may wait; past it a repository is rescanned.
35+ Workers, Queue int
36+}
37+
38+// Mergeability keeps up to date whether each open merge request merges with
39+// its target (see domain.Mergeability). It is independent of CI: it never
40+// touches refs/pull, workflows or runs. It computes with Git reads only, holds
41+// no repository lock, and stores its result with a compare-and-swap.
42+type Mergeability struct {
43+ cfg MergeabilityConfig
44+ q *mergeQueue
45+ ctx context.Context
46+ cancel context.CancelFunc
47+ wg sync.WaitGroup
48+}
49+
50+// MergeabilityHook is what the merge request use cases need from the
51+// mergeability service: queue checks, read results and drop the record of a
52+// request that is no longer open.
53+type MergeabilityHook interface {
54+ ports.MergeabilityTrigger
55+ ports.MergeabilityReader
56+ // OnClosed deletes the record of a merge request that left the open state.
57+ OnClosed(ctx context.Context, mr domain.MergeRequest)
58+}
59+
60+var _ MergeabilityHook = (*Mergeability)(nil)
61+
62+// NewMergeability builds the service. Call Start to run it, Close to stop it.
63+func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability {
64+ if cfg.Workers <= 0 {
65+ cfg.Workers = defaultMergeabilityWorkers
66+ }
67+ if cfg.Queue <= 0 {
68+ cfg.Queue = defaultMergeabilityQueue
69+ }
70+ wctx, cancel := context.WithCancel(ctx)
71+ return &Mergeability{cfg: cfg, q: newMergeQueue(cfg.Queue), ctx: wctx, cancel: cancel}
72+}
73+
74+// Start rescans the open merge requests of every repository (the records may
75+// be missing or stale after a restart) and starts the workers.
76+func (s *Mergeability) Start() {
77+ s.rescanAll()
78+ for range s.cfg.Workers {
79+ s.wg.Add(1)
80+ go s.loop()
81+ }
82+}
83+
84+// Close stops the workers and waits for them.
85+func (s *Mergeability) Close(_ context.Context) {
86+ s.cancel()
87+ s.wg.Wait()
88+}
89+
90+func (s *Mergeability) rescanAll() {
91+ repos, err := s.cfg.Meta.List(s.ctx)
92+ if err != nil {
93+ ctxLog(s.ctx).Error().Err(err).Msg("mergeability: list the repositories to rescan")
94+ return
95+ }
96+ for _, r := range repos {
97+ n, err := s.cfg.Store.CountOpen(s.ctx, r.Name)
98+ if err != nil {
99+ ctxLog(s.ctx).Error().Err(err).Str("repo", r.Name.String()).Msg("mergeability: count the open merge requests")
100+ s.q.markDirty(r.Name)
101+ continue
102+ }
103+ if n > 0 {
104+ s.q.markDirty(r.Name)
105+ }
106+ }
107+}
108+
109+// OnOpened implements ports.MergeabilityTrigger.
110+func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) {
111+ s.q.add(mergeKey{mr.Repo, mr.Number})
112+}
113+
114+// OnClosed deletes the record of a merge request that is no longer open. The
115+// store already drops it when the request leaves the open state; this makes it
116+// explicit and is idempotent.
117+func (s *Mergeability) OnClosed(ctx context.Context, mr domain.MergeRequest) {
118+ if err := s.cfg.Store.DeleteMergeability(context.WithoutCancel(ctx), mr.Repo, mr.Number); err != nil {
119+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a closed merge request")
120+ }
121+}
122+
123+// OnBranchesMoved implements ports.MergeabilityTrigger: the open merge
124+// requests whose source or target is one of the branches are queued. Past the
125+// bound, or when the lookup fails, the repository is rescanned.
126+func (s *Mergeability) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
127+ var branches []string
128+ for _, u := range updates {
129+ if b, ok := strings.CutPrefix(u.Ref, "refs/heads/"); ok && !slices.Contains(branches, b) {
130+ branches = append(branches, b)
131+ }
132+ }
133+ if len(branches) == 0 {
134+ return
135+ }
136+ lookups := []func(context.Context, domain.RepoName, []string, int) ([]int64, bool, error){
137+ s.cfg.Store.OpenBySource, s.cfg.Store.OpenByTarget,
138+ }
139+ for _, lookup := range lookups {
140+ numbers, truncated, err := lookup(ctx, repo, branches, s.cfg.Queue)
141+ if err != nil || truncated {
142+ if err != nil {
143+ ctxLog(ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: look up the merge requests of moved branches: the repository is rescanned")
144+ }
145+ s.q.markDirty(repo)
146+ }
147+ for _, n := range numbers {
148+ s.q.add(mergeKey{repo, n})
149+ }
150+ }
151+}
152+
153+// Read implements ports.MergeabilityReader.
154+func (s *Mergeability) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
155+ checking := domain.Mergeability{Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityChecking}
156+ if mr.HeadID.IsZero() || targetTip.IsZero() {
157+ return checking // a branch is gone: there is nothing to check
158+ }
159+ rec, err := s.cfg.Store.GetMergeability(ctx, mr.Repo, mr.Number)
160+ if err == nil && rec.IsFor(targetTip, mr.HeadID) {
161+ return rec
162+ }
163+ k := mergeKey{mr.Repo, mr.Number}
164+ s.q.markSeen(k)
165+ s.q.add(k)
166+ return checking
167+}
168+
169+func (s *Mergeability) loop() {
170+ defer s.wg.Done()
171+ for {
172+ key, dirty := s.q.next()
173+ switch {
174+ case key != nil:
175+ s.runKey(*key)
176+ case dirty != nil:
177+ s.rescan(*dirty)
178+ default:
179+ select {
180+ case <-s.ctx.Done():
181+ return
182+ case <-s.q.wake:
183+ }
184+ }
185+ if s.ctx.Err() != nil {
186+ return
187+ }
188+ }
189+}
190+
191+// runKey checks k, which is marked running, as often as it was queued again
192+// meanwhile.
193+func (s *Mergeability) runKey(k mergeKey) {
194+ for {
195+ s.check(k)
196+ if !s.q.finish(k) || s.ctx.Err() != nil {
197+ return
198+ }
199+ }
200+}
201+
202+// rescan checks every open merge request of a repository, here and not through
203+// the bounded queue, so that a rescan never marks its repository dirty again.
204+func (s *Mergeability) rescan(repo domain.RepoName) {
205+ mrs, err := s.cfg.Store.ListOpen(s.ctx, repo)
206+ if err != nil {
207+ ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: list the open merge requests to rescan")
208+ return
209+ }
210+ for _, mr := range mrs {
211+ if s.ctx.Err() != nil {
212+ return
213+ }
214+ k := mergeKey{repo, mr.Number}
215+ if s.q.tryRun(k) {
216+ s.runKey(k)
217+ }
218+ }
219+}
220+
221+// Check computes the mergeability of a merge request now, on the calling
222+// goroutine, as a worker does. It is for tests; the server never calls it.
223+func (s *Mergeability) Check(repo domain.RepoName, number int64) {
224+ k := mergeKey{repo, number}
225+ if s.q.tryRun(k) {
226+ s.runKey(k)
227+ }
228+}
229+
230+// check brings the record of k up to date.
231+func (s *Mergeability) check(k mergeKey) {
232+ ctx := s.ctx
233+ log := ctxLog(ctx).With().Str("repo", k.repo.String()).Int64("number", k.number).Logger()
234+ for range mergeabilityAttempts {
235+ mr, err := s.cfg.Store.Get(ctx, k.repo, k.number)
236+ if err != nil {
237+ if !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
238+ log.Error().Err(err).Msg("mergeability: read the merge request")
239+ }
240+ return
241+ }
242+ if !mr.IsOpen() {
243+ return // the store dropped the record when it left the open state
244+ }
245+ head, tip, ok := s.tips(ctx, mr)
246+ if !ok {
247+ return
248+ }
249+ prev, err := s.cfg.Store.GetMergeability(ctx, k.repo, k.number)
250+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
251+ log.Error().Err(err).Msg("mergeability: read the record")
252+ return
253+ }
254+ if prev.IsFor(tip, head) {
255+ s.q.takeSeen(k)
256+ return
257+ }
258+ res, err := s.compute(ctx, k, tip, head)
259+ if err != nil {
260+ if ctx.Err() == nil {
261+ log.Error().Err(err).Msg("mergeability: compute")
262+ }
263+ return
264+ }
265+ // The branches may have moved during the computation: the result is
266+ // then for tips nobody asks about, and is dropped.
267+ if h2, t2, ok := s.tips(ctx, mr); !ok {
268+ return
269+ } else if h2 != head || t2 != tip {
270+ continue
271+ }
272+ res.Version = prev.Version
273+ switch err := s.cfg.Store.PutMergeability(ctx, res); {
274+ case errors.Is(err, domain.ErrConflict):
275+ continue
276+ case errors.Is(err, domain.ErrNotFound):
277+ return // closed meanwhile
278+ case err != nil:
279+ log.Error().Err(err).Msg("mergeability: store the record")
280+ return
281+ }
282+ seen := s.q.takeSeen(k)
283+ if seen || prev.State == "" || changedMergeability(prev, res) {
284+ s.publish(ctx, res)
285+ }
286+ return
287+ }
288+ s.q.add(k) // kept losing races: try again later
289+}
290+
291+// tips reads the heads of the source and the target of mr. ok is false when
292+// the check is over: a branch is gone (the record is deleted) or Git failed.
293+func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok bool) {
294+ head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
295+ tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
296+ switch {
297+ case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
298+ if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil && ctx.Err() == nil {
299+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
300+ }
301+ return domain.ObjectID{}, domain.ObjectID{}, false
302+ case herr != nil || terr != nil:
303+ if ctx.Err() == nil {
304+ ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
305+ }
306+ return domain.ObjectID{}, domain.ObjectID{}, false
307+ }
308+ return head, tip, true
309+}
310+
311+// compute runs the Git reads that decide the state of head against tip.
312+func (s *Mergeability) compute(ctx context.Context, k mergeKey, tip, head domain.ObjectID) (domain.Mergeability, error) {
313+ m := domain.Mergeability{Repo: k.repo, Number: k.number, TargetID: tip, HeadID: head, CheckedAt: s.cfg.Clock.Now().UTC()}
314+ ancestor := tip == head
315+ if !ancestor {
316+ var err error
317+ if ancestor, err = s.cfg.Git.IsAncestor(ctx, k.repo, tip, head); err != nil {
318+ return m, err
319+ }
320+ }
321+ if ancestor {
322+ m.State = domain.MergeabilityUpToDate
323+ return m, nil
324+ }
325+ tree, err := s.cfg.Git.MergeTree(ctx, k.repo, tip, head)
326+ if err != nil {
327+ return m, err
328+ }
329+ if !tree.Clean {
330+ m.State = domain.MergeabilityConflict
331+ m.ConflictPaths, m.ConflictPathsTruncated = tree.ConflictPaths, tree.Truncated
332+ return m, nil
333+ }
334+ m.State = domain.MergeabilityBehind
335+ if m.Behind, m.BehindCapped, err = s.cfg.Git.CountBehind(ctx, k.repo, head, tip, domain.MaxBehindCount); err != nil {
336+ return m, err
337+ }
338+ return m, nil
339+}
340+
341+// changedMergeability reports whether what a reader shows differs.
342+func changedMergeability(a, b domain.Mergeability) bool {
343+ return a.State != b.State || a.Behind != b.Behind || a.BehindCapped != b.BehindCapped ||
344+ a.ConflictPathsTruncated != b.ConflictPathsTruncated || !slices.Equal(a.ConflictPaths, b.ConflictPaths)
345+}
346+
347+func (s *Mergeability) publish(ctx context.Context, m domain.Mergeability) {
348+ repo, err := s.cfg.Meta.Get(ctx, m.Repo)
349+ if err != nil {
350+ if !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
351+ ctxLog(ctx).Error().Err(err).Str("repo", m.Repo.String()).Msg("mergeability: read the repository to publish a result")
352+ }
353+ return
354+ }
355+ s.cfg.Bus.Publish(ctx, domain.MergeRequestMergeability{
356+ Repo: m.Repo, Number: m.Number, Mergeability: m, At: m.CheckedAt,
357+ Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
358+ })
359+}
@@ -0,0 +1,359 @@
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+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+const (
17+ defaultMergeabilityWorkers = 2
18+ defaultMergeabilityQueue = 1024
19+ // mergeabilityAttempts bounds the computations of one check that lose a
20+ // race with a moving branch or a concurrent write; the check is then queued
21+ // again.
22+ mergeabilityAttempts = 3
23+)
24+
25+// MergeabilityConfig holds what the mergeability service needs. Workers and
26+// Queue default to 2 and 1024.
27+type MergeabilityConfig struct {
28+ Store ports.MergeRequestStore
29+ Meta ports.RepoMetaStore
30+ Git ports.MergeGit
31+ Bus ports.EventBus
32+ Clock ports.Clock
33+ // Workers is the number of checks that run at once, Queue the number of
34+ // checks that may wait; past it a repository is rescanned.
35+ Workers, Queue int
36+}
37+
38+// Mergeability keeps up to date whether each open merge request merges with
39+// its target (see domain.Mergeability). It is independent of CI: it never
40+// touches refs/pull, workflows or runs. It computes with Git reads only, holds
41+// no repository lock, and stores its result with a compare-and-swap.
42+type Mergeability struct {
43+ cfg MergeabilityConfig
44+ q *mergeQueue
45+ ctx context.Context
46+ cancel context.CancelFunc
47+ wg sync.WaitGroup
48+}
49+
50+// MergeabilityHook is what the merge request use cases need from the
51+// mergeability service: queue checks, read results and drop the record of a
52+// request that is no longer open.
53+type MergeabilityHook interface {
54+ ports.MergeabilityTrigger
55+ ports.MergeabilityReader
56+ // OnClosed deletes the record of a merge request that left the open state.
57+ OnClosed(ctx context.Context, mr domain.MergeRequest)
58+}
59+
60+var _ MergeabilityHook = (*Mergeability)(nil)
61+
62+// NewMergeability builds the service. Call Start to run it, Close to stop it.
63+func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability {
64+ if cfg.Workers <= 0 {
65+ cfg.Workers = defaultMergeabilityWorkers
66+ }
67+ if cfg.Queue <= 0 {
68+ cfg.Queue = defaultMergeabilityQueue
69+ }
70+ wctx, cancel := context.WithCancel(ctx)
71+ return &Mergeability{cfg: cfg, q: newMergeQueue(cfg.Queue), ctx: wctx, cancel: cancel}
72+}
73+
74+// Start rescans the open merge requests of every repository (the records may
75+// be missing or stale after a restart) and starts the workers.
76+func (s *Mergeability) Start() {
77+ s.rescanAll()
78+ for range s.cfg.Workers {
79+ s.wg.Add(1)
80+ go s.loop()
81+ }
82+}
83+
84+// Close stops the workers and waits for them.
85+func (s *Mergeability) Close(_ context.Context) {
86+ s.cancel()
87+ s.wg.Wait()
88+}
89+
90+func (s *Mergeability) rescanAll() {
91+ repos, err := s.cfg.Meta.List(s.ctx)
92+ if err != nil {
93+ ctxLog(s.ctx).Error().Err(err).Msg("mergeability: list the repositories to rescan")
94+ return
95+ }
96+ for _, r := range repos {
97+ n, err := s.cfg.Store.CountOpen(s.ctx, r.Name)
98+ if err != nil {
99+ ctxLog(s.ctx).Error().Err(err).Str("repo", r.Name.String()).Msg("mergeability: count the open merge requests")
100+ s.q.markDirty(r.Name)
101+ continue
102+ }
103+ if n > 0 {
104+ s.q.markDirty(r.Name)
105+ }
106+ }
107+}
108+
109+// OnOpened implements ports.MergeabilityTrigger.
110+func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) {
111+ s.q.add(mergeKey{mr.Repo, mr.Number})
112+}
113+
114+// OnClosed deletes the record of a merge request that is no longer open. The
115+// store already drops it when the request leaves the open state; this makes it
116+// explicit and is idempotent.
117+func (s *Mergeability) OnClosed(ctx context.Context, mr domain.MergeRequest) {
118+ if err := s.cfg.Store.DeleteMergeability(context.WithoutCancel(ctx), mr.Repo, mr.Number); err != nil {
119+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a closed merge request")
120+ }
121+}
122+
123+// OnBranchesMoved implements ports.MergeabilityTrigger: the open merge
124+// requests whose source or target is one of the branches are queued. Past the
125+// bound, or when the lookup fails, the repository is rescanned.
126+func (s *Mergeability) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
127+ var branches []string
128+ for _, u := range updates {
129+ if b, ok := strings.CutPrefix(u.Ref, "refs/heads/"); ok && !slices.Contains(branches, b) {
130+ branches = append(branches, b)
131+ }
132+ }
133+ if len(branches) == 0 {
134+ return
135+ }
136+ lookups := []func(context.Context, domain.RepoName, []string, int) ([]int64, bool, error){
137+ s.cfg.Store.OpenBySource, s.cfg.Store.OpenByTarget,
138+ }
139+ for _, lookup := range lookups {
140+ numbers, truncated, err := lookup(ctx, repo, branches, s.cfg.Queue)
141+ if err != nil || truncated {
142+ if err != nil {
143+ ctxLog(ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: look up the merge requests of moved branches: the repository is rescanned")
144+ }
145+ s.q.markDirty(repo)
146+ }
147+ for _, n := range numbers {
148+ s.q.add(mergeKey{repo, n})
149+ }
150+ }
151+}
152+
153+// Read implements ports.MergeabilityReader.
154+func (s *Mergeability) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
155+ checking := domain.Mergeability{Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityChecking}
156+ if mr.HeadID.IsZero() || targetTip.IsZero() {
157+ return checking // a branch is gone: there is nothing to check
158+ }
159+ rec, err := s.cfg.Store.GetMergeability(ctx, mr.Repo, mr.Number)
160+ if err == nil && rec.IsFor(targetTip, mr.HeadID) {
161+ return rec
162+ }
163+ k := mergeKey{mr.Repo, mr.Number}
164+ s.q.markSeen(k)
165+ s.q.add(k)
166+ return checking
167+}
168+
169+func (s *Mergeability) loop() {
170+ defer s.wg.Done()
171+ for {
172+ key, dirty := s.q.next()
173+ switch {
174+ case key != nil:
175+ s.runKey(*key)
176+ case dirty != nil:
177+ s.rescan(*dirty)
178+ default:
179+ select {
180+ case <-s.ctx.Done():
181+ return
182+ case <-s.q.wake:
183+ }
184+ }
185+ if s.ctx.Err() != nil {
186+ return
187+ }
188+ }
189+}
190+
191+// runKey checks k, which is marked running, as often as it was queued again
192+// meanwhile.
193+func (s *Mergeability) runKey(k mergeKey) {
194+ for {
195+ s.check(k)
196+ if !s.q.finish(k) || s.ctx.Err() != nil {
197+ return
198+ }
199+ }
200+}
201+
202+// rescan checks every open merge request of a repository, here and not through
203+// the bounded queue, so that a rescan never marks its repository dirty again.
204+func (s *Mergeability) rescan(repo domain.RepoName) {
205+ mrs, err := s.cfg.Store.ListOpen(s.ctx, repo)
206+ if err != nil {
207+ ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: list the open merge requests to rescan")
208+ return
209+ }
210+ for _, mr := range mrs {
211+ if s.ctx.Err() != nil {
212+ return
213+ }
214+ k := mergeKey{repo, mr.Number}
215+ if s.q.tryRun(k) {
216+ s.runKey(k)
217+ }
218+ }
219+}
220+
221+// Check computes the mergeability of a merge request now, on the calling
222+// goroutine, as a worker does. It is for tests; the server never calls it.
223+func (s *Mergeability) Check(repo domain.RepoName, number int64) {
224+ k := mergeKey{repo, number}
225+ if s.q.tryRun(k) {
226+ s.runKey(k)
227+ }
228+}
229+
230+// check brings the record of k up to date.
231+func (s *Mergeability) check(k mergeKey) {
232+ ctx := s.ctx
233+ log := ctxLog(ctx).With().Str("repo", k.repo.String()).Int64("number", k.number).Logger()
234+ for range mergeabilityAttempts {
235+ mr, err := s.cfg.Store.Get(ctx, k.repo, k.number)
236+ if err != nil {
237+ if !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
238+ log.Error().Err(err).Msg("mergeability: read the merge request")
239+ }
240+ return
241+ }
242+ if !mr.IsOpen() {
243+ return // the store dropped the record when it left the open state
244+ }
245+ head, tip, ok := s.tips(ctx, mr)
246+ if !ok {
247+ return
248+ }
249+ prev, err := s.cfg.Store.GetMergeability(ctx, k.repo, k.number)
250+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
251+ log.Error().Err(err).Msg("mergeability: read the record")
252+ return
253+ }
254+ if prev.IsFor(tip, head) {
255+ s.q.takeSeen(k)
256+ return
257+ }
258+ res, err := s.compute(ctx, k, tip, head)
259+ if err != nil {
260+ if ctx.Err() == nil {
261+ log.Error().Err(err).Msg("mergeability: compute")
262+ }
263+ return
264+ }
265+ // The branches may have moved during the computation: the result is
266+ // then for tips nobody asks about, and is dropped.
267+ if h2, t2, ok := s.tips(ctx, mr); !ok {
268+ return
269+ } else if h2 != head || t2 != tip {
270+ continue
271+ }
272+ res.Version = prev.Version
273+ switch err := s.cfg.Store.PutMergeability(ctx, res); {
274+ case errors.Is(err, domain.ErrConflict):
275+ continue
276+ case errors.Is(err, domain.ErrNotFound):
277+ return // closed meanwhile
278+ case err != nil:
279+ log.Error().Err(err).Msg("mergeability: store the record")
280+ return
281+ }
282+ seen := s.q.takeSeen(k)
283+ if seen || prev.State == "" || changedMergeability(prev, res) {
284+ s.publish(ctx, res)
285+ }
286+ return
287+ }
288+ s.q.add(k) // kept losing races: try again later
289+}
290+
291+// tips reads the heads of the source and the target of mr. ok is false when
292+// the check is over: a branch is gone (the record is deleted) or Git failed.
293+func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok bool) {
294+ head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
295+ tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
296+ switch {
297+ case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
298+ if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil && ctx.Err() == nil {
299+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
300+ }
301+ return domain.ObjectID{}, domain.ObjectID{}, false
302+ case herr != nil || terr != nil:
303+ if ctx.Err() == nil {
304+ ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
305+ }
306+ return domain.ObjectID{}, domain.ObjectID{}, false
307+ }
308+ return head, tip, true
309+}
310+
311+// compute runs the Git reads that decide the state of head against tip.
312+func (s *Mergeability) compute(ctx context.Context, k mergeKey, tip, head domain.ObjectID) (domain.Mergeability, error) {
313+ m := domain.Mergeability{Repo: k.repo, Number: k.number, TargetID: tip, HeadID: head, CheckedAt: s.cfg.Clock.Now().UTC()}
314+ ancestor := tip == head
315+ if !ancestor {
316+ var err error
317+ if ancestor, err = s.cfg.Git.IsAncestor(ctx, k.repo, tip, head); err != nil {
318+ return m, err
319+ }
320+ }
321+ if ancestor {
322+ m.State = domain.MergeabilityUpToDate
323+ return m, nil
324+ }
325+ tree, err := s.cfg.Git.MergeTree(ctx, k.repo, tip, head)
326+ if err != nil {
327+ return m, err
328+ }
329+ if !tree.Clean {
330+ m.State = domain.MergeabilityConflict
331+ m.ConflictPaths, m.ConflictPathsTruncated = tree.ConflictPaths, tree.Truncated
332+ return m, nil
333+ }
334+ m.State = domain.MergeabilityBehind
335+ if m.Behind, m.BehindCapped, err = s.cfg.Git.CountBehind(ctx, k.repo, head, tip, domain.MaxBehindCount); err != nil {
336+ return m, err
337+ }
338+ return m, nil
339+}
340+
341+// changedMergeability reports whether what a reader shows differs.
342+func changedMergeability(a, b domain.Mergeability) bool {
343+ return a.State != b.State || a.Behind != b.Behind || a.BehindCapped != b.BehindCapped ||
344+ a.ConflictPathsTruncated != b.ConflictPathsTruncated || !slices.Equal(a.ConflictPaths, b.ConflictPaths)
345+}
346+
347+func (s *Mergeability) publish(ctx context.Context, m domain.Mergeability) {
348+ repo, err := s.cfg.Meta.Get(ctx, m.Repo)
349+ if err != nil {
350+ if !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
351+ ctxLog(ctx).Error().Err(err).Str("repo", m.Repo.String()).Msg("mergeability: read the repository to publish a result")
352+ }
353+ return
354+ }
355+ s.cfg.Bus.Publish(ctx, domain.MergeRequestMergeability{
356+ Repo: m.Repo, Number: m.Number, Mergeability: m, At: m.CheckedAt,
357+ Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
358+ })
359+}
A internal/usecase/mergeability_queue.go +146 -0
@@ -0,0 +1,146 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "sync"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
10+
11+// mergeKey names one open merge request.
12+type mergeKey struct {
13+ repo domain.RepoName
14+ number int64
15+}
16+
17+// mergeQueue is the bounded work queue of the mergeability checks: a key
18+// waits at most once (pending), is checked by one worker at a time (running),
19+// and a key queued while it runs is checked again afterwards (again). When
20+// the queue is full the repository is marked dirty instead: a worker then
21+// rescans all its open merge requests.
22+type mergeQueue struct {
23+ mu sync.Mutex
24+ max int
25+ pending map[mergeKey]bool
26+ order []mergeKey
27+ running map[mergeKey]bool
28+ again map[mergeKey]bool
29+ dirty map[domain.RepoName]bool
30+ dirtyOrder []domain.RepoName
31+ // seen holds the keys a reader answered "checking" for: the result of
32+ // their next check is published whatever it is.
33+ seen map[mergeKey]bool
34+ wake chan struct{}
35+}
36+
37+func newMergeQueue(max int) *mergeQueue {
38+ return &mergeQueue{
39+ max: max, pending: map[mergeKey]bool{}, running: map[mergeKey]bool{}, again: map[mergeKey]bool{},
40+ dirty: map[domain.RepoName]bool{}, seen: map[mergeKey]bool{}, wake: make(chan struct{}, 1),
41+ }
42+}
43+
44+func (q *mergeQueue) signal() {
45+ select {
46+ case q.wake <- struct{}{}:
47+ default:
48+ }
49+}
50+
51+// add queues a check. The caller does not hold mu.
52+func (q *mergeQueue) add(k mergeKey) {
53+ q.mu.Lock()
54+ switch {
55+ case q.running[k]:
56+ q.again[k] = true
57+ case q.pending[k]:
58+ case len(q.pending) < q.max:
59+ q.pending[k] = true
60+ q.order = append(q.order, k)
61+ default:
62+ q.markDirtyLocked(k.repo)
63+ }
64+ q.mu.Unlock()
65+ q.signal()
66+}
67+
68+func (q *mergeQueue) markDirty(repo domain.RepoName) {
69+ q.mu.Lock()
70+ q.markDirtyLocked(repo)
71+ q.mu.Unlock()
72+ q.signal()
73+}
74+
75+func (q *mergeQueue) markDirtyLocked(repo domain.RepoName) {
76+ if !q.dirty[repo] {
77+ q.dirty[repo] = true
78+ q.dirtyOrder = append(q.dirtyOrder, repo)
79+ }
80+}
81+
82+// next takes the next check, which it marks running, or the next dirty
83+// repository to rescan; pending checks first.
84+func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {
85+ q.mu.Lock()
86+ defer q.mu.Unlock()
87+ switch {
88+ case len(q.order) > 0:
89+ k := q.order[0]
90+ q.order = q.order[1:]
91+ delete(q.pending, k)
92+ q.running[k] = true
93+ key = &k
94+ case len(q.dirtyOrder) > 0:
95+ r := q.dirtyOrder[0]
96+ q.dirtyOrder = q.dirtyOrder[1:]
97+ delete(q.dirty, r)
98+ dirty = &r
99+ }
100+ if len(q.order) > 0 || len(q.dirtyOrder) > 0 {
101+ q.signal() // let another worker take the rest
102+ }
103+ return key, dirty
104+}
105+
106+// tryRun marks k running for a rescan; when it already runs it asks for
107+// another pass instead and reports false.
108+func (q *mergeQueue) tryRun(k mergeKey) bool {
109+ q.mu.Lock()
110+ defer q.mu.Unlock()
111+ if q.running[k] {
112+ q.again[k] = true
113+ return false
114+ }
115+ delete(q.pending, k) // a pending check is covered by this run
116+ q.running[k] = true
117+ return true
118+}
119+
120+// finish ends a pass of a running key; it reports whether another is due.
121+func (q *mergeQueue) finish(k mergeKey) (again bool) {
122+ q.mu.Lock()
123+ defer q.mu.Unlock()
124+ if q.again[k] {
125+ delete(q.again, k)
126+ return true
127+ }
128+ delete(q.running, k)
129+ return false
130+}
131+
132+// markSeen records that a reader was told "checking" for k.
133+func (q *mergeQueue) markSeen(k mergeKey) {
134+ q.mu.Lock()
135+ q.seen[k] = true
136+ q.mu.Unlock()
137+}
138+
139+// takeSeen reports and clears the seen flag.
140+func (q *mergeQueue) takeSeen(k mergeKey) bool {
141+ q.mu.Lock()
142+ defer q.mu.Unlock()
143+ s := q.seen[k]
144+ delete(q.seen, k)
145+ return s
146+}
@@ -0,0 +1,146 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "sync"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
10+
11+// mergeKey names one open merge request.
12+type mergeKey struct {
13+ repo domain.RepoName
14+ number int64
15+}
16+
17+// mergeQueue is the bounded work queue of the mergeability checks: a key
18+// waits at most once (pending), is checked by one worker at a time (running),
19+// and a key queued while it runs is checked again afterwards (again). When
20+// the queue is full the repository is marked dirty instead: a worker then
21+// rescans all its open merge requests.
22+type mergeQueue struct {
23+ mu sync.Mutex
24+ max int
25+ pending map[mergeKey]bool
26+ order []mergeKey
27+ running map[mergeKey]bool
28+ again map[mergeKey]bool
29+ dirty map[domain.RepoName]bool
30+ dirtyOrder []domain.RepoName
31+ // seen holds the keys a reader answered "checking" for: the result of
32+ // their next check is published whatever it is.
33+ seen map[mergeKey]bool
34+ wake chan struct{}
35+}
36+
37+func newMergeQueue(max int) *mergeQueue {
38+ return &mergeQueue{
39+ max: max, pending: map[mergeKey]bool{}, running: map[mergeKey]bool{}, again: map[mergeKey]bool{},
40+ dirty: map[domain.RepoName]bool{}, seen: map[mergeKey]bool{}, wake: make(chan struct{}, 1),
41+ }
42+}
43+
44+func (q *mergeQueue) signal() {
45+ select {
46+ case q.wake <- struct{}{}:
47+ default:
48+ }
49+}
50+
51+// add queues a check. The caller does not hold mu.
52+func (q *mergeQueue) add(k mergeKey) {
53+ q.mu.Lock()
54+ switch {
55+ case q.running[k]:
56+ q.again[k] = true
57+ case q.pending[k]:
58+ case len(q.pending) < q.max:
59+ q.pending[k] = true
60+ q.order = append(q.order, k)
61+ default:
62+ q.markDirtyLocked(k.repo)
63+ }
64+ q.mu.Unlock()
65+ q.signal()
66+}
67+
68+func (q *mergeQueue) markDirty(repo domain.RepoName) {
69+ q.mu.Lock()
70+ q.markDirtyLocked(repo)
71+ q.mu.Unlock()
72+ q.signal()
73+}
74+
75+func (q *mergeQueue) markDirtyLocked(repo domain.RepoName) {
76+ if !q.dirty[repo] {
77+ q.dirty[repo] = true
78+ q.dirtyOrder = append(q.dirtyOrder, repo)
79+ }
80+}
81+
82+// next takes the next check, which it marks running, or the next dirty
83+// repository to rescan; pending checks first.
84+func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {
85+ q.mu.Lock()
86+ defer q.mu.Unlock()
87+ switch {
88+ case len(q.order) > 0:
89+ k := q.order[0]
90+ q.order = q.order[1:]
91+ delete(q.pending, k)
92+ q.running[k] = true
93+ key = &k
94+ case len(q.dirtyOrder) > 0:
95+ r := q.dirtyOrder[0]
96+ q.dirtyOrder = q.dirtyOrder[1:]
97+ delete(q.dirty, r)
98+ dirty = &r
99+ }
100+ if len(q.order) > 0 || len(q.dirtyOrder) > 0 {
101+ q.signal() // let another worker take the rest
102+ }
103+ return key, dirty
104+}
105+
106+// tryRun marks k running for a rescan; when it already runs it asks for
107+// another pass instead and reports false.
108+func (q *mergeQueue) tryRun(k mergeKey) bool {
109+ q.mu.Lock()
110+ defer q.mu.Unlock()
111+ if q.running[k] {
112+ q.again[k] = true
113+ return false
114+ }
115+ delete(q.pending, k) // a pending check is covered by this run
116+ q.running[k] = true
117+ return true
118+}
119+
120+// finish ends a pass of a running key; it reports whether another is due.
121+func (q *mergeQueue) finish(k mergeKey) (again bool) {
122+ q.mu.Lock()
123+ defer q.mu.Unlock()
124+ if q.again[k] {
125+ delete(q.again, k)
126+ return true
127+ }
128+ delete(q.running, k)
129+ return false
130+}
131+
132+// markSeen records that a reader was told "checking" for k.
133+func (q *mergeQueue) markSeen(k mergeKey) {
134+ q.mu.Lock()
135+ q.seen[k] = true
136+ q.mu.Unlock()
137+}
138+
139+// takeSeen reports and clears the seen flag.
140+func (q *mergeQueue) takeSeen(k mergeKey) bool {
141+ q.mu.Lock()
142+ defer q.mu.Unlock()
143+ s := q.seen[k]
144+ delete(q.seen, k)
145+ return s
146+}
A internal/usecase/mergeability_test.go +347 -0
@@ -0,0 +1,347 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
20+)
21+
22+func fakeOID(t *testing.T, c string) domain.ObjectID {
23+ t.Helper()
24+ return oidFrom(t, strings.Repeat(c, 40))
25+}
26+
27+// mergeRig runs the mergeability service over the real adapters of mrRig.
28+type mergeRig struct {
29+ *mrRig
30+ svc *usecase.Mergeability
31+ mev <-chan domain.Event
32+}
33+
34+func newMergeRig(t *testing.T) *mergeRig {
35+ t.Helper()
36+ r := newMRRig(t)
37+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
38+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2,
39+ })
40+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
41+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
42+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
43+ Mergeability: svc,
44+ })
45+ t.Cleanup(func() { svc.Close(context.Background()) })
46+ return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)}
47+}
48+
49+func (r *mergeRig) record(n int64) (domain.Mergeability, error) {
50+ return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n)
51+}
52+
53+func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability {
54+ r.t.Helper()
55+ var m domain.Mergeability
56+ require.Eventually(r.t, func() bool {
57+ var err error
58+ m, err = r.record(n)
59+ return err == nil && m.State == want && m.IsFor(target, head)
60+ }, 10*time.Second, 10*time.Millisecond, "mergeability of !%d did not become %s", n, want)
61+ return m
62+}
63+
64+// events returns the mergeability events published so far.
65+func (r *mergeRig) events() []domain.MergeRequestMergeability {
66+ var out []domain.MergeRequestMergeability
67+ for {
68+ select {
69+ case ev := <-r.mev:
70+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
71+ out = append(out, m)
72+ }
73+ default:
74+ return out
75+ }
76+ }
77+}
78+
79+func (r *mergeRig) push2(branch, path, content string) domain.ObjectID {
80+ r.t.Helper()
81+ r.sh("checkout", "-q", branch)
82+ id := r.commit(author, path, content, "edit "+path)
83+ r.push(branch)
84+ return id
85+}
86+
87+func TestMergeabilityStatesEndToEnd(t *testing.T) {
88+ r := newMergeRig(t)
89+ r.svc.Start()
90+ main := r.seed()
91+ head := r.feature("feat")
92+ mr := r.open("feat")
93+
94+ // up to date: the target is an ancestor of the head.
95+ m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
96+ assert.False(t, m.CheckedAt.IsZero())
97+
98+ // behind: the target moved with a change that merges cleanly.
99+ r.sh("checkout", "-q", "main")
100+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
101+ r.push("main")
102+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
103+ m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
104+ assert.Equal(t, 1, m.Behind)
105+ assert.False(t, m.BehindCapped)
106+
107+ // conflict: the target changes a line the head changed.
108+ main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a")
109+ r.push("main")
110+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}})
111+ m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head)
112+ assert.Equal(t, []string{"a.txt"}, m.ConflictPaths)
113+
114+ got := r.events()
115+ require.Len(t, got, 3)
116+ for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} {
117+ assert.Equal(t, s, got[i].Mergeability.State)
118+ assert.Equal(t, mr.Number, got[i].Number)
119+ }
120+}
121+
122+func TestMergeabilityReaderIsLazy(t *testing.T) {
123+ r := newMergeRig(t)
124+ main := r.seed()
125+ head := r.feature("feat")
126+ mr := r.open("feat") // queued, but the workers are not started
127+
128+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
129+ require.NoError(t, err)
130+ assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State)
131+
132+ r.svc.Start()
133+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
134+ d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
135+ require.NoError(t, err)
136+ assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State)
137+
138+ page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
139+ require.NoError(t, err)
140+ assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State)
141+}
142+
143+func TestMergeabilityTransitionOnlyPublication(t *testing.T) {
144+ r := newMergeRig(t)
145+ r.seed()
146+ r.feature("feat")
147+ mr := r.open("feat")
148+ r.svc.Check(r.repo, mr.Number)
149+ require.Len(t, r.events(), 1)
150+
151+ // Same tips: nothing is computed, nothing is published.
152+ r.svc.Check(r.repo, mr.Number)
153+ assert.Empty(t, r.events())
154+
155+ // The target moves without changing the state: a new record but, being the
156+ // same state and counts, no event.
157+ r.sh("checkout", "-q", "feat")
158+ r.commit(author, "e.txt", "e\n", "more")
159+ r.push("feat")
160+ r.svc.Check(r.repo, mr.Number)
161+ assert.Empty(t, r.events())
162+ m, err := r.record(mr.Number)
163+ require.NoError(t, err)
164+ assert.Equal(t, domain.MergeabilityUpToDate, m.State)
165+}
166+
167+func TestMergeabilityPublishesWhenReadersSawChecking(t *testing.T) {
168+ r := newMergeRig(t)
169+ r.seed()
170+ r.feature("feat")
171+ mr := r.open("feat")
172+ r.svc.Check(r.repo, mr.Number)
173+ require.Len(t, r.events(), 1)
174+
175+ r.sh("checkout", "-q", "feat")
176+ r.commit(author, "e.txt", "e\n", "more")
177+ r.push("feat")
178+ _, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) // sees "checking"
179+ require.NoError(t, err)
180+ r.svc.Check(r.repo, mr.Number)
181+ got := r.events()
182+ require.Len(t, got, 1)
183+ assert.Equal(t, domain.MergeabilityUpToDate, got[0].Mergeability.State)
184+}
185+
186+func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) {
187+ r := newMergeRig(t)
188+ r.svc.Start()
189+ main := r.seed()
190+ r.sh("checkout", "-q", "-B", "one", "main")
191+ oneHead := r.commit(author, "one.txt", "1\n", "one")
192+ r.push("one")
193+ r.sh("checkout", "-q", "-B", "two", "main")
194+ twoHead := r.commit(author, "two.txt", "2\n", "two")
195+ r.push("two")
196+ one, two := r.open("one"), r.open("two")
197+ r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead)
198+ r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead)
199+
200+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead})
201+ require.NoError(t, err)
202+ m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead)
203+ assert.Equal(t, 1, m.Behind)
204+ _, err = r.record(one.Number)
205+ assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record")
206+}
207+
208+func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) {
209+ r := newMergeRig(t)
210+ r.seed()
211+ r.feature("feat")
212+ mr := r.open("feat")
213+ r.svc.Check(r.repo, mr.Number)
214+ _, err := r.record(mr.Number)
215+ require.NoError(t, err)
216+
217+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
218+ require.NoError(t, err)
219+ _, err = r.record(mr.Number)
220+ assert.ErrorIs(t, err, domain.ErrNotFound)
221+ r.svc.Check(r.repo, mr.Number) // a late check writes nothing
222+ _, err = r.record(mr.Number)
223+ assert.ErrorIs(t, err, domain.ErrNotFound)
224+}
225+
226+func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) {
227+ r := newMergeRig(t)
228+ main := r.seed()
229+ head := r.feature("feat")
230+ mr := r.open("feat")
231+ _, err := r.record(mr.Number)
232+ require.ErrorIs(t, err, domain.ErrNotFound)
233+
234+ r.svc.Start()
235+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
236+}
237+
238+func TestMergeabilityNeedsNoCI(t *testing.T) {
239+ // The service is built without any CI: a target move still updates the
240+ // record, and the repository gets no pull ref (the service has no CI to create a run with).
241+ r := newMergeRig(t)
242+ r.svc.Start()
243+ main := r.seed()
244+ head := r.feature("feat")
245+ mr := r.open("feat")
246+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
247+ r.sh("checkout", "-q", "main")
248+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
249+ r.push("main")
250+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
251+ r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
252+
253+ assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull"))
254+}
255+
256+// --- unit tests over mocks ---
257+
258+type mockRig struct {
259+ store *mocks.MergeRequestStore
260+ meta *mocks.RepoMetaStore
261+ git *mocks.MergeGit
262+ bus *mocks.EventBus
263+ svc *usecase.Mergeability
264+ mr domain.MergeRequest
265+}
266+
267+func newMockRig(t *testing.T) *mockRig {
268+ t.Helper()
269+ repo := name(t, "proj")
270+ r := &mockRig{
271+ store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t),
272+ mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen},
273+ }
274+ r.store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(r.mr, nil).Maybe()
275+ r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe()
276+ r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
277+ r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe()
278+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
279+ Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{},
280+ })
281+ t.Cleanup(func() { r.svc.Close(context.Background()) })
282+ return r
283+}
284+
285+func TestMergeabilityStaleResultIsDiscarded(t *testing.T) {
286+ r := newMockRig(t)
287+ repo := r.mr.Repo
288+ head, t1, t2 := fakeOID(t, "a"), fakeOID(t, "b"), fakeOID(t, "c")
289+ var moved atomic.Bool
290+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
291+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").RunAndReturn(
292+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
293+ if moved.Load() {
294+ return t2, nil
295+ }
296+ return t1, nil
297+ })
298+ // The target moves while the first computation runs.
299+ r.git.EXPECT().IsAncestor(mock.Anything, repo, mock.Anything, head).RunAndReturn(
300+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
301+ moved.Store(true)
302+ return true, nil
303+ })
304+ var puts []domain.Mergeability
305+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
306+ func(_ context.Context, m domain.Mergeability) error { puts = append(puts, m); return nil })
307+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
308+
309+ r.svc.Check(repo, 7)
310+ require.Len(t, puts, 1, "the result for the old tip is dropped, the one for the new tip is stored")
311+ assert.Equal(t, t2, puts[0].TargetID)
312+}
313+
314+func TestMergeabilityDedupsTriggers(t *testing.T) {
315+ r := newMockRig(t)
316+ repo := r.mr.Repo
317+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
318+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
319+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
320+ var computes atomic.Int32
321+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn(
322+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
323+ computes.Add(1)
324+ return true, nil
325+ })
326+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
327+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
328+
329+ for range 50 {
330+ r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs
331+ }
332+ r.svc.Start()
333+ require.Eventually(t, func() bool { return computes.Load() >= 1 }, 5*time.Second, 5*time.Millisecond)
334+ time.Sleep(50 * time.Millisecond)
335+ assert.EqualValues(t, 1, computes.Load())
336+}
337+
338+func TestMergeabilityNoWriteForClosedRequest(t *testing.T) {
339+ r := newMockRig(t)
340+ repo := r.mr.Repo
341+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
342+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
343+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
344+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
345+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile
346+ r.svc.Check(repo, 7) // no event, no further write
347+}
@@ -0,0 +1,347 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
20+)
21+
22+func fakeOID(t *testing.T, c string) domain.ObjectID {
23+ t.Helper()
24+ return oidFrom(t, strings.Repeat(c, 40))
25+}
26+
27+// mergeRig runs the mergeability service over the real adapters of mrRig.
28+type mergeRig struct {
29+ *mrRig
30+ svc *usecase.Mergeability
31+ mev <-chan domain.Event
32+}
33+
34+func newMergeRig(t *testing.T) *mergeRig {
35+ t.Helper()
36+ r := newMRRig(t)
37+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
38+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2,
39+ })
40+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
41+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
42+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
43+ Mergeability: svc,
44+ })
45+ t.Cleanup(func() { svc.Close(context.Background()) })
46+ return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)}
47+}
48+
49+func (r *mergeRig) record(n int64) (domain.Mergeability, error) {
50+ return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n)
51+}
52+
53+func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability {
54+ r.t.Helper()
55+ var m domain.Mergeability
56+ require.Eventually(r.t, func() bool {
57+ var err error
58+ m, err = r.record(n)
59+ return err == nil && m.State == want && m.IsFor(target, head)
60+ }, 10*time.Second, 10*time.Millisecond, "mergeability of !%d did not become %s", n, want)
61+ return m
62+}
63+
64+// events returns the mergeability events published so far.
65+func (r *mergeRig) events() []domain.MergeRequestMergeability {
66+ var out []domain.MergeRequestMergeability
67+ for {
68+ select {
69+ case ev := <-r.mev:
70+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
71+ out = append(out, m)
72+ }
73+ default:
74+ return out
75+ }
76+ }
77+}
78+
79+func (r *mergeRig) push2(branch, path, content string) domain.ObjectID {
80+ r.t.Helper()
81+ r.sh("checkout", "-q", branch)
82+ id := r.commit(author, path, content, "edit "+path)
83+ r.push(branch)
84+ return id
85+}
86+
87+func TestMergeabilityStatesEndToEnd(t *testing.T) {
88+ r := newMergeRig(t)
89+ r.svc.Start()
90+ main := r.seed()
91+ head := r.feature("feat")
92+ mr := r.open("feat")
93+
94+ // up to date: the target is an ancestor of the head.
95+ m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
96+ assert.False(t, m.CheckedAt.IsZero())
97+
98+ // behind: the target moved with a change that merges cleanly.
99+ r.sh("checkout", "-q", "main")
100+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
101+ r.push("main")
102+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
103+ m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
104+ assert.Equal(t, 1, m.Behind)
105+ assert.False(t, m.BehindCapped)
106+
107+ // conflict: the target changes a line the head changed.
108+ main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a")
109+ r.push("main")
110+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}})
111+ m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head)
112+ assert.Equal(t, []string{"a.txt"}, m.ConflictPaths)
113+
114+ got := r.events()
115+ require.Len(t, got, 3)
116+ for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} {
117+ assert.Equal(t, s, got[i].Mergeability.State)
118+ assert.Equal(t, mr.Number, got[i].Number)
119+ }
120+}
121+
122+func TestMergeabilityReaderIsLazy(t *testing.T) {
123+ r := newMergeRig(t)
124+ main := r.seed()
125+ head := r.feature("feat")
126+ mr := r.open("feat") // queued, but the workers are not started
127+
128+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
129+ require.NoError(t, err)
130+ assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State)
131+
132+ r.svc.Start()
133+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
134+ d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
135+ require.NoError(t, err)
136+ assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State)
137+
138+ page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
139+ require.NoError(t, err)
140+ assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State)
141+}
142+
143+func TestMergeabilityTransitionOnlyPublication(t *testing.T) {
144+ r := newMergeRig(t)
145+ r.seed()
146+ r.feature("feat")
147+ mr := r.open("feat")
148+ r.svc.Check(r.repo, mr.Number)
149+ require.Len(t, r.events(), 1)
150+
151+ // Same tips: nothing is computed, nothing is published.
152+ r.svc.Check(r.repo, mr.Number)
153+ assert.Empty(t, r.events())
154+
155+ // The target moves without changing the state: a new record but, being the
156+ // same state and counts, no event.
157+ r.sh("checkout", "-q", "feat")
158+ r.commit(author, "e.txt", "e\n", "more")
159+ r.push("feat")
160+ r.svc.Check(r.repo, mr.Number)
161+ assert.Empty(t, r.events())
162+ m, err := r.record(mr.Number)
163+ require.NoError(t, err)
164+ assert.Equal(t, domain.MergeabilityUpToDate, m.State)
165+}
166+
167+func TestMergeabilityPublishesWhenReadersSawChecking(t *testing.T) {
168+ r := newMergeRig(t)
169+ r.seed()
170+ r.feature("feat")
171+ mr := r.open("feat")
172+ r.svc.Check(r.repo, mr.Number)
173+ require.Len(t, r.events(), 1)
174+
175+ r.sh("checkout", "-q", "feat")
176+ r.commit(author, "e.txt", "e\n", "more")
177+ r.push("feat")
178+ _, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) // sees "checking"
179+ require.NoError(t, err)
180+ r.svc.Check(r.repo, mr.Number)
181+ got := r.events()
182+ require.Len(t, got, 1)
183+ assert.Equal(t, domain.MergeabilityUpToDate, got[0].Mergeability.State)
184+}
185+
186+func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) {
187+ r := newMergeRig(t)
188+ r.svc.Start()
189+ main := r.seed()
190+ r.sh("checkout", "-q", "-B", "one", "main")
191+ oneHead := r.commit(author, "one.txt", "1\n", "one")
192+ r.push("one")
193+ r.sh("checkout", "-q", "-B", "two", "main")
194+ twoHead := r.commit(author, "two.txt", "2\n", "two")
195+ r.push("two")
196+ one, two := r.open("one"), r.open("two")
197+ r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead)
198+ r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead)
199+
200+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead})
201+ require.NoError(t, err)
202+ m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead)
203+ assert.Equal(t, 1, m.Behind)
204+ _, err = r.record(one.Number)
205+ assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record")
206+}
207+
208+func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) {
209+ r := newMergeRig(t)
210+ r.seed()
211+ r.feature("feat")
212+ mr := r.open("feat")
213+ r.svc.Check(r.repo, mr.Number)
214+ _, err := r.record(mr.Number)
215+ require.NoError(t, err)
216+
217+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
218+ require.NoError(t, err)
219+ _, err = r.record(mr.Number)
220+ assert.ErrorIs(t, err, domain.ErrNotFound)
221+ r.svc.Check(r.repo, mr.Number) // a late check writes nothing
222+ _, err = r.record(mr.Number)
223+ assert.ErrorIs(t, err, domain.ErrNotFound)
224+}
225+
226+func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) {
227+ r := newMergeRig(t)
228+ main := r.seed()
229+ head := r.feature("feat")
230+ mr := r.open("feat")
231+ _, err := r.record(mr.Number)
232+ require.ErrorIs(t, err, domain.ErrNotFound)
233+
234+ r.svc.Start()
235+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
236+}
237+
238+func TestMergeabilityNeedsNoCI(t *testing.T) {
239+ // The service is built without any CI: a target move still updates the
240+ // record, and the repository gets no pull ref (the service has no CI to create a run with).
241+ r := newMergeRig(t)
242+ r.svc.Start()
243+ main := r.seed()
244+ head := r.feature("feat")
245+ mr := r.open("feat")
246+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
247+ r.sh("checkout", "-q", "main")
248+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
249+ r.push("main")
250+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
251+ r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
252+
253+ assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull"))
254+}
255+
256+// --- unit tests over mocks ---
257+
258+type mockRig struct {
259+ store *mocks.MergeRequestStore
260+ meta *mocks.RepoMetaStore
261+ git *mocks.MergeGit
262+ bus *mocks.EventBus
263+ svc *usecase.Mergeability
264+ mr domain.MergeRequest
265+}
266+
267+func newMockRig(t *testing.T) *mockRig {
268+ t.Helper()
269+ repo := name(t, "proj")
270+ r := &mockRig{
271+ store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t),
272+ mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen},
273+ }
274+ r.store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(r.mr, nil).Maybe()
275+ r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe()
276+ r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
277+ r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe()
278+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
279+ Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{},
280+ })
281+ t.Cleanup(func() { r.svc.Close(context.Background()) })
282+ return r
283+}
284+
285+func TestMergeabilityStaleResultIsDiscarded(t *testing.T) {
286+ r := newMockRig(t)
287+ repo := r.mr.Repo
288+ head, t1, t2 := fakeOID(t, "a"), fakeOID(t, "b"), fakeOID(t, "c")
289+ var moved atomic.Bool
290+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
291+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").RunAndReturn(
292+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
293+ if moved.Load() {
294+ return t2, nil
295+ }
296+ return t1, nil
297+ })
298+ // The target moves while the first computation runs.
299+ r.git.EXPECT().IsAncestor(mock.Anything, repo, mock.Anything, head).RunAndReturn(
300+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
301+ moved.Store(true)
302+ return true, nil
303+ })
304+ var puts []domain.Mergeability
305+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
306+ func(_ context.Context, m domain.Mergeability) error { puts = append(puts, m); return nil })
307+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
308+
309+ r.svc.Check(repo, 7)
310+ require.Len(t, puts, 1, "the result for the old tip is dropped, the one for the new tip is stored")
311+ assert.Equal(t, t2, puts[0].TargetID)
312+}
313+
314+func TestMergeabilityDedupsTriggers(t *testing.T) {
315+ r := newMockRig(t)
316+ repo := r.mr.Repo
317+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
318+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
319+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
320+ var computes atomic.Int32
321+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn(
322+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
323+ computes.Add(1)
324+ return true, nil
325+ })
326+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
327+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
328+
329+ for range 50 {
330+ r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs
331+ }
332+ r.svc.Start()
333+ require.Eventually(t, func() bool { return computes.Load() >= 1 }, 5*time.Second, 5*time.Millisecond)
334+ time.Sleep(50 * time.Millisecond)
335+ assert.EqualValues(t, 1, computes.Load())
336+}
337+
338+func TestMergeabilityNoWriteForClosedRequest(t *testing.T) {
339+ r := newMockRig(t)
340+ repo := r.mr.Repo
341+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
342+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
343+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
344+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
345+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile
346+ r.svc.Check(repo, 7) // no event, no further write
347+}
M internal/usecase/mergerequests.go +37 -1
@@ -56,6 +56,7 @@ type MergeRequests struct {
5656 pulls ports.PullRequestTrigger // nil: no pull_request workflows
5757 tips ports.CIRefTipStore // nil: who merges is not recorded
5858 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
59+ mergeab MergeabilityHook // nil: no mergeability
5960
6061 mu sync.Mutex
6162 locks map[domain.RepoName]chan struct{}
@@ -95,13 +96,16 @@ type MergeRequestsConfig struct {
9596 // deleted source branch) before the merge does, as a push does
9697 // (domain.CIRefTip). Optional.
9798 RefTips ports.CIRefTipStore
99+ // Mergeability is queued when a request is opened, closed or merged and
100+ // read by Get and List. Optional: without it Mergeability stays zero.
101+ Mergeability MergeabilityHook
98102 }
99103
100104 // NewMergeRequests builds the merge request use cases.
101105 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
102106 return &MergeRequests{
103107 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
104- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,
108+ 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,
105109 locks: make(map[domain.RepoName]chan struct{}),
106110 }
107111 }
@@ -256,6 +260,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
256260 }
257261 recordActivity(ctx, m.meta, repo.Name, now)
258262 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
263+ if m.mergeab != nil {
264+ m.mergeab.OnOpened(ctx, mr)
265+ }
259266 if m.pulls != nil {
260267 // The commits the opening is for are the ones of now.
261268 base, _ := m.branchOrZero(ctx, name, target)
@@ -336,10 +343,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
336343 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
337344 },
338345 encodeMRToken)
346+ tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
339347 for i, mr := range page.MergeRequests {
340348 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
341349 return ports.MergeRequestPage{}, err
342350 }
351+ if m.mergeab == nil || !mr.IsOpen() {
352+ continue
353+ }
354+ tip, ok := tips[mr.Target]
355+ if !ok {
356+ if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
357+ return ports.MergeRequestPage{}, err
358+ }
359+ tips[mr.Target] = tip
360+ }
361+ if page.Mergeability == nil {
362+ page.Mergeability = map[int64]domain.Mergeability{}
363+ }
364+ page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
343365 }
344366 return page, nil
345367 }
@@ -467,6 +489,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
467489 return nil
468490 }
469491 d := ports.MergeRequestDetail{MergeRequest: mr}
492+ if m.mergeab != nil && mr.IsOpen() {
493+ tip, err := m.branchOrZero(ctx, name, mr.Target)
494+ if err != nil {
495+ return ports.MergeRequestDetail{}, err
496+ }
497+ d.Mergeability = m.mergeab.Read(ctx, mr, tip)
498+ }
470499 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
471500 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
472501 }
@@ -801,6 +830,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
801830 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802831 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
803832 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
833+ if m.mergeab != nil {
834+ m.mergeab.OnClosed(ctx, mr)
835+ }
804836 if m.pulls != nil {
805837 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
806838 }
@@ -1007,6 +1039,10 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10071039 m.bus.Publish(ctx, domain.PushEvent{
10081040 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
10091041 })
1042+ if m.mergeab != nil {
1043+ m.mergeab.OnClosed(ctx, mr)
1044+ m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1045+ }
10101046 if m.pulls != nil {
10111047 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
10121048 }
@@ -56,6 +56,7 @@ type MergeRequests struct {
56 pulls ports.PullRequestTrigger // nil: no pull_request workflows56 pulls ports.PullRequestTrigger // nil: no pull_request workflows
57 tips ports.CIRefTipStore // nil: who merges is not recorded57 tips ports.CIRefTipStore // nil: who merges is not recorded
58 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)58 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
59+ mergeab MergeabilityHook // nil: no mergeability
59 60
60 mu sync.Mutex61 mu sync.Mutex
61 locks map[domain.RepoName]chan struct{}62 locks map[domain.RepoName]chan struct{}
@@ -95,13 +96,16 @@ type MergeRequestsConfig struct {
95 // deleted source branch) before the merge does, as a push does96 // deleted source branch) before the merge does, as a push does
96 // (domain.CIRefTip). Optional.97 // (domain.CIRefTip). Optional.
97 RefTips ports.CIRefTipStore98 RefTips ports.CIRefTipStore
99+ // Mergeability is queued when a request is opened, closed or merged and
100+ // read by Get and List. Optional: without it Mergeability stays zero.
101+ Mergeability MergeabilityHook
98 }102 }
99 103
100 // NewMergeRequests builds the merge request use cases.104 // NewMergeRequests builds the merge request use cases.
101 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {105 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
102 return &MergeRequests{106 return &MergeRequests{
103 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,107 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
104- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,108+ 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,
105 locks: make(map[domain.RepoName]chan struct{}),109 locks: make(map[domain.RepoName]chan struct{}),
106 }110 }
107 }111 }
@@ -256,6 +260,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
256 }260 }
257 recordActivity(ctx, m.meta, repo.Name, now)261 recordActivity(ctx, m.meta, repo.Name, now)
258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})262 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
263+ if m.mergeab != nil {
264+ m.mergeab.OnOpened(ctx, mr)
265+ }
259 if m.pulls != nil {266 if m.pulls != nil {
260 // The commits the opening is for are the ones of now.267 // The commits the opening is for are the ones of now.
261 base, _ := m.branchOrZero(ctx, name, target)268 base, _ := m.branchOrZero(ctx, name, target)
@@ -336,10 +343,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
336 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)343 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
337 },344 },
338 encodeMRToken)345 encodeMRToken)
346+ tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
339 for i, mr := range page.MergeRequests {347 for i, mr := range page.MergeRequests {
340 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {348 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
341 return ports.MergeRequestPage{}, err349 return ports.MergeRequestPage{}, err
342 }350 }
351+ if m.mergeab == nil || !mr.IsOpen() {
352+ continue
353+ }
354+ tip, ok := tips[mr.Target]
355+ if !ok {
356+ if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
357+ return ports.MergeRequestPage{}, err
358+ }
359+ tips[mr.Target] = tip
360+ }
361+ if page.Mergeability == nil {
362+ page.Mergeability = map[int64]domain.Mergeability{}
363+ }
364+ page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
343 }365 }
344 return page, nil366 return page, nil
345 }367 }
@@ -467,6 +489,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
467 return nil489 return nil
468 }490 }
469 d := ports.MergeRequestDetail{MergeRequest: mr}491 d := ports.MergeRequestDetail{MergeRequest: mr}
492+ if m.mergeab != nil && mr.IsOpen() {
493+ tip, err := m.branchOrZero(ctx, name, mr.Target)
494+ if err != nil {
495+ return ports.MergeRequestDetail{}, err
496+ }
497+ d.Mergeability = m.mergeab.Read(ctx, mr, tip)
498+ }
470 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {499 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
471 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)500 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
472 }501 }
@@ -801,6 +830,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")830 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)831 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})832 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
833+ if m.mergeab != nil {
834+ m.mergeab.OnClosed(ctx, mr)
835+ }
804 if m.pulls != nil {836 if m.pulls != nil {
805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)837 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
806 }838 }
@@ -1007,6 +1039,10 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1007 m.bus.Publish(ctx, domain.PushEvent{1039 m.bus.Publish(ctx, domain.PushEvent{
1008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1040 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1009 })1041 })
1042+ if m.mergeab != nil {
1043+ m.mergeab.OnClosed(ctx, mr)
1044+ m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1045+ }
1010 if m.pulls != nil {1046 if m.pulls != nil {
1011 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)1047 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1012 }1048 }
M internal/usecase/pushes.go +23 -11
@@ -17,17 +17,18 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20- meta ports.RepoMetaStore
21- repos *Repos
22- git ports.GitRepos
23- bus ports.EventBus
24- clock ports.Clock
25- maint ports.MaintenanceScheduler
26- auth ports.Revalidator
27- fresh ports.RepoFreshener // nil: the reader needs no telling
28- ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
20+ meta ports.RepoMetaStore
21+ repos *Repos
22+ git ports.GitRepos
23+ bus ports.EventBus
24+ clock ports.Clock
25+ maint ports.MaintenanceScheduler
26+ auth ports.Revalidator
27+ fresh ports.RepoFreshener // nil: the reader needs no telling
28+ ci ports.WorkflowTrigger // nil: no CI
29+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3132 }
3233
3334 var (
@@ -56,6 +57,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
5657 return u
5758 }
5859
60+// WithMergeability makes Record queue the mergeability checks of the merge
61+// requests whose branches the push moved. Call it before the first push; it
62+// returns u.
63+func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
64+ u.mergeab = t
65+ return u
66+}
67+
5968 // WithRefTips makes Check record who is about to move each branch and tag
6069 // (domain.CIRefTip), before Git moves it. Call it before the first push; it
6170 // returns u.
@@ -230,6 +239,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
230239 if u.ci != nil {
231240 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
232241 }
242+ if u.mergeab != nil {
243+ u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
244+ }
233245 u.bus.Publish(ctx, domain.PushEvent{
234246 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
235247 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -17,17 +17,18 @@ 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- meta ports.RepoMetaStore20+ meta ports.RepoMetaStore
21- repos *Repos21+ repos *Repos
22- git ports.GitRepos22+ git ports.GitRepos
23- bus ports.EventBus23+ bus ports.EventBus
24- clock ports.Clock24+ clock ports.Clock
25- maint ports.MaintenanceScheduler25+ maint ports.MaintenanceScheduler
26- auth ports.Revalidator26+ auth ports.Revalidator
27- fresh ports.RepoFreshener // nil: the reader needs no telling27+ fresh ports.RepoFreshener // nil: the reader needs no telling
28- ci ports.WorkflowTrigger // nil: no CI28+ ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused29+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded30+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
31 }32 }
32 33
33 var (34 var (
@@ -56,6 +57,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
56 return u57 return u
57 }58 }
58 59
60+// WithMergeability makes Record queue the mergeability checks of the merge
61+// requests whose branches the push moved. Call it before the first push; it
62+// returns u.
63+func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
64+ u.mergeab = t
65+ return u
66+}
67+
59 // WithRefTips makes Check record who is about to move each branch and tag68 // WithRefTips makes Check record who is about to move each branch and tag
60 // (domain.CIRefTip), before Git moves it. Call it before the first push; it69 // (domain.CIRefTip), before Git moves it. Call it before the first push; it
61 // returns u.70 // returns u.
@@ -230,6 +239,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
230 if u.ci != nil {239 if u.ci != nil {
231 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push240 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
232 }241 }
242+ if u.mergeab != nil {
243+ u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
244+ }
233 u.bus.Publish(ctx, domain.PushEvent{245 u.bus.Publish(ctx, domain.PushEvent{
234 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,246 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
235 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,247 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,

Keyboard shortcuts

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

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

Branches 2

Tags 43

↑↓ moveenter openesc close