Commit e99285743d

e99285743d3401ebda70be62e2142d4517b174a3

parent: 46a896187a

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 17:32 UTC

mr: what changed between two revisions of a merge request

A push stales every review, and nothing said what had moved. A diff of
the two heads cannot answer it — that shows the whole branch again,
rebases and all — and the head it used to be was overwritten in place, so
the question had no data behind it either.

mr_heads records one row per revision, with the merge base at the time.
`mr revisions` lists them; `mr range-diff` compares the last two, or any
two named with --from/--to. The MR page points at it once there is more
than one revision, next to the stale marks, which is where the question
comes up.

Each side of the comparison carries its own base. The target moves, and
measuring both revisions against today's base would attribute every
commit that landed on the target in between to this merge request's
author.

--creation-factor is 80 rather than git's default 60. The default is
tuned for comparing two independently developed patch series, where
refusing to pair is the safe answer. Here the two sides are known to be
revisions of one branch, and the commonest revision of all — a commit
that adds a file, with one line inside it later changed — is not paired
at 60: git reports one commit deleted and a different one added, which
tells a reviewer nothing. It pairs at 80, and two unrelated commits are
still correctly left unpaired there. Both measured before choosing.

A push that does not move the head adds no revision, and a merge request
with one revision reports that there is nothing to compare and exits 0 —
the argv was fine and the answer is "nothing changed".

Last of the three stages. #111's fourth bullet, mr diff truncating
silently, landed in v1.11.0 with #117.

Closes #111

Layout: unified · split

cmd/gitbay/main.go +2
@@ -507,6 +507,8 @@ func mrCmd() *cobra.Command {
507507 pass("review", "submit a review: --approve|--request-changes|--comment, or --discard a pending batch", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
508508 pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
509509 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
510 pass("revisions", "the heads this merge request has had", passOpts{server: []string{"mr", "revisions"}, needsRepo: true}),
511 pass("range-diff", "what changed between two revisions: [--from <sha>] [--to <sha>]", passOpts{server: []string{"mr", "range-diff"}, needsRepo: true}),
510512 pass("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}),
511513 pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}),
512514 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
e2e/rangediff_test.go added +124
@@ -0,0 +1,124 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestMRRangeDiff is #111's last stage: a push stales every review and
12// nothing said what had changed between the two heads. A plain diff of
13// the heads cannot answer that — it shows the whole branch again.
14func TestMRRangeDiff(t *testing.T) {
15 inst := startInstance(t)
16 key := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
18 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
19 t.Fatalf("repo create: %s", errOut)
20 }
21 env := inst.gitEnv(key)
22 work := t.TempDir()
23 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
24 dir := filepath.Join(work, "w")
25 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644)
26 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
27 mustGit(t, dir, env, "add", ".")
28 mustGit(t, dir, env, "commit", "-q", "-m", "base")
29 mustGit(t, dir, env, "push", "-q", "origin", "main")
30
31 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
32 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("alpha\nbeta\ngamma\n"), 0o644)
33 mustGit(t, dir, env, "add", ".")
34 mustGit(t, dir, env, "commit", "-q", "-m", "add b")
35 mustGit(t, dir, env, "push", "-q", "origin", "feat")
36 if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app",
37 "--source", "feat", "--target", "main", "--title", "'add b'"); code != 0 {
38 t.Fatalf("mr create: %s", errOut)
39 }
40
41 // One revision: nothing to compare, and saying so is not a failure.
42 out, errOut, code := inst.ssh(t, key, "", "mr", "range-diff", "alice/app", "1")
43 if code != 0 || !strings.Contains(errOut, "one revision") {
44 t.Fatalf("single-revision range-diff: exit %d, %s, %s", code, errOut, out)
45 }
46
47 // Amend and force-push: the same commit with one line changed, which
48 // is what addressing review feedback looks like. A diff of the two
49 // heads cannot describe this — it shows the whole branch again.
50 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("alpha\nbeta revised\ngamma\n"), 0o644)
51 mustGit(t, dir, env, "add", ".")
52 mustGit(t, dir, env, "commit", "-q", "--amend", "--no-edit")
53 mustGit(t, dir, env, "push", "-q", "--force", "origin", "feat")
54
55 revs := revisions(t, inst, key)
56 if len(revs) != 2 {
57 t.Fatalf("revisions = %d, want 2 after a force-push: %+v", len(revs), revs)
58 }
59 if !revs[1].Current {
60 t.Fatalf("the newest revision is not marked current: %+v", revs)
61 }
62
63 out, errOut, code = inst.ssh(t, key, "", "mr", "range-diff", "alice/app", "1")
64 if code != 0 {
65 t.Fatalf("range-diff: %s", errOut)
66 }
67 // The two versions of the one commit are paired — "1: <old> ! 1:
68 // <new>" — and the interdiff shows the one line that moved, not the
69 // whole branch.
70 if !strings.Contains(out, "add b") {
71 t.Fatalf("range-diff does not mention the commit:\n%s", out)
72 }
73 if !strings.Contains(out, "!") {
74 t.Fatalf("range-diff did not pair the amended commit:\n%s", out)
75 }
76 if !strings.Contains(out, "beta revised") {
77 t.Fatalf("range-diff does not show the changed line:\n%s", out)
78 }
79 // One commit on each side, paired: a plain diff of the two heads
80 // would instead show b.txt created from nothing all over again.
81 if strings.Count(out, "add b") != 1 {
82 t.Fatalf("range-diff lists the commit more than once:\n%s", out)
83 }
84
85 // Naming revisions explicitly, and refusing one that is not a
86 // revision of this merge request.
87 if _, _, code := inst.ssh(t, key, "", "mr", "range-diff", "alice/app", "1",
88 "--from", revs[0].SHA, "--to", revs[1].SHA); code != 0 {
89 t.Fatal("explicit --from/--to failed")
90 }
91 if _, errOut, code := inst.ssh(t, key, "", "mr", "range-diff", "alice/app", "1",
92 "--from", "0123456789ab"); code != 3 || !strings.Contains(errOut, "not a revision") {
93 t.Fatalf("unknown revision: exit %d, %s", code, errOut)
94 }
95 if _, _, code := inst.ssh(t, key, "", "mr", "range-diff", "alice/app", "1",
96 "--from", revs[1].SHA, "--to", revs[1].SHA); code != 2 {
97 t.Fatal("comparing a revision with itself was accepted")
98 }
99
100 // A push that changes nothing does not add a revision.
101 mustGit(t, dir, env, "push", "-q", "--force", "origin", "feat")
102 if got := revisions(t, inst, key); len(got) != 2 {
103 t.Fatalf("a no-op push added a revision: %d", len(got))
104 }
105}
106
107type revision struct {
108 N int `json:"n"`
109 SHA string `json:"sha"`
110 Current bool `json:"current"`
111}
112
113func revisions(t *testing.T, inst *instance, key string) []revision {
114 t.Helper()
115 out, errOut, code := inst.ssh(t, key, "", "mr", "revisions", "alice/app", "1", "--json")
116 if code != 0 {
117 t.Fatalf("mr revisions: %s", errOut)
118 }
119 var env struct {
120 Data []revision `json:"data"`
121 }
122 json.Unmarshal([]byte(out), &env)
123 return env.Data
124}
e2e/readonly_test.go +2
@@ -144,6 +144,8 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
144144 "release asset get": {"alice/app", "v1", "a.txt"},
145145 "notifications list": nil,
146146 "search": {"app"},
147 "mr revisions": {"alice/app", "1"},
148 "mr range-diff": {"alice/app", "1"},
147149 "webhook list": {"alice/app"},
148150 "webhook deliveries": {"alice/app"},
149151 "wiki list": {"alice/app"},
internal/control/mr.go +136
@@ -39,6 +39,14 @@ func init() {
3939 Summary: "open a merge request",
4040 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org] [--draft]",
4141 ReadsStdin: true, Run: runMRCreate})
42 register(Command{Path: []string{"mr", "range-diff"},
43 Summary: "what changed between two revisions of a merge request",
44 Usage: "mr range-diff <owner/name> <n> [--from <sha>] [--to <sha>]",
45 ReadOnly: true, Run: runMRRangeDiff})
46 register(Command{Path: []string{"mr", "revisions"},
47 Summary: "the heads a merge request has had",
48 Usage: "mr revisions <owner/name> <n>",
49 ReadOnly: true, Run: runMRRevisions})
4250 register(Command{Path: []string{"mr", "draft"},
4351 Summary: "mark a merge request as work in progress",
4452 Usage: "mr draft <owner/name> <n>", Run: runMRDraft})
@@ -1306,3 +1314,131 @@ func reviewAction(number int64, verdict string, published int64) string {
13061314 }
13071315 return fmt.Sprintf("reviewed !%d: %s", number, verdict)
13081316}
1317
1318// RevisionOut is one head a merge request has had.
1319type RevisionOut struct {
1320 N int `json:"n"` // 1 is the first push
1321 SHA string `json:"sha"`
1322 BaseSHA string `json:"base_sha,omitempty"`
1323 CreatedAt string `json:"created_at"`
1324 Current bool `json:"current,omitempty"`
1325}
1326
1327func mrRevisions(c *Ctx, mr store.MR) ([]RevisionOut, error) {
1328 heads, err := c.Store.MRHeads(mr.ID)
1329 if err != nil {
1330 return nil, err
1331 }
1332 out := make([]RevisionOut, 0, len(heads))
1333 for i, h := range heads {
1334 out = append(out, RevisionOut{N: i + 1, SHA: h.SHA, BaseSHA: h.BaseSHA,
1335 CreatedAt: h.CreatedAt, Current: h.SHA == mr.HeadSHA})
1336 }
1337 return out, nil
1338}
1339
1340func runMRRevisions(c *Ctx, args []string) int {
1341 repo, mr, code := mrRef(c, args, policy.CanRead)
1342 if code >= 0 {
1343 return code
1344 }
1345 if len(args) != 2 {
1346 return c.fail(protocol.ExitUsage, "usage: mr revisions <owner/name> <n>")
1347 }
1348 revs, err := mrRevisions(c, mr)
1349 if err != nil {
1350 return c.fail(protocol.ExitFailure, "%v", err)
1351 }
1352 return c.emit(revs, func(w io.Writer) {
1353 for _, r := range revs {
1354 mark := " "
1355 if r.Current {
1356 mark = "*"
1357 }
1358 fmt.Fprintf(w, "%s v%d\t%.10s\t%s\n", mark, r.N, r.SHA, r.CreatedAt)
1359 }
1360 if len(revs) < 2 {
1361 fmt.Fprintf(w, "\nonly one revision; %s!%d has not been pushed to since it was opened\n",
1362 repo.Path(), mr.Number)
1363 }
1364 })
1365}
1366
1367func runMRRangeDiff(c *Ctx, args []string) int {
1368 const usage = "mr range-diff <owner/name> <n> [--from <sha>] [--to <sha>]"
1369 f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: usage})
1370 if err != nil {
1371 return c.fail(protocol.ExitUsage, "%v", err)
1372 }
1373 repo, mr, code := mrRef(c, f.Pos, policy.CanRead)
1374 if code >= 0 {
1375 return code
1376 }
1377 if len(f.Pos) != 2 {
1378 return c.fail(protocol.ExitUsage, "usage: %s", usage)
1379 }
1380 revs, err := mrRevisions(c, mr)
1381 if err != nil {
1382 return c.fail(protocol.ExitFailure, "%v", err)
1383 }
1384 // One revision is a merge request nobody has pushed to since it was
1385 // opened. The argv was fine and the answer is "nothing changed", so
1386 // this succeeds with an empty patch rather than failing.
1387 if len(revs) < 2 {
1388 fmt.Fprintf(c.Stderr, "%s!%d has one revision; nothing to compare it against\n",
1389 repo.Path(), mr.Number)
1390 return protocol.ExitOK
1391 }
1392 // Default to the two most recent, which is "what changed since the
1393 // last push" — the question a stale review asks.
1394 from, to := revs[len(revs)-2], revs[len(revs)-1]
1395 pick := func(sha string) (RevisionOut, bool) {
1396 for _, r := range revs {
1397 if strings.HasPrefix(r.SHA, sha) {
1398 return r, true
1399 }
1400 }
1401 return RevisionOut{}, false
1402 }
1403 if v := f.Value("--from"); v != "" {
1404 r, ok := pick(v)
1405 if !ok {
1406 return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number)
1407 }
1408 from = r
1409 }
1410 if v := f.Value("--to"); v != "" {
1411 r, ok := pick(v)
1412 if !ok {
1413 return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number)
1414 }
1415 to = r
1416 }
1417 if from.SHA == to.SHA {
1418 return c.fail(protocol.ExitUsage, "--from and --to are the same revision")
1419 }
1420
1421 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1422 // A revision recorded before its base could be worked out, or by a
1423 // migration backfill, falls back to the target's merge base.
1424 baseOf := func(r RevisionOut) string {
1425 if r.BaseSHA != "" {
1426 return r.BaseSHA
1427 }
1428 b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, r.SHA)
1429 if err != nil {
1430 return r.SHA + "^"
1431 }
1432 return b
1433 }
1434 patch, truncated, err := gitutil.RangeDiff(dir, baseOf(from), from.SHA, baseOf(to), to.SHA, 4<<20)
1435 if err != nil {
1436 return c.fail(protocol.ExitFailure,
1437 "%v (the objects for an older revision may have been garbage-collected)", err)
1438 }
1439 fmt.Fprint(c.Stdout, patch)
1440 if truncated {
1441 fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB")
1442 }
1443 return protocol.ExitOK
1444}
internal/gitutil/merge.go +29
@@ -284,3 +284,32 @@ func DiffFiles(dir, old, new string) ([]string, error) {
284284 }
285285 return files, nil
286286}
287
288// RangeDiff compares two revisions of the same work: what the commits
289// between oldBase and oldHead became between newBase and newHead. This is
290// what answers "what changed since I reviewed this", which a plain diff
291// of the two heads cannot — that shows the whole branch again, rebases
292// and all.
293//
294// Each side carries its own base, because the target moves: comparing
295// both revisions against today's base would attribute every commit that
296// landed on the target in between to the author of this merge request.
297//
298// --creation-factor is raised from git's default of 60. That default is
299// tuned for comparing two independently developed patch series, where
300// refusing to pair is the safe answer. Here the two sides are known to be
301// revisions of one branch, and the commonest revision of all — a commit
302// that adds a file, with one line inside it changed — is not paired at
303// 60: git reports the commit as deleted and a different one added, which
304// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
305// commits are still left unpaired there; both measured.
306func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
307 cmd := exec.Command("git", "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
308 oldBase+".."+oldHead, newBase+".."+newHead)
309 out, err := cmd.Output()
310 if err != nil {
311 return "", false, fmt.Errorf("range-diff: %w", err)
312 }
313 out, truncated = cutAtLine(out, limit)
314 return string(out), truncated, nil
315}
internal/hookd/hookd.go +9 −1
@@ -247,7 +247,15 @@ func (s *Server) postReceive(req Request) {
247247 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
248248 continue
249249 }
250 if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
250 // The merge base as it stands now, so a later range-diff
251 // compares each revision against the target it was written
252 // on rather than against today's. Best-effort: a base that
253 // cannot be worked out costs precision, not the record.
254 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
255 if err != nil {
256 base = ""
257 }
258 if err := s.st.UpdateMRHead(mr.ID, u.New, base); err != nil {
251259 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
252260 }
253261 if srcRepo.ID != target.ID {
internal/httpd/mrpage_test.go +1
@@ -28,6 +28,7 @@ type mrPageData struct {
2828 CanEdit bool
2929 CanWrite bool
3030 Unresolved int
31 Revisions []store.MRHead
3132 Notice string
3233 DetachedThreads []diffThread
3334}
internal/httpd/web.go +6 −1
@@ -1805,6 +1805,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18051805 // its own view rather than a fold at the foot of the conversation.
18061806 // A query parameter keeps this working without JavaScript.
18071807 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1808 // The revisions this merge request has had. A stale review is the
1809 // moment someone wants to know what moved, so the link to the
1810 // range-diff belongs next to it.
1811 revisions, _ := s.st.MRHeads(m.ID)
18081812 branches, _ := gitutil.Refs(p.Dir, "heads")
18091813 view := r.URL.Query().Get("view")
18101814 if view != "commits" && view != "diff" {
@@ -1839,13 +1843,14 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18391843 CanEdit bool
18401844 CanWrite bool
18411845 Unresolved int
1846 Revisions []store.MRHead
18421847 Notice string
18431848 DetachedThreads []diffThread
18441849 StackedOn *store.MR
18451850 Stacked []store.MR
18461851 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
18471852 reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1848 canWrite, unresolved, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1853 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
18491854}
18501855
18511856func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/store/migrations/0039_mr_head_history.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE mr_heads;
internal/store/migrations/0039_mr_head_history.up.sql added +22
@@ -0,0 +1,22 @@
1-- Every push to a merge request's source stales its reviews, and nothing
2-- said what had changed between the two heads. Answering that needs the
3-- head it used to be, which nothing kept: merge_requests.head_sha is
4-- overwritten in place (#111).
5--
6-- One row per head a merge request has had, oldest first by id. The base
7-- recorded alongside is the merge base at that moment, so a range-diff
8-- compares like with like even when the target moved underneath.
9CREATE TABLE mr_heads (
10 id INTEGER PRIMARY KEY,
11 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
12 sha TEXT NOT NULL,
13 base_sha TEXT NOT NULL DEFAULT '',
14 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
15);
16CREATE INDEX mr_heads_mr ON mr_heads(mr_id, id);
17
18-- The current head of every existing merge request, so one that has never
19-- been force-pushed since this migration still has a first entry to
20-- measure from.
21INSERT INTO mr_heads (mr_id, sha, base_sha)
22SELECT id, head_sha, COALESCE(merged_base, '') FROM merge_requests WHERE head_sha <> '';
internal/store/mrs.go +51 −1
@@ -60,6 +60,16 @@ func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, target
6060 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format, draft); err != nil {
6161 return 0, err
6262 }
63 if headSHA != "" {
64 var mrID int64
65 if err := tx.QueryRow("SELECT id FROM merge_requests WHERE repo_id = ? AND number = ?",
66 repoID, n).Scan(&mrID); err != nil {
67 return 0, err
68 }
69 if _, err := tx.Exec("INSERT INTO mr_heads (mr_id, sha) VALUES (?, ?)", mrID, headSHA); err != nil {
70 return 0, err
71 }
72 }
6373 return n, tx.Commit()
6474}
6575
@@ -241,7 +251,11 @@ func (s *Store) SetMRState(mrID int64, state string) error {
241251
242252// UpdateMRHead records a new head and marks every review at another head
243253// stale, in one transaction.
244func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
254// UpdateMRHead moves a merge request onto a new head, stales the reviews
255// of the old one, and records the head in the history a range-diff reads.
256// baseSHA is the merge base at this moment; "" when the caller could not
257// work it out, which only costs the range-diff its precision.
258func (s *Store) UpdateMRHead(mrID int64, headSHA, baseSHA string) error {
245259 tx, err := s.DB.Begin()
246260 if err != nil {
247261 return err
@@ -256,9 +270,45 @@ func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
256270 "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
257271 return err
258272 }
273 // Same head twice is a push that changed nothing about this merge
274 // request; it should not add a revision to compare against.
275 var last string
276 tx.QueryRow("SELECT sha FROM mr_heads WHERE mr_id = ? ORDER BY id DESC LIMIT 1", mrID).Scan(&last)
277 if last != headSHA {
278 if _, err := tx.Exec(
279 "INSERT INTO mr_heads (mr_id, sha, base_sha) VALUES (?, ?, ?)", mrID, headSHA, baseSHA); err != nil {
280 return err
281 }
282 }
259283 return tx.Commit()
260284}
261285
286// MRHead is one revision a merge request has had.
287type MRHead struct {
288 SHA string
289 BaseSHA string
290 CreatedAt string
291}
292
293// MRHeads returns a merge request's revisions, oldest first.
294func (s *Store) MRHeads(mrID int64) ([]MRHead, error) {
295 rows, err := s.DB.Query(
296 "SELECT sha, base_sha, created_at FROM mr_heads WHERE mr_id = ? ORDER BY id", mrID)
297 if err != nil {
298 return nil, err
299 }
300 defer rows.Close()
301 var out []MRHead
302 for rows.Next() {
303 var h MRHead
304 if err := rows.Scan(&h.SHA, &h.BaseSHA, &h.CreatedAt); err != nil {
305 return nil, err
306 }
307 out = append(out, h)
308 }
309 return out, rows.Err()
310}
311
262312// SetMRTarget retargets a merge request and marks every existing review
263313// stale, in one transaction. The base of the diff is derived from the
264314// target on every read, so nothing else has to move; an approval,
internal/web/templates/mr.html +2
@@ -130,6 +130,8 @@
130130 <h2>Reviews</h2>
131131 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p>
132132 {{else}}<p class="none">No reviews yet</p>{{end}}
133 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
134 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
133135 </div>
134136 <div class="grp">
135137 <h2>Checks</h2>