Commit 47d3c4db23

47d3c4db233e491b47ec9fd23f4b37554673a79a

parent: ae4dce6203

Verified · cmc

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

web: close in favour of another merge request

The close form gets an optional "!N" field; mrCloseSubmit passes it as
mr close's --by, refusing non-numeric input before it reaches the
command. The title line names the superseding request once closed, and
the page a request supersedes lists what it replaced, both linking to
the other's page.

TestMRSupersedes drives this end to end: close through the web form,
check both pages, then clear it with mr edit --superseded-by none over
ssh and check both lines are gone.

Ref #223
e2e/mrweb_test.go +65
@@ -388,3 +388,68 @@ func TestMRDiffEmptyExplained(t *testing.T) {
388388 t.Fatalf("empty diff unexplained:\n%s", body)
389389 }
390390}
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/httpd/mractions.go +9 −1
@@ -76,7 +76,15 @@ func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.U
7676}
7777
7878func (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...))
8088 s.done(w, r, code, msg, s.mrRedirect)
8189}
8290
internal/httpd/web.go +5 −1
@@ -1957,6 +1957,9 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19571957 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
19581958 }
19591959 }
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)
19601963 s.render(w, "mr.html", struct {
19611964 repoPage
19621965 MR store.MR
@@ -1980,6 +1983,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19801983 DetachedThreads []diffThread
19811984 StackedOn *store.MR
19821985 Stacked []store.MR
1986 Supersedes []store.MR
19831987 Gates *control.GatesOut
19841988 SourceGone bool
19851989 HeadMerged bool
@@ -1987,7 +1991,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19871991 Base string
19881992 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
19891993 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,
19911995 sourceGone(p, m), headMerged, headPruned, base})
19921996}
19931997
internal/web/templates/mr.html +4 −1
@@ -5,12 +5,13 @@
55<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
66<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}}
77 {{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}}
99 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}}
1010 {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
1111 · <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
1212{{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}}
1313{{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}}
1415
1516{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
1617
@@ -103,6 +104,8 @@
103104 <button type="submit">Merge</button>
104105 </form>
105106 <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">
106109 <button type="submit" class="danger">Close without merging</button>
107110 </form>
108111 <form method="post" action="{{$base}}/draft" class="actions">