Commit 94e954df77

94e954df773628e95118d8dd24313a4870068e0c

parent: 6b8e159687

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 04:55 UTC

control: mr apply-suggestion commits a thread's suggestion to the source branch

Ref #288

Layout: unified · split

cmd/gitbay/main.go +1
@@ -683,6 +683,7 @@ func mrCmd() *cobra.Command {
683683 pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
684684 pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
685685 pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
686 pass("apply-suggestion", passOpts{server: []string{"mr", "apply-suggestion"}, needsRepo: true}),
686687 pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}),
687688 review,
688689 pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +1
@@ -64,6 +64,7 @@ var summaries = map[string]string{
6464 "milestone create": "create a milestone",
6565 "milestone list": "list milestones with progress",
6666 "milestone reopen": "reopen a milestone",
67 "mr apply-suggestion": "commit a review thread's suggestion to the source branch",
6768 "mr close": "close without merging",
6869 "mr comment": "add a comment",
6970 "mr create": "open a merge request",
internal/control/suggestion.go +149
@@ -2,13 +2,26 @@ package control
22
33import (
44 "bytes"
5 "errors"
56 "fmt"
7 "io"
8 "strconv"
69
710 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
813 "gitbay.org/gitbay/internal/store"
914 "gitbay.org/gitbay/internal/suggest"
1015)
1116
17func init() {
18 register(Command{Path: []string{"mr", "apply-suggestion"},
19 Summary: "commit a review thread's suggestion to the source branch",
20 Usage: "mr apply-suggestion <owner/name> <n> <thread-id>",
21 Examples: []string{"mr apply-suggestion krz/gitbay 431 12"},
22 Run: runMRApplySuggestion})
23}
24
1225// SuggestionOut is the change a review thread's ```suggestion block
1326// proposes: lines StartLine through EndLine of Path, as they were at
1427// Commit (Original), replaced by Replacement. Both texts end every line
@@ -125,3 +138,139 @@ func signedOnly(st *store.Store, repo store.Repo, mr store.MR) bool {
125138 src, err := st.RepoByID(mr.SourceRepoID)
126139 return err != nil || src.Settings.RequireSignedCommits
127140}
141
142// runMRApplySuggestion commits a thread's suggestion to the merge
143// request's source branch as the caller, and resolves the thread.
144//
145// It is a push by the caller, and is held to what a push is: the caller
146// needs write on the source repository (for a fork, the fork's writers;
147// write on the target grants nothing there), the update goes through the
148// pre-receive ref policy (policy.CheckPush: protected branches,
149// require-mr) before a compare-and-swap ref update, and RefsUpdated then
150// does what post-receive does, which moves the merge request's head and
151// reaches a queued merge. The server has no signing key, so where either
152// repository requires signed commits it refuses, and the CLI applies the
153// suggestion locally instead.
154func runMRApplySuggestion(c *Ctx, args []string) int {
155 if len(args) != 3 {
156 return c.usage()
157 }
158 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
159 if code >= 0 {
160 return code
161 }
162 if code := refuseArchived(c, repo); code >= 0 {
163 return code
164 }
165 threadID, err := strconv.ParseInt(args[2], 10, 64)
166 if err != nil {
167 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
168 }
169 cm, err := c.Store.DiffCommentByID(mr.ID, threadID)
170 if errors.Is(err, store.ErrNotFound) || (err == nil && cm.Pending && cm.Author != c.User.Username) {
171 return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
172 }
173 if err != nil {
174 return c.failErr(err)
175 }
176 if cm.ReplyTo != 0 {
177 return c.fail(protocol.ExitUsage, "%d is a reply; name the thread root %d", threadID, cm.ReplyTo)
178 }
179 if cm.Pending {
180 return c.fail(protocol.ExitUsage, "thread %d is in your unsubmitted review; submit it with `mr review` first", threadID)
181 }
182 if mr.State != "open" {
183 return c.fail(protocol.ExitUsage, "%s!%d is %s", repo.Path(), mr.Number, mr.State)
184 }
185 s := ThreadSuggestion(c.Store, c.Cfg.Server.Root, repo, mr, cm)
186 if s == nil {
187 return c.fail(protocol.ExitUsage, "thread %d carries no suggestion", threadID)
188 }
189
190 src, err := c.Store.RepoByID(mr.SourceRepoID)
191 if err != nil {
192 return c.failErr(err)
193 }
194 grant, err := c.Store.AccessRole(src.ID, c.User.ID)
195 if err != nil {
196 return c.failErr(err)
197 }
198 source := mr.SourceRef
199 if src.ID != repo.ID {
200 source = src.Path() + ":" + mr.SourceRef
201 }
202 if !policy.CanWrite(c.User, src, grant) {
203 return c.fail(protocol.ExitDenied, "applying a suggestion pushes to %s; only its writers can", source)
204 }
205 if src.Settings.Archived {
206 return c.fail(protocol.ExitDenied, "%s is archived and read-only", src.Path())
207 }
208 if mirrored, err := c.Store.PullMirrored(src.ID); err != nil {
209 return c.failErr(err)
210 } else if mirrored {
211 return c.fail(protocol.ExitDenied, "%s is a pull mirror: its refs come from the upstream", src.Path())
212 }
213 if s.Apply == "local" {
214 return c.fail(protocol.ExitDenied,
215 "%s requires signed commits and the server cannot sign one; apply it from a clone, which commits with your own key: gitbay mr apply-suggestion %s %d %d",
216 source, repo.Path(), mr.Number, threadID)
217 }
218 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
219 if err != nil {
220 return c.failErr(err)
221 }
222 if email == "" {
223 return c.fail(protocol.ExitDenied, "commits carry your identity: your account needs a verified primary email")
224 }
225
226 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
227 ref := "refs/heads/" + mr.SourceRef
228 tip, err := gitutil.ResolveRef(srcDir, ref)
229 if err != nil {
230 return c.fail(protocol.ExitUsage, "the source branch %s is gone", source)
231 }
232 if s.Reason == reasonNoBase {
233 return c.fail(protocol.ExitUsage, "suggestion in thread %d cannot be applied: %s", threadID, s.Reason)
234 }
235 if reason := anchorReason(srcDir, tip, s); reason != "" {
236 return c.fail(protocol.ExitUsage, "suggestion in thread %d is outdated: %s", threadID, reason)
237 }
238 content, mode, err := readAnchored(srcDir, tip, s.Path)
239 if err != nil {
240 return c.fail(protocol.ExitUsage, "%v", err)
241 }
242 updated, err := suggest.Apply(content, int(s.StartLine), int(s.EndLine), suggest.FromText(s.Replacement))
243 if err != nil {
244 return c.fail(protocol.ExitUsage, "%v", err)
245 }
246 if bytes.Equal(updated, content) {
247 return c.fail(protocol.ExitUsage, "the suggestion in thread %d changes nothing", threadID)
248 }
249 message := SuggestionMessage(repo.Path(), mr.Number, threadID, cm.Author)
250 sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message)
251 if err != nil {
252 return c.failErr(err)
253 }
254 updates := []policy.RefUpdate{{Ref: ref, Old: tip, New: sha}}
255 if msg := policy.CheckPush(src, updates); msg != "" {
256 return c.fail(protocol.ExitDenied, "%s", msg)
257 }
258 if err := gitutil.UpdateRefCAS(srcDir, ref, sha, tip); err != nil {
259 return c.fail(protocol.ExitFailure, "the source branch moved; reload and retry")
260 }
261 RefsUpdated(c.Store, c.Cfg, src.ID, c.User.ID, c.Scope, updates)
262 if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, true); err != nil {
263 return c.failErr(err)
264 }
265 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
266 return c.emit(map[string]any{"thread": threadID, "sha": sha, "source": source, "resolved": true}, func(w io.Writer) {
267 fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha)
268 })
269}
270
271// SuggestionMessage is the commit message of an applied suggestion,
272// naming the merge request and the thread. The CLI's local apply writes
273// the same one.
274func SuggestionMessage(repoPath string, mr, thread int64, author string) string {
275 return fmt.Sprintf("Apply suggestion from %s\n\nThread %d on %s!%d.\n", author, thread, repoPath, mr)
276}
internal/control/suggestion_test.go +275
@@ -49,6 +49,7 @@ func (f *queueFixture) suggest(u store.User, path string, start, end int, lines
4949 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut, JSON: true,
5050 Stdin: strings.NewReader(body)}
5151 c.Cfg.Server.Root = f.root
52 c.Cfg.Limits.WriteRate = -1
5253 argv := []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", path,
5354 "--start-line", strconv.Itoa(start), "--line", strconv.Itoa(end), "--file", "-"}
5455 if code := Dispatch(c, argv); code != protocol.ExitOK {
@@ -73,6 +74,19 @@ type threadJSON struct {
7374 Suggestion *SuggestionOut `json:"suggestion"`
7475}
7576
77// unlimited lifts the per-account write limit, which these tests would
78// otherwise spend for every test in the package that writes as uid 1.
79func unlimited(c *Ctx) { c.Cfg.Limits.WriteRate = -1 }
80
81func (f *queueFixture) mustWrite(u store.User, argv ...string) string {
82 f.t.Helper()
83 code, out, errOut := f.runWith(u, unlimited, argv...)
84 if code != protocol.ExitOK {
85 f.t.Fatalf("%v: exit %d, %s", argv, code, errOut)
86 }
87 return out
88}
89
7690func (f *queueFixture) threads(u store.User) []threadJSON {
7791 f.t.Helper()
7892 out := f.mustRun(u, "mr", "threads", f.repo.Path(), "1", "--json")
@@ -168,6 +182,7 @@ func TestSuggestionRefusals(t *testing.T) {
168182 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut,
169183 Stdin: strings.NewReader(body)}
170184 c.Cfg.Server.Root = f.root
185 c.Cfg.Limits.WriteRate = -1
171186 return Dispatch(c, append([]string{"mr", "diff-comment", f.repo.Path(), "1", "--file", "-"}, extra...)), errOut.String()
172187 }
173188 block := "```suggestion\nx\n```\n"
@@ -190,3 +205,263 @@ func TestSuggestionRefusals(t *testing.T) {
190205 }
191206 }
192207}
208
209// verified gives u a verified primary address, which a commit needs.
210func (f *queueFixture) verified(u store.User) {
211 f.t.Helper()
212 if err := f.st.AddEmail(u.ID, u.Username+"@example.test", "admin", true); err != nil {
213 f.t.Fatal(err)
214 }
215}
216
217// applyOK applies thread as u and returns the new commit, checking what
218// every successful apply must have done: one commit on the old head by
219// u, naming the merge request and thread, the merge request moved to
220// it, and the thread resolved.
221func (f *queueFixture) applyOK(u store.User, thread string) string {
222 f.t.Helper()
223 old := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
224 out := f.mustWrite(u, "mr", "apply-suggestion", f.repo.Path(), "1", thread, "--json")
225 var env struct {
226 Data struct {
227 SHA string `json:"sha"`
228 } `json:"data"`
229 }
230 json.Unmarshal([]byte(out), &env)
231 sha := env.Data.SHA
232 if tip := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); tip != sha || sha == "" {
233 f.t.Fatalf("feature = %s, apply reported %q", tip, sha)
234 }
235 if parent := strings.TrimSpace(f.git(f.dir, "rev-parse", sha+"^")); parent != old {
236 f.t.Fatalf("parent = %s, want the old head %s", parent, old)
237 }
238 meta := f.git(f.dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>|%B", sha)
239 who := u.Username + " <" + u.Username + "@example.test>"
240 if !strings.HasPrefix(meta, who+"|"+who+"|") || !strings.Contains(meta, "Thread "+thread+" on alice/app!1") {
241 f.t.Fatalf("commit = %q", meta)
242 }
243 if mr := f.mr(); mr.HeadSHA != sha {
244 f.t.Fatalf("MR head = %s, want %s", mr.HeadSHA, sha)
245 }
246 if head := strings.TrimSpace(f.git(f.dir, "rev-parse", mrHeadRef(1))); head != sha {
247 f.t.Fatalf("MR head ref = %s, want %s", head, sha)
248 }
249 for _, th := range f.threads(u) {
250 if strconv.Itoa(int(th.ID)) == thread && th.Resolved != u.Username {
251 f.t.Fatalf("thread %s resolved by %q, want %s", thread, th.Resolved, u.Username)
252 }
253 }
254 return sha
255}
256
257func (f *queueFixture) file(sha, path string) string {
258 f.t.Helper()
259 return f.git(f.dir, "show", sha+":"+path)
260}
261
262// Each shape of range: several lines to more, deletion, the last line of
263// a file with no final newline, and a CRLF file.
264func TestApplySuggestion(t *testing.T) {
265 f := newSuggestFixture(t, nil)
266 f.verified(f.alice)
267 cases := []struct {
268 path string
269 start, end int
270 lines []string
271 want string
272 }{
273 {"lib.txt", 2, 3, []string{"TWO", "THREE", "3.5"}, "one\nTWO\nTHREE\n3.5\nfour\nfive\n"},
274 {"lib.txt", 5, 6, nil, "one\nTWO\nTHREE\n3.5\n"},
275 {"tail.txt", 2, 2, []string{"LAST", "more"}, "x\nLAST\nmore"},
276 {"dos.txt", 2, 2, []string{"B", "B2"}, "a\r\nB\r\nB2\r\nc\r\n"},
277 }
278 for _, c := range cases {
279 id := f.suggest(f.alice, c.path, c.start, c.end, c.lines...)
280 sha := f.applyOK(f.alice, id)
281 if got := f.file(sha, c.path); got != c.want {
282 t.Errorf("%s %d-%d: file = %q, want %q", c.path, c.start, c.end, got, c.want)
283 }
284 }
285}
286
287// A suggestion whose lines changed, or whose file is gone, is refused;
288// so is one on a repository requiring signed commits, with the command
289// that applies it locally.
290func TestApplySuggestionRefusals(t *testing.T) {
291 f := newSuggestFixture(t, nil)
292 f.verified(f.alice)
293 stale := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
294 gone := f.suggest(f.alice, "tail.txt", 1, 1, "X")
295 plain := f.suggest(f.alice, "lib.txt", 2, 2, "two")
296 f.write("lib.txt", "uno\ntwo\nthree\nfour\nfive\n")
297 f.git(f.src, "rm", "-q", "tail.txt")
298 f.git(f.src, "commit", "-q", "-am", "moved on")
299 f.moveHead()
300 cases := []struct {
301 thread string
302 code int
303 want string
304 }{
305 {stale, protocol.ExitUsage, "outdated: " + reasonChanged},
306 {gone, protocol.ExitUsage, reasonGone},
307 {plain, protocol.ExitUsage, "changes nothing"},
308 {"999", protocol.ExitNotFound, "no thread 999"},
309 }
310 for _, c := range cases {
311 code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", c.thread)
312 if code != c.code || !strings.Contains(errOut, c.want) {
313 t.Errorf("thread %s: exit %d %q, want %d with %q", c.thread, code, errOut, c.code, c.want)
314 }
315 }
316
317 if _, err := f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.RequireSignedCommits = true }); err != nil {
318 t.Fatal(err)
319 }
320 ok := f.suggest(f.alice, "lib.txt", 2, 2, "TWO")
321 code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", ok)
322 if code != protocol.ExitDenied || !strings.Contains(errOut, "gitbay mr apply-suggestion alice/app 1 "+ok) {
323 t.Fatalf("signed repo: exit %d %q", code, errOut)
324 }
325}
326
327// The update is held to the pre-receive ref policy a push is: a source
328// branch that is protected under require-mr refuses it.
329func TestApplySuggestionHonoursRefPolicy(t *testing.T) {
330 f := newSuggestFixture(t, func(s *store.RepoSettings) {
331 s.ProtectedBranches = []string{"feature"}
332 s.RequireMR = true
333 })
334 f.verified(f.alice)
335 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
336 before := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
337 code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", id)
338 if code != protocol.ExitDenied || !strings.Contains(errOut, "merge requests only") {
339 t.Fatalf("exit %d %q, want the require-mr refusal", code, errOut)
340 }
341 if after := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); after != before {
342 t.Fatal("refused apply moved the branch")
343 }
344}
345
346// Only the source branch's writers apply: a reader cannot. A thread in
347// an unsubmitted review is not applied, and to anyone but its author it
348// does not exist.
349func TestApplySuggestionNeedsWrite(t *testing.T) {
350 f := newSuggestFixture(t, nil)
351 carol := f.user("carol", "read")
352 f.verified(carol)
353 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
354 code, _, errOut := f.runWith(carol, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", id)
355 if code != protocol.ExitDenied || !strings.Contains(errOut, "only its writers") {
356 t.Fatalf("reader: exit %d %q", code, errOut)
357 }
358
359 var out, stderr strings.Builder
360 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &stderr, JSON: true,
361 Stdin: strings.NewReader("```suggestion\nONE\n```\n")}
362 c.Cfg.Server.Root = f.root
363 c.Cfg.Limits.WriteRate = -1
364 if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", "lib.txt", "--line", "1", "--pending", "--file", "-"}); code != protocol.ExitOK {
365 t.Fatalf("pending diff-comment: %s", stderr.String())
366 }
367 var env struct {
368 Data struct {
369 Thread int64 `json:"thread"`
370 } `json:"data"`
371 }
372 json.Unmarshal([]byte(out.String()), &env)
373 pending := strconv.Itoa(int(env.Data.Thread))
374 if code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", pending); code != protocol.ExitUsage || !strings.Contains(errOut, "unsubmitted") {
375 t.Errorf("own pending thread: exit %d %q", code, errOut)
376 }
377 if code, _, _ := f.runWith(carol, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", pending); code != protocol.ExitNotFound {
378 t.Errorf("someone else's pending thread: exit %d, want not found", code)
379 }
380}
381
382// Applying is a push by the applier, so a queued merge sees it: a
383// writer's apply keeps the queue, and resolving the thread it came from
384// lets the queued merge land on the new head.
385func TestApplySuggestionReachesQueuedMerge(t *testing.T) {
386 f := newSuggestFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true })
387 f.verified(f.alice)
388 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
389 f.mustWrite(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
390 f.wantQueued("threads resolved")
391 sha := f.applyOK(f.alice, id)
392 f.wantMergedBy("alice")
393 if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != sha {
394 t.Fatalf("main = %s, want the applied commit %s", main, sha)
395 }
396}
397
398// On a merge request from a fork the source branch is the fork's, so its
399// writers apply and the target's do not. The fork writer's apply is a
400// push by someone who cannot merge into the target, which dequeues a
401// merge queued there.
402func TestApplySuggestionFork(t *testing.T) {
403 f := newSuggestFixture(t, nil)
404 bobID, err := f.st.CreateUser("bob", false)
405 if err != nil {
406 t.Fatal(err)
407 }
408 bob, _ := f.st.UserByID(bobID)
409 f.verified(f.alice)
410 f.verified(bob)
411 forkID, err := f.st.CreateRepo("user", bobID, "app", "public")
412 if err != nil {
413 t.Fatal(err)
414 }
415 fork, _ := f.st.RepoByID(forkID)
416 forkDir := RepoDir(f.root, fork.OwnerName, fork.Name)
417 f.git(f.root, "clone", "-q", "--bare", f.src, forkDir)
418 f.git(f.dir, "update-ref", mrHeadRef(2), f.headSHA)
419 if _, err := f.st.CreateMR(f.repo.ID, bobID, forkID, "feature", "main", "forked", "", f.headSHA, "md", false); err != nil {
420 t.Fatal(err)
421 }
422 var out, errOut strings.Builder
423 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut, JSON: true,
424 Stdin: strings.NewReader("```suggestion\nONE\n```\n")}
425 c.Cfg.Server.Root = f.root
426 c.Cfg.Limits.WriteRate = -1
427 if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "2", "--path", "lib.txt", "--line", "1", "--file", "-"}); code != protocol.ExitOK {
428 t.Fatalf("diff-comment: %s", errOut.String())
429 }
430 var env struct {
431 Data struct {
432 Thread int64 `json:"thread"`
433 } `json:"data"`
434 }
435 json.Unmarshal([]byte(out.String()), &env)
436 thread := strconv.Itoa(int(env.Data.Thread))
437
438 code, _, stderr := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "2", thread)
439 if code != protocol.ExitDenied || !strings.Contains(stderr, "bob/app:feature") {
440 t.Fatalf("target owner on a fork's branch: exit %d %q", code, stderr)
441 }
442
443 if _, err := f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }); err != nil {
444 t.Fatal(err)
445 }
446 f.mustWrite(f.alice, "mr", "merge", f.repo.Path(), "2", "--when-ready", "--strategy", "merge")
447 f.mustWrite(bob, "mr", "apply-suggestion", f.repo.Path(), "2", thread)
448 tip := strings.TrimSpace(f.git(forkDir, "rev-parse", "refs/heads/feature"))
449 if got := f.git(forkDir, "show", tip+":lib.txt"); !strings.HasPrefix(got, "ONE\ntwo\n") {
450 t.Fatalf("fork's lib.txt = %q", got)
451 }
452 mr, _ := f.st.MRByNumber(f.repo.ID, 2)
453 if mr.HeadSHA != tip {
454 t.Fatalf("MR head = %s, want the fork's new tip %s", mr.HeadSHA, tip)
455 }
456 if mr.QueuedAt != "" || mr.State != "open" {
457 t.Fatalf("queued merge after the fork writer's apply: state %s queued %q", mr.State, mr.QueuedAt)
458 }
459 cs, _ := f.st.ListMRComments(mr.ID)
460 said := false
461 for _, c := range cs {
462 said = said || (c.Kind == "system" && strings.Contains(c.Body, "bob pushed and cannot merge"))
463 }
464 if !said {
465 t.Fatalf("timeline does not say why the merge was dequeued: %+v", cs)
466 }
467}