mr: close in favour of another merge request !413

merged merged by cmc on 2026-09-19 00:45 UTC · krz/gitbay:supersede-223 into main

13 files changed, +449 −25

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -41,6 +41,7 @@ browser-only and the iOS build screen unable to say more than the log.
41| comment on a diff line | yes | yes | yes | 41| comment on a diff line | yes | yes | yes |
42| merge (all strategies) | yes | yes | yes | 42| merge (all strategies) | yes | yes | yes |
43| close | yes | yes | yes | 43| close | yes | yes | yes |
44| close in favour of another | yes | yes | no |
44| create | yes | yes | yes | 45| create | yes | yes | yes |
45| draft, ready | yes | yes | yes | 46| draft, ready | yes | yes | yes |
46| search title and body | yes | yes | yes | 47| search title and body | yes | yes | yes |
.gitbay/wiki/Users.org +5
@@ -415,6 +415,11 @@ gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
415gitbay mr close 4 415gitbay mr close 4
416#+end_src 416#+end_src
417 417
418A merge request closed without merging can name the one that carries
419its change forward: =mr close 4 --by 7= records it and both pages show
420it, and =mr edit 4 --superseded-by 7|none= sets or clears it
421afterwards, refused on anything but a closed merge request.
422
418Semantics worth knowing: 423Semantics worth knowing:
419 424
420- the MR head lives in the *target* repository as 425- the MR head lives in the *target* repository as
e2e/mrweb_test.go +65
@@ -388,3 +388,68 @@ func TestMRDiffEmptyExplained(t *testing.T) {
388 t.Fatalf("empty diff unexplained:\n%s", body) 388 t.Fatalf("empty diff unexplained:\n%s", body)
389 } 389 }
390} 390}
391
392// TestMRSupersedes closes one merge request in favour of another from the
393// web form, and checks both pages say so; clearing it over ssh removes
394// both lines again (#223).
395func TestMRSupersedes(t *testing.T) {
396 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
397 aliceKey := inst.newKey(t, "alice")
398 inst.admin(t, "admin", "user", "create", "alice",
399 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
400 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
401 t.Fatalf("repo create: %s", errOut)
402 }
403 env := inst.gitEnv(aliceKey)
404 work := t.TempDir()
405 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
406 dir := filepath.Join(work, "w")
407 os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
408 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
409 mustGit(t, dir, env, "add", ".")
410 mustGit(t, dir, env, "commit", "-q", "-m", "base")
411 mustGit(t, dir, env, "push", "-q", "origin", "main")
412
413 for _, branch := range []string{"one", "two"} {
414 mustGit(t, dir, env, "checkout", "-q", "main")
415 mustGit(t, dir, env, "checkout", "-q", "-b", branch)
416 os.WriteFile(filepath.Join(dir, branch+".txt"), []byte(branch+"\n"), 0o644)
417 mustGit(t, dir, env, "add", ".")
418 mustGit(t, dir, env, "commit", "-q", "-m", branch)
419 mustGit(t, dir, env, "push", "-q", "origin", branch)
420 }
421 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
422 "--source", "one", "--target", "main", "--title", "one"); code != 0 {
423 t.Fatalf("mr create one: %s", errOut)
424 }
425 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
426 "--source", "two", "--target", "main", "--title", "two"); code != 0 {
427 t.Fatalf("mr create two: %s", errOut)
428 }
429
430 alice := inst.login(t, aliceKey)
431 if status, body := browserPost(t, alice, inst.base()+"/alice/app/mrs/1/close", url.Values{"by": {"2"}}); status != 200 {
432 t.Fatalf("close post: %d\n%s", status, body)
433 }
434
435 _, body1 := browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
436 if !strings.Contains(body1, `in favour of <a href="/alice/app/mrs/2">!2</a>`) {
437 t.Fatalf("!1 does not say it was superseded:\n%s", body1)
438 }
439 _, body2 := browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
440 if !strings.Contains(body2, `supersedes <a href="/alice/app/mrs/1">!1</a>`) {
441 t.Fatalf("!2 does not say what it supersedes:\n%s", body2)
442 }
443
444 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--superseded-by", "none"); code != 0 {
445 t.Fatalf("mr edit --superseded-by none: %s", errOut)
446 }
447 _, body1 = browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
448 if strings.Contains(body1, "in favour of") {
449 t.Fatalf("!1 still says it was superseded after clearing:\n%s", body1)
450 }
451 _, body2 = browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
452 if strings.Contains(body2, "supersedes") {
453 t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
454 }
455}
internal/control/issue.go +21 −11
@@ -289,12 +289,15 @@ func setIssueState(c *Ctx, args []string, state string) int {
289} 289}
290 290
291// editText parses --title/--body/--file -/--format and authorizes: author or 291// editText parses --title/--body/--file -/--format and authorizes: author or
292// write. A nil format means the stored markup format stays as it is. 292// write. A nil format means the stored markup format stays as it is. extra
293func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) { 293// names further value flags a caller wants (mr edit's --superseded-by):
294 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--body", "--file", "--format"}, MaxPos: -1, 294// they are accepted and reported in the returned flags, and count toward
295// "at least one edit was given" alongside title/body/format.
296func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) {
297 f, err := parseFlags(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
295 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"}) 298 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
296 if err != nil { 299 if err != nil {
297 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err) 300 return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err)
298 } 301 }
299 rest = f.Pos 302 rest = f.Pos
300 titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format") 303 titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
@@ -302,20 +305,27 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body, f
302 if file != "" { 305 if file != "" {
303 b, err := bodyFrom(c, "", file) 306 b, err := bodyFrom(c, "", file)
304 if err != nil { 307 if err != nil {
305 return nil, nil, nil, nil, c.failInput(err) 308 return nil, nil, nil, nil, flags{}, c.failInput(err)
306 } 309 }
307 bodyV, haveBody = b, true 310 bodyV, haveBody = b, true
308 } 311 }
309 fmtName, err := markupFormat(formatV) 312 fmtName, err := markupFormat(formatV)
310 if err != nil { 313 if err != nil {
311 return nil, nil, nil, nil, c.failInput(err) 314 return nil, nil, nil, nil, flags{}, c.failInput(err)
312 } 315 }
313 if !haveTitle && !haveBody && fmtName == "" { 316 anyExtra := false
314 return nil, nil, nil, nil, c.usage() 317 for _, e := range extra {
318 if f.Has(e) {
319 anyExtra = true
320 break
321 }
322 }
323 if !haveTitle && !haveBody && fmtName == "" && !anyExtra {
324 return nil, nil, nil, nil, flags{}, c.usage()
315 } 325 }
316 if haveTitle { 326 if haveTitle {
317 if strings.TrimSpace(titleV) == "" { 327 if strings.TrimSpace(titleV) == "" {
318 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty") 328 return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty")
319 } 329 }
320 title = &titleV 330 title = &titleV
321 } 331 }
@@ -325,11 +335,11 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body, f
325 if fmtName != "" { 335 if fmtName != "" {
326 format = &fmtName 336 format = &fmtName
327 } 337 }
328 return rest, title, body, format, -1 338 return rest, title, body, format, f, -1
329} 339}
330 340
331func runIssueEdit(c *Ctx, args []string) int { 341func runIssueEdit(c *Ctx, args []string) int {
332 rest, title, body, format, code := editText(c, args, "issue") 342 rest, title, body, format, _, code := editText(c, args, "issue")
333 if code >= 0 { 343 if code >= 0 {
334 return code 344 return code
335 } 345 }
internal/control/mr.go +76 −7
@@ -68,7 +68,7 @@ func init() {
68 Usage: "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) 68 Usage: "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
69 register(Command{Path: []string{"mr", "edit"}, 69 register(Command{Path: []string{"mr", "edit"},
70 Summary: "edit title or body", 70 Summary: "edit title or body",
71 Usage: "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", 71 Usage: "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org] [--superseded-by <m>|none]",
72 ReadsStdin: true, Run: runMREdit}) 72 ReadsStdin: true, Run: runMREdit})
73 register(Command{Path: []string{"mr", "retarget"}, 73 register(Command{Path: []string{"mr", "retarget"},
74 Summary: "retarget onto another branch", 74 Summary: "retarget onto another branch",
@@ -88,7 +88,7 @@ func init() {
88 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge}) 88 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
89 register(Command{Path: []string{"mr", "close"}, 89 register(Command{Path: []string{"mr", "close"},
90 Summary: "close without merging", 90 Summary: "close without merging",
91 Usage: "mr close <owner/name> <n>", Run: runMRClose}) 91 Usage: "mr close <owner/name> <n> [--by <m>]", Run: runMRClose})
92} 92}
93 93
94// ForkOut is what `repo fork` emits: where the fork landed, and what it 94// ForkOut is what `repo fork` emits: where the fork landed, and what it
@@ -384,6 +384,9 @@ type mrOut struct {
384 MergedBy string `json:"merged_by,omitempty"` 384 MergedBy string `json:"merged_by,omitempty"`
385 ClosedAt string `json:"closed_at,omitempty"` 385 ClosedAt string `json:"closed_at,omitempty"`
386 ClosedBy string `json:"closed_by,omitempty"` 386 ClosedBy string `json:"closed_by,omitempty"`
387 // SupersededBy is the merge request, by number, this one was closed
388 // in favour of. 0 means none.
389 SupersededBy int64 `json:"superseded_by,omitempty"`
387} 390}
388 391
389type stackRef struct { 392type stackRef struct {
@@ -429,7 +432,7 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
429 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, 432 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
430 ReviewRequests: m.ReviewRequests, 433 ReviewRequests: m.ReviewRequests,
431 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, 434 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
432 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy} 435 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy}
433 if withBody { 436 if withBody {
434 o.Body = m.Body 437 o.Body = m.Body
435 o.BodyFormat = m.BodyFormat 438 o.BodyFormat = m.BodyFormat
@@ -598,6 +601,9 @@ func runMRShow(c *Ctx, args []string) int {
598 if d.ClosedAt != "" { 601 if d.ClosedAt != "" {
599 fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy)) 602 fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy))
600 } 603 }
604 if d.SupersededBy != 0 {
605 fmt.Fprintf(w, "superseded by: !%d\n", d.SupersededBy)
606 }
601 if d.Body != "" { 607 if d.Body != "" {
602 fmt.Fprintf(w, "\n%s\n", d.Body) 608 fmt.Fprintf(w, "\n%s\n", d.Body)
603 } 609 }
@@ -677,7 +683,7 @@ func runMRDiff(c *Ctx, args []string) int {
677} 683}
678 684
679func runMREdit(c *Ctx, args []string) int { 685func runMREdit(c *Ctx, args []string) int {
680 rest, title, body, format, code := editText(c, args, "mr") 686 rest, title, body, format, fl, code := editText(c, args, "mr", "--superseded-by")
681 if code >= 0 { 687 if code >= 0 {
682 return code 688 return code
683 } 689 }
@@ -691,9 +697,33 @@ func runMREdit(c *Ctx, args []string) int {
691 if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 { 697 if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 {
692 return code 698 return code
693 } 699 }
700 var clearSuperseded bool
701 var supersededBy int64
702 if fl.Has("--superseded-by") {
703 if mr.State != "closed" {
704 return c.fail(protocol.ExitUsage, "only a closed merge request can be superseded")
705 }
706 if v := fl.Value("--superseded-by"); v == "none" {
707 clearSuperseded = true
708 } else {
709 supersededBy, code = resolveSupersededBy(c, repo, mr.Number, v)
710 if code >= 0 {
711 return code
712 }
713 }
714 }
694 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil { 715 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
695 return c.fail(protocol.ExitFailure, "%v", err) 716 return c.fail(protocol.ExitFailure, "%v", err)
696 } 717 }
718 if clearSuperseded {
719 if err := c.Store.SetSupersededBy(mr.ID, 0); err != nil {
720 return c.fail(protocol.ExitFailure, "%v", err)
721 }
722 } else if supersededBy != 0 {
723 if err := c.Store.SetSupersededBy(mr.ID, supersededBy); err != nil {
724 return c.fail(protocol.ExitFailure, "%v", err)
725 }
726 }
697 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number)) 727 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number))
698 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { 728 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
699 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number) 729 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
@@ -1499,14 +1529,19 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
1499} 1529}
1500 1530
1501func runMRClose(c *Ctx, args []string) int { 1531func runMRClose(c *Ctx, args []string) int {
1502 repo, mr, code := mrRef(c, args, policy.CanRead) 1532 f, err := parseFlags(args, flagSpec{Values: []string{"--by"}, MaxPos: 2,
1533 Usage: "mr close <owner/name> <n> [--by <m>]"})
1534 if err != nil {
1535 return c.fail(protocol.ExitUsage, "%v", err)
1536 }
1537 repo, mr, code := mrRef(c, f.Pos, policy.CanRead)
1503 if code >= 0 { 1538 if code >= 0 {
1504 return code 1539 return code
1505 } 1540 }
1506 if code := refuseArchived(c, repo); code >= 0 { 1541 if code := refuseArchived(c, repo); code >= 0 {
1507 return code 1542 return code
1508 } 1543 }
1509 if len(args) != 2 { 1544 if len(f.Pos) != 2 {
1510 return c.usage() 1545 return c.usage()
1511 } 1546 }
1512 if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 { 1547 if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 {
@@ -1515,10 +1550,24 @@ func runMRClose(c *Ctx, args []string) int {
1515 if mr.State == "merged" || mr.State == "closed" { 1550 if mr.State == "merged" || mr.State == "closed" {
1516 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State) 1551 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1517 } 1552 }
1553 var by int64
1554 if f.Has("--by") {
1555 by, code = resolveSupersededBy(c, repo, mr.Number, f.Value("--by"))
1556 if code >= 0 {
1557 return code
1558 }
1559 }
1518 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { 1560 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1519 return c.fail(protocol.ExitFailure, "%v", err) 1561 return c.fail(protocol.ExitFailure, "%v", err)
1520 } 1562 }
1521 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", fmt.Sprintf(`{"number":%d}`, mr.Number)) 1563 eventData := fmt.Sprintf(`{"number":%d}`, mr.Number)
1564 if by != 0 {
1565 if err := c.Store.SetSupersededBy(mr.ID, by); err != nil {
1566 return c.fail(protocol.ExitFailure, "%v", err)
1567 }
1568 eventData = fmt.Sprintf(`{"number":%d,"by":%d}`, mr.Number, by)
1569 }
1570 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData)
1522 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1571 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1523 notify(c, parts, notice{repo: repo, kind: "mr", 1572 notify(c, parts, notice{repo: repo, kind: "mr",
1524 subject: mrSubject(repo, mr.Number, mr.Title), 1573 subject: mrSubject(repo, mr.Number, mr.Title),
@@ -1530,6 +1579,26 @@ func runMRClose(c *Ctx, args []string) int {
1530 }) 1579 })
1531} 1580}
1532 1581
1582// resolveSupersededBy validates a --superseded-by/--by value against the
1583// merge request it would be set on: it must parse, name another merge
1584// request in the same repository (never itself), and that request must
1585// exist. -1 as the returned code means the value is good to use.
1586func resolveSupersededBy(c *Ctx, repo store.Repo, number int64, v string) (int64, int) {
1587 m, err := strconv.ParseInt(v, 10, 64)
1588 if err != nil {
1589 return 0, c.fail(protocol.ExitUsage, "bad MR number %q", v)
1590 }
1591 if m == number {
1592 return 0, c.fail(protocol.ExitUsage, "a merge request cannot supersede itself")
1593 }
1594 if _, err := c.Store.MRByNumber(repo.ID, m); errors.Is(err, store.ErrNotFound) {
1595 return 0, c.fail(protocol.ExitNotFound, "no merge request !%d on %s", m, repo.Path())
1596 } else if err != nil {
1597 return 0, c.fail(protocol.ExitFailure, "%v", err)
1598 }
1599 return m, -1
1600}
1601
1533// reviewAction is what a review notification says it was. A verdict with 1602// reviewAction is what a review notification says it was. A verdict with
1534// a batch behind it is a different thing from a bare verdict, and the 1603// a batch behind it is a different thing from a bare verdict, and the
1535// person reading the mail is deciding whether to open it. 1604// person reading the mail is deciding whether to open it.
internal/control/mr_test.go added +179
@@ -0,0 +1,179 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "strconv"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// mrTestCtx runs control commands as owner against st, capturing output.
15func mrTestCtx(st *store.Store, owner store.User) (*Ctx, *bytes.Buffer, *bytes.Buffer) {
16 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
17 c := &Ctx{User: owner, Scope: "full", Store: st, Stdout: out, Stderr: errOut}
18 return c, out, errOut
19}
20
21// twoMRTestRepo is a repository with two open merge requests, both
22// authored by the returned owner, so authorOrWrite never gets in the way.
23func twoMRTestRepo(t *testing.T) (*store.Store, store.Repo, store.User) {
24 t.Helper()
25 st, repo, uid := newQueueTestRepo(t)
26 owner := store.User{ID: uid, Username: "alice"}
27 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature1", "main", "one", "", "abc111", "md", false); err != nil {
28 t.Fatal(err)
29 }
30 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature2", "main", "two", "", "abc222", "md", false); err != nil {
31 t.Fatal(err)
32 }
33 return st, repo, owner
34}
35
36func mrShowJSON(t *testing.T, st *store.Store, owner store.User, path string, n int64) mrOut {
37 t.Helper()
38 c, out, errOut := mrTestCtx(st, owner)
39 if code := Dispatch(c, []string{"mr", "show", path, strconv.FormatInt(n, 10), "--json"}); code != protocol.ExitOK {
40 t.Fatalf("mr show: exit %d, %s", code, errOut.String())
41 }
42 var env struct {
43 Data mrOut `json:"data"`
44 }
45 if err := json.Unmarshal(out.Bytes(), &env); err != nil {
46 t.Fatalf("mr show JSON: %v\n%s", err, out.String())
47 }
48 return env.Data
49}
50
51// eventDataFor pulls the most recent data_json for a kind, so a test can
52// check what mr close recorded without a store accessor built just for it.
53func eventDataFor(t *testing.T, st *store.Store, kind string) string {
54 t.Helper()
55 var data string
56 err := st.DB.QueryRow("SELECT data_json FROM events WHERE kind = ? ORDER BY id DESC LIMIT 1", kind).Scan(&data)
57 if err != nil {
58 t.Fatalf("event %s: %v", kind, err)
59 }
60 return data
61}
62
63// Closing a merge request can name the one that carries its change
64// forward; mr show and the mr.closed event both then carry it (#223).
65func TestMRCloseWithBy(t *testing.T) {
66 st, repo, owner := twoMRTestRepo(t)
67 c, _, errOut := mrTestCtx(st, owner)
68 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "2"}); code != protocol.ExitOK {
69 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
70 }
71 got := mrShowJSON(t, st, owner, repo.Path(), 1)
72 if got.State != "closed" || got.SupersededBy != 2 {
73 t.Fatalf("mr show !1 = %+v, want closed superseded_by 2", got)
74 }
75 if data := eventDataFor(t, st, "mr.closed"); !strings.Contains(data, `"by":2`) {
76 t.Fatalf("mr.closed event = %s, want it to carry by:2", data)
77 }
78}
79
80// mr close --by refuses a merge request naming itself.
81func TestMRCloseBySelfRefused(t *testing.T) {
82 st, repo, owner := twoMRTestRepo(t)
83 c, _, errOut := mrTestCtx(st, owner)
84 code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "1"})
85 if code != protocol.ExitUsage {
86 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String())
87 }
88 if !strings.Contains(errOut.String(), "cannot supersede itself") {
89 t.Fatalf("stderr = %q, want it to say a merge request cannot supersede itself", errOut.String())
90 }
91}
92
93// mr close --by refuses a merge request number that does not exist in
94// the repository.
95func TestMRCloseByMissingRefused(t *testing.T) {
96 st, repo, owner := twoMRTestRepo(t)
97 c, _, errOut := mrTestCtx(st, owner)
98 code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "99"})
99 if code != protocol.ExitNotFound {
100 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitNotFound, errOut.String())
101 }
102 if !strings.Contains(errOut.String(), "no merge request !99") {
103 t.Fatalf("stderr = %q, want it to name !99 as missing", errOut.String())
104 }
105}
106
107// mr edit --superseded-by sets and clears the field on a closed merge
108// request.
109func TestMREditSupersededBySetAndClear(t *testing.T) {
110 st, repo, owner := twoMRTestRepo(t)
111 c, _, errOut := mrTestCtx(st, owner)
112 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK {
113 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
114 }
115 c, _, errOut = mrTestCtx(st, owner)
116 if code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "2"}); code != protocol.ExitOK {
117 t.Fatalf("mr edit --superseded-by 2: exit %d, %s", code, errOut.String())
118 }
119 if got := mrShowJSON(t, st, owner, repo.Path(), 1); got.SupersededBy != 2 {
120 t.Fatalf("SupersededBy = %d, want 2", got.SupersededBy)
121 }
122 c, _, errOut = mrTestCtx(st, owner)
123 if code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "none"}); code != protocol.ExitOK {
124 t.Fatalf("mr edit --superseded-by none: exit %d, %s", code, errOut.String())
125 }
126 if got := mrShowJSON(t, st, owner, repo.Path(), 1); got.SupersededBy != 0 {
127 t.Fatalf("SupersededBy after clear = %d, want 0", got.SupersededBy)
128 }
129}
130
131// mr edit --superseded-by refuses a self-reference the same way mr close
132// --by does.
133func TestMREditSupersededBySelfRefused(t *testing.T) {
134 st, repo, owner := twoMRTestRepo(t)
135 c, _, errOut := mrTestCtx(st, owner)
136 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK {
137 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
138 }
139 c, _, errOut = mrTestCtx(st, owner)
140 code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "1"})
141 if code != protocol.ExitUsage {
142 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String())
143 }
144 if !strings.Contains(errOut.String(), "cannot supersede itself") {
145 t.Fatalf("stderr = %q, want it to say a merge request cannot supersede itself", errOut.String())
146 }
147}
148
149// mr edit --superseded-by refuses a merge request number that does not
150// exist in the repository.
151func TestMREditSupersededByMissingRefused(t *testing.T) {
152 st, repo, owner := twoMRTestRepo(t)
153 c, _, errOut := mrTestCtx(st, owner)
154 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK {
155 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
156 }
157 c, _, errOut = mrTestCtx(st, owner)
158 code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "99"})
159 if code != protocol.ExitNotFound {
160 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitNotFound, errOut.String())
161 }
162 if !strings.Contains(errOut.String(), "no merge request !99") {
163 t.Fatalf("stderr = %q, want it to name !99 as missing", errOut.String())
164 }
165}
166
167// mr edit --superseded-by refuses an open merge request: only a closed
168// one can be superseded.
169func TestMREditSupersededByOnOpenMRRefused(t *testing.T) {
170 st, repo, owner := twoMRTestRepo(t)
171 c, _, errOut := mrTestCtx(st, owner)
172 code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "2"})
173 if code != protocol.ExitUsage {
174 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String())
175 }
176 if !strings.Contains(errOut.String(), "only a closed merge request can be superseded") {
177 t.Fatalf("stderr = %q, want the closed-only refusal", errOut.String())
178 }
179}
internal/httpd/mractions.go +9 −1
@@ -76,7 +76,15 @@ func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.U
76} 76}
77 77
78func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 78func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
79 _, msg, code := s.runControlCode(u, mrArgs(r, "close")) 79 args := []string{}
80 if by := strings.TrimSpace(r.FormValue("by")); by != "" {
81 if _, err := strconv.ParseInt(by, 10, 64); err != nil {
82 s.mrRedirect(w, r, "the superseding request is a number")
83 return
84 }
85 args = append(args, "--by", by)
86 }
87 _, msg, code := s.runControlCode(u, mrArgs(r, "close", args...))
80 s.done(w, r, code, msg, s.mrRedirect) 88 s.done(w, r, code, msg, s.mrRedirect)
81} 89}
82 90
internal/httpd/web.go +5 −1
@@ -1957,6 +1957,9 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1957 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef) 1957 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1958 } 1958 }
1959 } 1959 }
1960 // The merge requests this one superseded when it was closed, so the
1961 // page it points to can also say what it supersedes.
1962 supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number)
1960 s.render(w, "mr.html", struct { 1963 s.render(w, "mr.html", struct {
1961 repoPage 1964 repoPage
1962 MR store.MR 1965 MR store.MR
@@ -1980,6 +1983,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1980 DetachedThreads []diffThread 1983 DetachedThreads []diffThread
1981 StackedOn *store.MR 1984 StackedOn *store.MR
1982 Stacked []store.MR 1985 Stacked []store.MR
1986 Supersedes []store.MR
1983 Gates *control.GatesOut 1987 Gates *control.GatesOut
1984 SourceGone bool 1988 SourceGone bool
1985 HeadMerged bool 1989 HeadMerged bool
@@ -1987,7 +1991,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1987 Base string 1991 Base string
1988 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 1992 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1989 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 1993 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1990 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates, 1994 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
1991 sourceGone(p, m), headMerged, headPruned, base}) 1995 sourceGone(p, m), headMerged, headPruned, base})
1992} 1996}
1993 1997
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 {
29 MergedBy string // "" when unknown (imports) or the account is gone 29 MergedBy string // "" when unknown (imports) or the account is gone
30 ClosedAt string // "" unless closed without merging 30 ClosedAt string // "" unless closed without merging
31 ClosedBy string 31 ClosedBy string
32 CreatedAt string 32 // SupersededBy is the number, within this repository, of the merge
33 UpdatedAt string 33 // request this one was closed in favour of. 0 means none.
34 SupersededBy int64
35 CreatedAt string
36 UpdatedAt string
34 // ReviewRequests is who has been asked, directly, for a review — the 37 // ReviewRequests is who has been asked, directly, for a review — the
35 // mr review request counterpart of Issue.Assignees. 38 // mr review request counterpart of Issue.Assignees.
36 ReviewRequests []string 39 ReviewRequests []string
@@ -92,7 +95,7 @@ const mrSelect = `
92 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, 95 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
93 COALESCE(ms.title, ''), m.head_sha, 96 COALESCE(ms.title, ''), m.head_sha,
94 m.merged_base, m.merged_at, COALESCE(mu.username, ''), 97 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
96 FROM merge_requests m 99 FROM merge_requests m
97 JOIN users u ON u.id = m.author_id 100 JOIN users u ON u.id = m.author_id
98 LEFT JOIN users mu ON mu.id = m.merged_by 101 LEFT JOIN users mu ON mu.id = m.merged_by
@@ -106,7 +109,7 @@ func scanMR(row interface{ Scan(...any) error }) (MR, error) {
106 var m MR 109 var m MR
107 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, 110 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
108 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, 111 &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)
110 return m, err 113 return m, err
111} 114}
112 115
@@ -265,6 +268,37 @@ func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
265 return err 268 return err
266} 269}
267 270
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
268// SetMRState moves an MR between states that carry no resolution stamp. 302// SetMRState moves an MR between states that carry no resolution stamp.
269// Returning to open (a source branch that came back) clears one. 303// Returning to open (a source branch that came back) clears one.
270func (s *Store) SetMRState(mrID int64, state string) error { 304func (s *Store) SetMRState(mrID int64, state string) error {
internal/store/mrs_test.go +42
@@ -59,6 +59,48 @@ func TestResolutionStamps(t *testing.T) {
59 } 59 }
60} 60}
61 61
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
62// An import carries the upstream time but no local account for the actor. 104// An import carries the upstream time but no local account for the actor.
63func TestResolutionStampImported(t *testing.T) { 105func TestResolutionStampImported(t *testing.T) {
64 s, repoID, _ := mrFixture(t) 106 s, repoID, _ := mrFixture(t)
internal/web/templates/mr.html +4 −1
@@ -5,12 +5,13 @@
5<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> 5<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
6<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}} 6<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}}
7 {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{end}} 7 {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{end}}
8 {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}} 8 {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}}{{if .MR.SupersededBy}}, in favour of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.SupersededBy}}">!{{.MR.SupersededBy}}</a>{{end}}
9 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}} 9 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}}
10 {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}} 10 {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
11 · <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> 11 · <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
12{{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} 12{{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}}
13{{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} 13{{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}}
14{{with field . "Supersedes"}}<p class="meta">supersedes {{range $i, $m := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$m.Number}}">!{{$m.Number}}</a>{{end}}</p>{{end}}
14 15
15{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 16{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
16 17
@@ -103,6 +104,8 @@
103 <button type="submit">Merge</button> 104 <button type="submit">Merge</button>
104 </form> 105 </form>
105 <form method="post" action="{{$base}}/close" class="actions"> 106 <form method="post" action="{{$base}}/close" class="actions">
107 <label class="vh" for="by">Closed in favour of</label>
108 <input type="text" id="by" name="by" inputmode="numeric" size="4" placeholder="!N">
106 <button type="submit" class="danger">Close without merging</button> 109 <button type="submit" class="danger">Close without merging</button>
107 </form> 110 </form>
108 <form method="post" action="{{$base}}/draft" class="actions"> 111 <form method="post" action="{{$base}}/draft" class="actions">