Commit 6f184c2936

6f184c29368e579846855669aa233b4a2c72814d

parent: f150dbe6d8

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

cmc <hello@cleberg.net> · 2026-09-04 16:42 UTC

mr: a merge request can say it is not asking yet

`mr create --draft`, `mr draft`, `mr ready`. A draft does not merge and
does not appear in anyone's review queue; marking it ready is the request
for review and notifies participants, while going back to draft withdraws
one and is not worth an inbox.

Draft is a column, not a fifth state. State is open | merged | closed |
source_gone, and adding to it would change what every `state = 'open'`
query means — the merge gates, the review queue, the list filters. A
draft is open; it is just not asking.

The merge refusal is unconditional. Every other gate is a repository
setting an admin turns on; this one is the author's own statement about
their own work, so there is nothing to configure.

First of the three things #111 asks for, in the order it asks for them.
Batched review and range-diff are still open; the truncation notice it
also lists landed in v1.11.0 with #117.

Ref #111

Layout: unified · split

cmd/gitbay/main.go +2
@@ -507,6 +507,8 @@ func mrCmd() *cobra.Command {
507507 pass("review", "review: --approve|--request-changes|--comment", 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("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}),
511 pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}),
510512 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
511513 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
512514 pass("retarget", "retarget onto another branch: <n> <branch>", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}),
e2e/mrdraft_test.go added +99
@@ -0,0 +1,99 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestMRDraft covers the first stage of #111: a merge request opened to
12// show work rather than to ask for a merge.
13func TestMRDraft(t *testing.T) {
14 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
18 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
23 t.Fatal("grant failed")
24 }
25
26 env := inst.gitEnv(aliceKey)
27 work := t.TempDir()
28 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
29 dir := filepath.Join(work, "w")
30 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "base")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
36 mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "work")
37 mustGit(t, dir, env, "push", "-q", "origin", "feat")
38
39 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
40 "--source", "feat", "--target", "main", "--title", "'wip'", "--draft"); code != 0 {
41 t.Fatalf("mr create --draft: %s", errOut)
42 }
43
44 draft := func() bool {
45 t.Helper()
46 out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
47 if code != 0 {
48 t.Fatalf("mr show: %s", errOut)
49 }
50 var env struct {
51 Data struct {
52 State string `json:"state"`
53 Draft bool `json:"draft"`
54 } `json:"data"`
55 }
56 json.Unmarshal([]byte(out), &env)
57 // A draft is open, not a fifth state.
58 if env.Data.State != "open" {
59 t.Fatalf("draft changed the state to %q", env.Data.State)
60 }
61 return env.Data.Draft
62 }
63
64 if !draft() {
65 t.Fatal("--draft did not mark it")
66 }
67
68 // A draft does not merge, and no repository setting is involved.
69 _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1")
70 if code != 4 || !strings.Contains(errOut, "is a draft") {
71 t.Fatalf("draft merged: exit %d, %s", code, errOut)
72 }
73
74 // Nor does it sit in anyone's review queue.
75 out, _, _ := inst.ssh(t, bobKey, "", "dashboard", "--json")
76 if strings.Contains(out, `"review_queue":[{`) {
77 t.Fatalf("draft is in the review queue: %s", out)
78 }
79
80 // Ready, and now it does both.
81 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "ready", "alice/app", "1"); code != 0 {
82 t.Fatalf("mr ready: %s", errOut)
83 }
84 if draft() {
85 t.Fatal("mr ready did not clear the mark")
86 }
87 out, _, _ = inst.ssh(t, bobKey, "", "dashboard", "--json")
88 if !strings.Contains(out, `"number":1`) {
89 t.Fatalf("ready MR is not in the review queue: %s", out)
90 }
91 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1"); code != 0 {
92 t.Fatalf("ready MR refused: %s", errOut)
93 }
94
95 // Ready twice is a usage error rather than a silent no-op.
96 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "ready", "alice/app", "1"); code == 0 {
97 t.Fatal("mr ready on a merged MR succeeded")
98 }
99}
internal/control/events.go +1
@@ -31,6 +31,7 @@ var EventKinds = []string{
3131 "mr.closed",
3232 "mr.commented",
3333 "mr.created",
34 "mr.draft",
3435 "mr.edited",
3536 "mr.merged",
3637 "mr.milestoned",
internal/control/ghimport.go +1 −1
@@ -169,7 +169,7 @@ func runImportIssues(c *Ctx, args []string) int {
169169 return c.fail(protocol.ExitFailure, "%v", err)
170170 }
171171 body := attribution(src, it.Number, "pull request", it.User.Login, it.CreatedAt) + it.Body
172 localN, err = c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, pr.Head.Ref, pr.Base.Ref, it.Title, body, pr.Head.SHA, "md")
172 localN, err = c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, pr.Head.Ref, pr.Base.Ref, it.Title, body, pr.Head.SHA, "md", false)
173173 if err != nil {
174174 return c.fail(protocol.ExitFailure, "%v", err)
175175 }
internal/control/migrate.go +1 −1
@@ -256,7 +256,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
256256 continue
257257 }
258258 body := migAttribution(src, "merge request", bm.Author, bm.CreatedAt, bm.Number) + bm.Body
259 n, err := c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, bm.SourceRef, bm.TargetRef, bm.Title, body, "", "md")
259 n, err := c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, bm.SourceRef, bm.TargetRef, bm.Title, body, "", "md", false)
260260 if err != nil {
261261 return c.fail(protocol.ExitFailure, "%v", err)
262262 }
internal/control/mr.go +82 −8
@@ -37,8 +37,14 @@ func init() {
3737 Usage: "repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
3838 register(Command{Path: []string{"mr", "create"},
3939 Summary: "open a merge request",
40 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org]",
40 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", "draft"},
43 Summary: "mark a merge request as work in progress",
44 Usage: "mr draft <owner/name> <n>", Run: runMRDraft})
45 register(Command{Path: []string{"mr", "ready"},
46 Summary: "take the draft mark off, so it can merge",
47 Usage: "mr ready <owner/name> <n>", Run: runMRReady})
4248 register(Command{Path: []string{"mr", "list"},
4349 Summary: "list merge requests",
4450 Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
@@ -229,15 +235,16 @@ func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool
229235func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
230236
231237func runMRCreate(c *Ctx, args []string) int {
232 f, err := parseFlags(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"}, MaxPos: 1,
233 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>"})
238 f, err := parseFlags(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"},
239 Bools: []string{"--draft"}, MaxPos: 1,
240 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"})
234241 if err != nil {
235242 return c.fail(protocol.ExitUsage, "%v", err)
236243 }
237244 path, source, target := f.pos(0), f.Value("--source"), f.Value("--target")
238245 title, body, file, format := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
239246 if path == "" || source == "" || title == "" {
240 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
247 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]")
241248 }
242249 fmtName, err := markupFormat(format)
243250 if err != nil {
@@ -280,7 +287,7 @@ func runMRCreate(c *Ctx, args []string) int {
280287 if err != nil {
281288 return c.failErr(err)
282289 }
283 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
290 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName, f.Has("--draft"))
284291 if err != nil {
285292 return c.fail(protocol.ExitFailure, "%v", err)
286293 }
@@ -315,6 +322,8 @@ type mrOut struct {
315322 Number int64 `json:"number"`
316323 Title string `json:"title"`
317324 State string `json:"state"`
325 // Draft is an open merge request not asking to be merged yet.
326 Draft bool `json:"draft,omitempty"`
318327 Author string `json:"author"`
319328 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
320329 TargetRef string `json:"target_ref"`
@@ -372,7 +381,7 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
372381 src = m.SourcePath + ":" + m.SourceRef
373382 }
374383 }
375 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
384 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author,
376385 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
377386 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
378387 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy}
@@ -433,7 +442,11 @@ func runMRList(c *Ctx, args []string) int {
433442 if d.StackedOn != nil {
434443 stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number)
435444 }
436 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef, stacked)
445 state := d.State
446 if d.Draft {
447 state = "draft"
448 }
449 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, state, d.Title, d.Source, d.TargetRef, stacked)
437450 }
438451 })
439452}
@@ -513,7 +526,11 @@ func runMRShow(c *Ctx, args []string) int {
513526 UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs}
514527 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
515528 return c.emit(d, func(w io.Writer) {
516 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
529 state := d.State
530 if d.Draft {
531 state = "draft"
532 }
533 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, state, d.Author, d.Source, d.TargetRef, d.HeadSHA)
517534 if d.StackedOn != nil {
518535 fmt.Fprintf(w, "stacked on !%d %s\n", d.StackedOn.Number, d.StackedOn.Title)
519536 }
@@ -1049,6 +1066,12 @@ func runMRMerge(c *Ctx, args []string) int {
10491066// per reviewer; a fresh request-changes blocks), require_codeowners, and
10501067// require_resolved. Returns -1 to proceed.
10511068func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1069 // A draft is open but not asking. This gate is unconditional — no
1070 // setting turns it off — because the author said so themselves.
1071 if mr.Draft {
1072 return c.fail(protocol.ExitDenied,
1073 "!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number)
1074 }
10521075 set := repo.Settings
10531076 reviews, err := c.Store.ListMRReviews(mr.ID)
10541077 if err != nil {
@@ -1156,6 +1179,57 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
11561179 return -1
11571180}
11581181
1182func runMRDraft(c *Ctx, args []string) int { return setMRDraft(c, args, true) }
1183func runMRReady(c *Ctx, args []string) int { return setMRDraft(c, args, false) }
1184
1185func setMRDraft(c *Ctx, args []string, draft bool) int {
1186 verb := "ready"
1187 if draft {
1188 verb = "draft"
1189 }
1190 repo, mr, code := mrRef(c, args, policy.CanRead)
1191 if code >= 0 {
1192 return code
1193 }
1194 if code := refuseArchived(c, repo); code >= 0 {
1195 return code
1196 }
1197 if len(args) != 2 {
1198 return c.fail(protocol.ExitUsage, "usage: mr %s <owner/name> <n>", verb)
1199 }
1200 if code := authorOrWrite(c, repo, mr.Author, "change this merge request"); code >= 0 {
1201 return code
1202 }
1203 if mr.State != "open" && mr.State != "source_gone" {
1204 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1205 }
1206 if mr.Draft == draft {
1207 state := "already ready"
1208 if draft {
1209 state = "already a draft"
1210 }
1211 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, state)
1212 }
1213 if err := c.Store.SetMRDraft(mr.ID, draft); err != nil {
1214 return c.fail(protocol.ExitFailure, "%v", err)
1215 }
1216 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft",
1217 fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft))
1218 // Marking ready is the request for review; going back to draft
1219 // withdraws it and is not worth anyone's inbox.
1220 if !draft {
1221 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1222 notify(c, parts, notice{repo: repo, kind: "mr",
1223 subject: mrSubject(repo, mr.Number, mr.Title),
1224 action: fmt.Sprintf("marked !%d ready for review", mr.Number),
1225 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1226 }
1227 }
1228 return c.emit(map[string]any{"number": mr.Number, "draft": draft}, func(w io.Writer) {
1229 fmt.Fprintf(w, "%s!%d is %s\n", repo.Path(), mr.Number, map[bool]string{true: "a draft", false: "ready"}[draft])
1230 })
1231}
1232
11591233func runMRClose(c *Ctx, args []string) int {
11601234 repo, mr, code := mrRef(c, args, policy.CanRead)
11611235 if code >= 0 {
internal/httpd/mractions.go +11
@@ -68,6 +68,17 @@ func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.U
6868 s.done(w, r, code, msg, s.mrRedirect)
6969}
7070
71// mrDraftSubmit toggles the draft mark. The form says which way it is
72// going, so a stale page cannot flip the wrong one.
73func (s *Server) mrDraftSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
74 verb := "ready"
75 if r.FormValue("draft") == "on" {
76 verb = "draft"
77 }
78 _, msg, code := s.runControlCode(u, mrArgs(r, verb))
79 s.done(w, r, code, msg, s.mrRedirect)
80}
81
7182// mrDiffCommentSubmit opens a review thread on a diff line, or replies to
7283// one. The body goes in on stdin: it is user prose, and argv is visible in
7384// /proc.
internal/httpd/routes.go +2
@@ -167,6 +167,8 @@ func (s *Server) Routes() []Route {
167167 Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))},
168168 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/close", Mutating: true,
169169 Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))},
170 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/draft", Mutating: true,
171 Handler: s.checkOrigin(s.requireUser(s.mrDraftSubmit))},
170172 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/retarget", Mutating: true,
171173 Handler: s.checkOrigin(s.requireUser(s.mrRetargetSubmit))},
172174 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true,
internal/store/dashboard.go +1
@@ -139,6 +139,7 @@ const reviewQueueQuery = `
139139 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
140140 JOIN users au ON au.id = x.author_id
141141 WHERE x.state IN ('open', 'source_gone')
142 AND x.draft = 0
142143 AND x.author_id <> ?1
143144 AND NOT EXISTS (SELECT 1 FROM mr_reviews rv
144145 WHERE rv.mr_id = x.id AND rv.reviewer_id = ?1
internal/store/migrations/0037_mr_draft.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE merge_requests DROP COLUMN draft;
internal/store/migrations/0037_mr_draft.up.sql added +6
@@ -0,0 +1,6 @@
1-- A merge request opened to show work in progress rather than to ask for
2-- a merge. Deliberately a flag, not a state: state is open | merged |
3-- closed | source_gone, and adding a fifth would change what every
4-- `state = 'open'` query means, including the merge gates and the review
5-- queue. A draft is open; it is just not asking yet (#111).
6ALTER TABLE merge_requests ADD COLUMN draft INTEGER NOT NULL DEFAULT 0;
internal/store/mrs.go +27 −15
@@ -19,15 +19,18 @@ type MR struct {
1919 Body string
2020 BodyFormat string // md | org
2121 State string // open | merged | closed | source_gone
22 Milestone string
23 HeadSHA string
24 MergedBase string // target tip at merge time; base for historical diffs
25 MergedAt string // "" unless merged
26 MergedBy string // "" when unknown (imports) or the account is gone
27 ClosedAt string // "" unless closed without merging
28 ClosedBy string
29 CreatedAt string
30 UpdatedAt string
22 // Draft marks an open merge request that is not asking to be merged
23 // yet. Not a state: see migration 0037.
24 Draft bool
25 Milestone string
26 HeadSHA string
27 MergedBase string // target tip at merge time; base for historical diffs
28 MergedAt string // "" unless merged
29 MergedBy string // "" when unknown (imports) or the account is gone
30 ClosedAt string // "" unless closed without merging
31 ClosedBy string
32 CreatedAt string
33 UpdatedAt string
3134}
3235
3336type MRReview struct {
@@ -38,7 +41,7 @@ type MRReview struct {
3841 CreatedAt string
3942}
4043
41func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string) (int64, error) {
44func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string, draft bool) (int64, error) {
4245 tx, err := s.DB.Begin()
4346 if err != nil {
4447 return 0, err
@@ -52,19 +55,28 @@ func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, target
5255 return 0, err
5356 }
5457 if _, err := tx.Exec(`
55 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format)
56 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
57 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format); err != nil {
58 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format, draft)
59 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
60 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format, draft); err != nil {
5861 return 0, err
5962 }
6063 return n, tx.Commit()
6164}
6265
66// SetMRDraft marks an open merge request as a draft, or takes the mark
67// off. Merging is refused while it is set.
68func (s *Store) SetMRDraft(mrID int64, draft bool) error {
69 _, err := s.DB.Exec(
70 "UPDATE merge_requests SET draft = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
71 draft, mrID)
72 return err
73}
74
6375const mrSelect = `
6476 SELECT m.id, m.repo_id, m.number, u.username,
6577 COALESCE(m.source_repo_id, 0),
6678 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
67 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state,
79 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
6880 COALESCE(ms.title, ''), m.head_sha,
6981 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
7082 m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at
@@ -80,7 +92,7 @@ const mrSelect = `
8092func scanMR(row interface{ Scan(...any) error }) (MR, error) {
8193 var m MR
8294 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
83 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase,
95 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
8496 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt)
8597 return m, err
8698}
internal/store/mrs_test.go +1 −1
@@ -16,7 +16,7 @@ func mrFixture(t *testing.T) (*Store, int64, int64) {
1616 if err != nil {
1717 t.Fatal(err)
1818 }
19 if _, err := s.CreateMR(repoID, uid, repoID, "feature", "main", "t", "", "abc123", "md"); err != nil {
19 if _, err := s.CreateMR(repoID, uid, repoID, "feature", "main", "t", "", "abc123", "md", false); err != nil {
2020 t.Fatal(err)
2121 }
2222 return s, repoID, uid
internal/web/templates/mr.html +5 −1
@@ -3,7 +3,7 @@
33{{define "content"}}
44{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
55<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
6<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6<p class="issuemeta">{{if .MR.Draft}}<span class="chip chip-neutral">draft</span> {{end}}<span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
77{{if and (eq .MR.State "merged") .MR.MergedAt}}
88 {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}}
99 {{template "mrrange" .MR}} on {{when .MR.MergedAt}}
@@ -107,6 +107,10 @@
107107 <form method="post" action="{{$base}}/close" class="actions">
108108 <button type="submit">Close without merging</button>
109109 </form>
110 <form method="post" action="{{$base}}/draft" class="actions">
111 {{if .MR.Draft}}<button type="submit">Ready for review</button>
112 {{else}}<input type="hidden" name="draft" value="on"><button type="submit">Convert to draft</button>{{end}}
113 </form>
110114 </div>
111115 {{end}}
112116 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
internal/web/templates/mrs.html +1 −1
@@ -21,7 +21,7 @@
2121 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p>
2222 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p>
2323 </div>
24 <span class="chip chip-{{.State}}">{{.State}}</span>
24 {{if .Draft}}<span class="chip chip-neutral">draft</span> {{end}}<span class="chip chip-{{.State}}">{{.State}}</span>
2525</li>
2626{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
2727{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}}