Commit c333b152d5

c333b152d575648efcd0b813112347e728adce13

parent: c10d966bf7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 00:25 UTC

store: record which merge request superseded a closed one

Migration 0055 adds merge_requests.superseded_by, the number (within the
same repository) of the merge request a closed one was closed in favour
of. MR.SupersededBy, SetSupersededBy and MRsSuperseding (the reverse
lookup) support the control and web layers.

Ref #223
internal/store/migrations/0055_mr_superseded_by.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE merge_requests DROP COLUMN superseded_by;
internal/store/migrations/0055_mr_superseded_by.up.sql added +3
@@ -0,0 +1,3 @@
1-- Which merge request, by number within the same repository, a closed
2-- merge request was closed in favour of. NULL means none (#223).
3ALTER TABLE merge_requests ADD COLUMN superseded_by INTEGER;
internal/store/mrs.go +38 −4
@@ -29,8 +29,11 @@ type MR struct {
2929 MergedBy string // "" when unknown (imports) or the account is gone
3030 ClosedAt string // "" unless closed without merging
3131 ClosedBy string
32 CreatedAt string
33 UpdatedAt string
32 // SupersededBy is the number, within this repository, of the merge
33 // request this one was closed in favour of. 0 means none.
34 SupersededBy int64
35 CreatedAt string
36 UpdatedAt string
3437 // ReviewRequests is who has been asked, directly, for a review — the
3538 // mr review request counterpart of Issue.Assignees.
3639 ReviewRequests []string
@@ -92,7 +95,7 @@ const mrSelect = `
9295 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
9396 COALESCE(ms.title, ''), m.head_sha,
9497 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
95 m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at
98 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at
9699 FROM merge_requests m
97100 JOIN users u ON u.id = m.author_id
98101 LEFT JOIN users mu ON mu.id = m.merged_by
@@ -106,7 +109,7 @@ func scanMR(row interface{ Scan(...any) error }) (MR, error) {
106109 var m MR
107110 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
108111 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
109 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt)
112 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt)
110113 return m, err
111114}
112115
@@ -265,6 +268,37 @@ func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
265268 return err
266269}
267270
271// SetSupersededBy records which merge request, by number within the same
272// repository, this one was closed in favour of. n of 0 clears it.
273func (s *Store) SetSupersededBy(mrID, n int64) error {
274 var v any
275 if n != 0 {
276 v = n
277 }
278 _, err := s.DB.Exec("UPDATE merge_requests SET superseded_by = ? WHERE id = ?", v, mrID)
279 return err
280}
281
282// MRsSuperseding returns the merge requests in a repository whose
283// superseded_by names number, oldest first — the reverse of
284// MR.SupersededBy.
285func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) {
286 rows, err := s.DB.Query(mrSelect+" WHERE m.repo_id = ? AND m.superseded_by = ? ORDER BY m.number ASC", repoID, number)
287 if err != nil {
288 return nil, err
289 }
290 defer rows.Close()
291 var out []MR
292 for rows.Next() {
293 m, err := scanMR(rows)
294 if err != nil {
295 return nil, err
296 }
297 out = append(out, m)
298 }
299 return out, rows.Err()
300}
301
268302// SetMRState moves an MR between states that carry no resolution stamp.
269303// Returning to open (a source branch that came back) clears one.
270304func (s *Store) SetMRState(mrID int64, state string) error {
internal/store/mrs_test.go +42
@@ -59,6 +59,48 @@ func TestResolutionStamps(t *testing.T) {
5959 }
6060}
6161
62// A merge request closed without merging can record the request that
63// carried its change forward; MRsSuperseding is the reverse lookup, and
64// 0 clears the field (#223).
65func TestSupersededBy(t *testing.T) {
66 s, repoID, uid := mrFixture(t)
67 if _, err := s.CreateMR(repoID, uid, repoID, "feature2", "main", "t2", "", "def456", "md", false); err != nil {
68 t.Fatal(err)
69 }
70 mr1, _ := s.MRByNumber(repoID, 1)
71 if err := s.MarkClosed(mr1.ID, uid, ""); err != nil {
72 t.Fatal(err)
73 }
74 if err := s.SetSupersededBy(mr1.ID, 2); err != nil {
75 t.Fatal(err)
76 }
77 mr1, _ = s.MRByNumber(repoID, 1)
78 if mr1.SupersededBy != 2 {
79 t.Fatalf("SupersededBy = %d, want 2", mr1.SupersededBy)
80 }
81 superseding, err := s.MRsSuperseding(repoID, 2)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if len(superseding) != 1 || superseding[0].Number != 1 {
86 t.Fatalf("MRsSuperseding(repoID, 2) = %+v", superseding)
87 }
88 if err := s.SetSupersededBy(mr1.ID, 0); err != nil {
89 t.Fatal(err)
90 }
91 mr1, _ = s.MRByNumber(repoID, 1)
92 if mr1.SupersededBy != 0 {
93 t.Fatalf("SupersededBy after clear = %d, want 0", mr1.SupersededBy)
94 }
95 superseding, err = s.MRsSuperseding(repoID, 2)
96 if err != nil {
97 t.Fatal(err)
98 }
99 if len(superseding) != 0 {
100 t.Fatalf("MRsSuperseding(repoID, 2) after clear = %+v", superseding)
101 }
102}
103
62104// An import carries the upstream time but no local account for the actor.
63105func TestResolutionStampImported(t *testing.T) {
64106 s, repoID, _ := mrFixture(t)