A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit c813bd2840

c813bd284086f18ee672f87a24f77524ac5d3551

parent: b41ccc6005

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T20:10:03Z

Add milestones and issue templates (#20)

Milestones (migration 0013): per-repo titled milestones with optional
description and YYYY-MM-DD due date; milestone create/list/close/reopen
over SSH (write access; content-write, so archived repos refuse), issue
milestone and mr milestone attach or clear (title or 'none'), progress
counts open vs closed/merged attached items. Web: /owner/name/milestones
with progress bars, milestone links on issue/MR pages and issue rows,
milestone in issue show --json.

Issue templates: .gitbay/issue-template*.md at the default branch.
issue templates lists them; gitbay issue create prefills $EDITOR with
the default template (best-effort fetch); new GET issues/new form
(accounts mode) prefills the textarea with a template picker.
cmd/gitbay/local.go +6 −1
@@ -27,7 +27,7 @@ func hasBodyFlag(args []string) bool {
2727 // body, none was given, and we are on a terminal. The result is passed to
2828 // the server via --file - on stdin. Returns the (possibly extended) args,
2929 // the stdin to use, and ok=false if the user aborted.
30func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, error) {
30func maybeEditor(args []string, kind string, prefill func() string) ([]string, *strings.Reader, bool, error) {
3131 if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) {
3232 return args, nil, true, nil
3333 }
@@ -42,6 +42,11 @@ func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, e
4242 return nil, nil, false, err
4343 }
4444 defer os.Remove(f.Name())
45 if prefill != nil {
46 if body := prefill(); body != "" {
47 fmt.Fprintf(f, "%s\n", strings.TrimRight(body, "\n"))
48 }
49 }
4550 fmt.Fprintf(f, "\n# Write the %s body above. Lines starting with '#' are ignored.\n# Save an empty file to skip the body.\n", kind)
4651 f.Close()
4752
cmd/gitbay/main.go +22 −1
@@ -32,6 +32,7 @@ func main() {
3232 ),
3333 repoCmd(),
3434 issueCmd(),
35 milestoneCmd(),
3536 mrCmd(),
3637 webCmd(),
3738 orgCmd(),
@@ -97,7 +98,13 @@ func runPass(o passOpts, args []string) int {
9798
9899 var stdin io.Reader = strings.NewReader("")
99100 if o.editor != "" {
100 extended, body, ok, err := maybeEditor(args, o.editor)
101 // Issue bodies prefill from the repo's .gitbay/issue-template*.md.
102 var prefill func() string
103 if o.editor == "issue" && len(args) > 0 && strings.Contains(args[0], "/") {
104 repoPath := args[0]
105 prefill = func() string { return fetchIssueTemplate(t, repoPath) }
106 }
107 extended, body, ok, err := maybeEditor(args, o.editor, prefill)
101108 if err != nil {
102109 fmt.Fprintln(os.Stderr, "gitbay:", err)
103110 return protocol.ExitFailure
@@ -265,6 +272,19 @@ func issueCmd() *cobra.Command {
265272 pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
266273 pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
267274 pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
275 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
276 pass("templates", "list issue templates (.gitbay/issue-template*.md)", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
277 )
278}
279
280func milestoneCmd() *cobra.Command {
281 return group("milestone", "group issues and MRs toward a release",
282 pass("create", "create a milestone: <title> [--description <d>] [--due YYYY-MM-DD]",
283 passOpts{server: []string{"milestone", "create"}, needsRepo: true}),
284 pass("list", "list milestones with progress [--state open|closed|all]",
285 passOpts{server: []string{"milestone", "list"}, needsRepo: true}),
286 pass("close", "close a milestone: <title>", passOpts{server: []string{"milestone", "close"}, needsRepo: true}),
287 pass("reopen", "reopen a milestone: <title>", passOpts{server: []string{"milestone", "reopen"}, needsRepo: true}),
268288 )
269289 }
270290
@@ -284,6 +304,7 @@ func mrCmd() *cobra.Command {
284304 pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
285305 pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
286306 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
307 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
287308 )
288309 }
289310
cmd/gitbay/ssh.go +49
@@ -1,6 +1,7 @@
11 package main
22
33 import (
4 "encoding/json"
45 "fmt"
56 "io"
67 "os"
@@ -110,6 +111,54 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
110111 return protocol.ExitProtocol
111112 }
112113
114// sshCapture runs a server command and returns its stdout, discarding
115// stderr. Used for quiet metadata fetches like issue templates.
116func sshCapture(t target, serverArgv []string) (string, int) {
117 args := []string{}
118 if t.inst.Port != 0 && t.inst.Port != 22 {
119 args = append(args, "-p", strconv.Itoa(t.inst.Port))
120 }
121 args = append(args, t.inst.SSHOptions...)
122 quoted := make([]string, len(serverArgv))
123 for i, a := range serverArgv {
124 quoted[i] = shellQuote(a)
125 }
126 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
127 out, err := exec.Command("ssh", args...).Output()
128 if err != nil {
129 code := protocol.ExitProtocol
130 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 255 {
131 code = ee.ExitCode()
132 }
133 return "", code
134 }
135 return string(out), 0
136}
137
138// fetchIssueTemplate returns the repo's default issue template body, or ""
139// when there is none (or anything fails — prefill is best-effort).
140func fetchIssueTemplate(t target, repoPath string) string {
141 out, code := sshCapture(t, []string{"issue", "templates", repoPath, "--json"})
142 if code != 0 {
143 return ""
144 }
145 var env struct {
146 Data []struct {
147 Name string `json:"name"`
148 Body string `json:"body"`
149 } `json:"data"`
150 }
151 if json.Unmarshal([]byte(out), &env) != nil || len(env.Data) == 0 {
152 return ""
153 }
154 for _, tpl := range env.Data {
155 if tpl.Name == "issue-template.md" {
156 return tpl.Body
157 }
158 }
159 return env.Data[0].Body
160}
161
113162 // withRepo prepends the repo path to args unless the user already gave one
114163 // explicitly (a first argument containing '/'). Commands' server parsers
115164 // accept the path at any position, so the front is always safe.
docs/users.org +16
@@ -138,6 +138,7 @@ gitbay issue comment 4 --message "same here"
138138 gitbay issue close 4 / reopen 4
139139 gitbay issue label 4 --add bug --remove wontfix
140140 gitbay issue assign 4 --add alice
141gitbay issue milestone 4 v1.0 # or "none" to clear
141142 #+end_src
142143
143144 Inside a clone, the repository is inferred from the =origin= remote —
@@ -145,6 +146,21 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the
145146 first argument. Long text: =--body= inline, =--file -= from stdin, or
146147 neither on a terminal and =$EDITOR= opens.
147148
149Milestones group issues and MRs toward a release (write access to
150manage, attach with =issue milestone= / =mr milestone=; progress shows
151on the web at =/owner/name/milestones=):
152
153#+begin_src sh
154gitbay milestone create v1.0 --description "first release" --due 2027-01-01
155gitbay milestone list [--state open|closed|all]
156gitbay milestone close v1.0 / reopen v1.0
157#+end_src
158
159Issue templates: commit =.gitbay/issue-template.md= (and optional
160=issue-template-<name>.md= variants) to the default branch. =gitbay
161issue create= prefills =$EDITOR= with the default template, the web
162form prefills its textarea, and =gitbay issue templates= lists them.
163
148164 * Merge requests
149165
150166 #+begin_src sh
e2e/milestone_test.go added +146
@@ -0,0 +1,146 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestMilestonesAndTemplates(t *testing.T) {
12 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
13 aliceKey := inst.newKey(t, "alice")
14 bobKey := inst.newKey(t, "bob")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
18
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 work := t.TempDir()
23 env := inst.gitEnv(aliceKey)
24 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
25 dir := filepath.Join(work, "w")
26 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
27 os.WriteFile(filepath.Join(dir, ".gitbay", "issue-template.md"),
28 []byte("## Steps to reproduce\n\n## Expected\n"), 0o644)
29 os.WriteFile(filepath.Join(dir, ".gitbay", "issue-template-feature.md"),
30 []byte("## Motivation\n"), 0o644)
31 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
32 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
33 mustGit(t, dir, env, "add", ".")
34 mustGit(t, dir, env, "commit", "-q", "-m", "base")
35 mustGit(t, dir, env, "push", "-q", "origin", "main")
36 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
37 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
40 mustGit(t, dir, env, "push", "-q", "origin", "feat")
41 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
42 "--source", "feat", "--target", "main", "--title", "'feat'"); code != 0 {
43 t.Fatal("mr create failed")
44 }
45 for _, title := range []string{"'one'", "'two'"} {
46 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", title); code != 0 {
47 t.Fatal("issue create failed")
48 }
49 }
50
51 // Milestone lifecycle and validation.
52 if _, errOut, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v1.0",
53 "--description", "'first release'", "--due", "2027-01-01"); code != 0 {
54 t.Fatalf("milestone create: %s", errOut)
55 }
56 if _, _, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v1.0"); code != 2 {
57 t.Fatal("duplicate milestone accepted")
58 }
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v2.0", "--due", "soon"); code != 2 || !strings.Contains(errOut, "YYYY-MM-DD") {
60 t.Fatalf("bad due accepted: %s", errOut)
61 }
62 if _, _, code := inst.ssh(t, bobKey, "", "milestone", "create", "alice/app", "sneaky"); code != 4 {
63 t.Fatal("read-only user created a milestone")
64 }
65
66 // Attach: issue 1, MR 1; unknown title refused; clearing works.
67 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "milestone", "alice/app", "1", "v1.0"); code != 0 {
68 t.Fatalf("issue milestone: %s", errOut)
69 }
70 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "milestone", "alice/app", "1", "v1.0"); code != 0 {
71 t.Fatal("mr milestone failed")
72 }
73 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "milestone", "alice/app", "2", "v9.9"); code != 3 {
74 t.Fatal("unknown milestone accepted")
75 }
76 out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
77 if !strings.Contains(out, `"milestone":"v1.0"`) {
78 t.Fatalf("issue show milestone: %s", out)
79 }
80
81 // Progress: 2 open (issue 1 + MR 1); closing the issue moves it.
82 out, _, _ = inst.ssh(t, aliceKey, "", "milestone", "list", "alice/app", "--json")
83 if !strings.Contains(out, `"open":2`) || !strings.Contains(out, `"due":"2027-01-01"`) {
84 t.Fatalf("milestone list: %s", out)
85 }
86 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "close", "alice/app", "1"); code != 0 {
87 t.Fatal("issue close failed")
88 }
89 out, _, _ = inst.ssh(t, aliceKey, "", "milestone", "list", "alice/app", "--json")
90 if !strings.Contains(out, `"open":1`) || !strings.Contains(out, `"closed":1`) {
91 t.Fatalf("milestone progress: %s", out)
92 }
93
94 // Close/reopen; closed milestones leave the default list.
95 if _, _, code := inst.ssh(t, aliceKey, "", "milestone", "close", "alice/app", "v1.0"); code != 0 {
96 t.Fatal("milestone close failed")
97 }
98 out, _, _ = inst.ssh(t, aliceKey, "", "milestone", "list", "alice/app")
99 if strings.Contains(out, "v1.0") {
100 t.Fatal("closed milestone in open list")
101 }
102 if _, _, code := inst.ssh(t, aliceKey, "", "milestone", "reopen", "alice/app", "v1.0"); code != 0 {
103 t.Fatal("milestone reopen failed")
104 }
105
106 // Web: milestones page with progress; issue page links the milestone.
107 status, body := inst.get(t, "/alice/app/milestones")
108 if status != 200 || !strings.Contains(body, "v1.0") || !strings.Contains(body, "50%") ||
109 !strings.Contains(body, "first release") {
110 t.Fatalf("milestones page: %d\n%s", status, body)
111 }
112 _, body = inst.get(t, "/alice/app/issues/1")
113 if !strings.Contains(body, ">v1.0</a>") {
114 t.Fatal("issue page missing milestone link")
115 }
116
117 // Templates over SSH.
118 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "templates", "alice/app", "--json")
119 if !strings.Contains(out, `"name":"issue-template.md"`) ||
120 !strings.Contains(out, "Steps to reproduce") ||
121 !strings.Contains(out, `"name":"issue-template-feature.md"`) {
122 t.Fatalf("issue templates: %s", out)
123 }
124
125 // Templates on the web form (login required).
126 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
127 if code != 0 {
128 t.Fatalf("web login: %s", errOut)
129 }
130 var env2 struct {
131 Data struct {
132 URL string `json:"url"`
133 } `json:"data"`
134 }
135 json.Unmarshal([]byte(out), &env2)
136 browser := newBrowser(t)
137 browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):])
138 _, body = browserGet(t, browser, inst.base()+"/alice/app/issues/new")
139 if !strings.Contains(body, "Steps to reproduce") || !strings.Contains(body, "issue-template-feature.md") {
140 t.Fatalf("issue form not prefilled:\n%s", body)
141 }
142 _, body = browserGet(t, browser, inst.base()+"/alice/app/issues/new?template=issue-template-feature.md")
143 if !strings.Contains(body, "Motivation") {
144 t.Fatal("template switch failed")
145 }
146}
internal/control/issue.go +2 −1
@@ -78,6 +78,7 @@ type issueOut struct {
7878 Title string `json:"title"`
7979 State string `json:"state"`
8080 Author string `json:"author"`
81 Milestone string `json:"milestone,omitempty"`
8182 Labels []string `json:"labels,omitempty"`
8283 Assignees []string `json:"assignees,omitempty"`
8384 Body string `json:"body,omitempty"`
@@ -86,7 +87,7 @@ type issueOut struct {
8687
8788 func issueToOut(i store.Issue, withBody bool) issueOut {
8889 o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
89 Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
90 Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
9091 if withBody {
9192 o.Body = i.Body
9293 }
internal/control/milestone.go added +264
@@ -0,0 +1,264 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "path"
8 "regexp"
9 "strings"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17func init() {
18 register(Command{Path: []string{"milestone", "create"},
19 Summary: "create a milestone: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]", Run: runMilestoneCreate})
20 register(Command{Path: []string{"milestone", "list"},
21 Summary: "list milestones with progress: milestone list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runMilestoneList})
22 register(Command{Path: []string{"milestone", "close"},
23 Summary: "close a milestone: milestone close <owner/name> <title>", Run: runMilestoneClose})
24 register(Command{Path: []string{"milestone", "reopen"},
25 Summary: "reopen a milestone: milestone reopen <owner/name> <title>", Run: runMilestoneReopen})
26 register(Command{Path: []string{"issue", "milestone"},
27 Summary: "set or clear an issue's milestone: issue milestone <owner/name> <n> <title|none>", Run: runIssueMilestone})
28 register(Command{Path: []string{"mr", "milestone"},
29 Summary: "set or clear an MR's milestone: mr milestone <owner/name> <n> <title|none>", Run: runMRMilestone})
30 register(Command{Path: []string{"issue", "templates"},
31 Summary: "list issue templates (.gitbay/issue-template*.md): issue templates <owner/name>", ReadOnly: true, Run: runIssueTemplates})
32}
33
34var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
35
36func runMilestoneCreate(c *Ctx, args []string) int {
37 var path, title, description, due string
38 for i := 0; i < len(args); i++ {
39 switch args[i] {
40 case "--description", "--due":
41 if i+1 >= len(args) {
42 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
43 }
44 if args[i] == "--description" {
45 description = args[i+1]
46 } else {
47 due = args[i+1]
48 }
49 i++
50 default:
51 if path == "" {
52 path = args[i]
53 } else if title == "" {
54 title = args[i]
55 } else {
56 return c.fail(protocol.ExitUsage, "usage: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]")
57 }
58 }
59 }
60 if path == "" || title == "" {
61 return c.fail(protocol.ExitUsage, "usage: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]")
62 }
63 if due != "" && !duePat.MatchString(due) {
64 return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
65 }
66 repo, code := resolveRepo(c, path, policy.CanWrite)
67 if code >= 0 {
68 return code
69 }
70 if code := refuseArchived(c, repo); code >= 0 {
71 return code
72 }
73 if _, err := c.Store.CreateMilestone(repo.ID, title, description, due); err != nil {
74 return c.fail(protocol.ExitUsage, "%v", err)
75 }
76 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
77 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
78 })
79}
80
81func runMilestoneList(c *Ctx, args []string) int {
82 state := "open"
83 var path string
84 for i := 0; i < len(args); i++ {
85 if args[i] == "--state" {
86 if i+1 >= len(args) {
87 return c.fail(protocol.ExitUsage, "--state requires open|closed|all")
88 }
89 state = args[i+1]
90 i++
91 } else if path == "" {
92 path = args[i]
93 } else {
94 return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]")
95 }
96 }
97 if path == "" || (state != "open" && state != "closed" && state != "all") {
98 return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]")
99 }
100 repo, code := resolveRepo(c, path, policy.CanRead)
101 if code >= 0 {
102 return code
103 }
104 ms, err := c.Store.ListMilestones(repo.ID, state)
105 if err != nil {
106 return c.fail(protocol.ExitFailure, "%v", err)
107 }
108 type out struct {
109 Title string `json:"title"`
110 Description string `json:"description,omitempty"`
111 Due string `json:"due,omitempty"`
112 State string `json:"state"`
113 Open int `json:"open"`
114 Closed int `json:"closed"`
115 }
116 var ds []out
117 for _, m := range ms {
118 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OpenItems, m.ClosedItems})
119 }
120 return c.emit(ds, func(w io.Writer) {
121 for _, d := range ds {
122 due := d.Due
123 if due == "" {
124 due = "-"
125 }
126 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed\n", d.Title, d.State, due, d.Open, d.Closed)
127 }
128 })
129}
130
131func runMilestoneClose(c *Ctx, args []string) int { return setMilestoneState(c, args, "closed") }
132func runMilestoneReopen(c *Ctx, args []string) int { return setMilestoneState(c, args, "open") }
133
134func setMilestoneState(c *Ctx, args []string, state string) int {
135 verb := "close"
136 if state == "open" {
137 verb = "reopen"
138 }
139 if len(args) != 2 {
140 return c.fail(protocol.ExitUsage, "usage: milestone %s <owner/name> <title>", verb)
141 }
142 repo, code := resolveRepo(c, args[0], policy.CanWrite)
143 if code >= 0 {
144 return code
145 }
146 if code := refuseArchived(c, repo); code >= 0 {
147 return code
148 }
149 m, err := c.Store.MilestoneByTitle(repo.ID, args[1])
150 if err != nil {
151 return milestoneErr(c, repo, args[1], err)
152 }
153 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
154 return c.fail(protocol.ExitFailure, "%v", err)
155 }
156 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
157 fmt.Fprintf(w, "%sd milestone %q on %s\n", verb, m.Title, repo.Path())
158 })
159}
160
161func milestoneErr(c *Ctx, repo store.Repo, title string, err error) int {
162 if errors.Is(err, store.ErrNotFound) {
163 return c.fail(protocol.ExitNotFound, "no milestone %q in %s", title, repo.Path())
164 }
165 return c.fail(protocol.ExitFailure, "%v", err)
166}
167
168func runIssueMilestone(c *Ctx, args []string) int {
169 repo, issue, code := issueRef(c, args, policy.CanWrite)
170 if code >= 0 {
171 return code
172 }
173 if code := refuseArchived(c, repo); code >= 0 {
174 return code
175 }
176 if len(args) != 3 {
177 return c.fail(protocol.ExitUsage, "usage: issue milestone <owner/name> <n> <title|none>")
178 }
179 return setItemMilestone(c, repo, args[2], func(id int64) error {
180 return c.Store.SetIssueMilestone(issue.ID, id)
181 })
182}
183
184func runMRMilestone(c *Ctx, args []string) int {
185 repo, mr, code := mrRef(c, args, policy.CanWrite)
186 if code >= 0 {
187 return code
188 }
189 if code := refuseArchived(c, repo); code >= 0 {
190 return code
191 }
192 if len(args) != 3 {
193 return c.fail(protocol.ExitUsage, "usage: mr milestone <owner/name> <n> <title|none>")
194 }
195 return setItemMilestone(c, repo, args[2], func(id int64) error {
196 return c.Store.SetMRMilestone(mr.ID, id)
197 })
198}
199
200func setItemMilestone(c *Ctx, repo store.Repo, title string, set func(int64) error) int {
201 var id int64
202 if title != "none" {
203 m, err := c.Store.MilestoneByTitle(repo.ID, title)
204 if err != nil {
205 return milestoneErr(c, repo, title, err)
206 }
207 id = m.ID
208 }
209 if err := set(id); err != nil {
210 return c.fail(protocol.ExitFailure, "%v", err)
211 }
212 if title == "none" {
213 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
214 fmt.Fprintln(w, "milestone cleared")
215 })
216 }
217 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
218 fmt.Fprintf(w, "milestone set to %q\n", title)
219 })
220}
221
222// runIssueTemplates lists .gitbay/issue-template*.md at the default branch.
223func runIssueTemplates(c *Ctx, args []string) int {
224 if len(args) != 1 {
225 return c.fail(protocol.ExitUsage, "usage: issue templates <owner/name>")
226 }
227 repo, code := resolveRepo(c, args[0], policy.CanRead)
228 if code >= 0 {
229 return code
230 }
231 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
232 ts := IssueTemplates(dir, repo.DefaultBranch)
233 return c.emit(ts, func(w io.Writer) {
234 for _, t := range ts {
235 fmt.Fprintln(w, t.Name)
236 }
237 })
238}
239
240type IssueTemplate struct {
241 Name string `json:"name"`
242 Body string `json:"body"`
243}
244
245// IssueTemplates reads .gitbay/issue-template*.md from ref. Missing
246// directory or unreadable files yield an empty list, never an error.
247func IssueTemplates(dir, ref string) []IssueTemplate {
248 entries, err := gitutil.ListTree(dir, ref, ".gitbay")
249 if err != nil {
250 return nil
251 }
252 var out []IssueTemplate
253 for _, e := range entries {
254 if e.Type != "blob" || !strings.HasPrefix(e.Name, "issue-template") || !strings.HasSuffix(e.Name, ".md") {
255 continue
256 }
257 raw, err := gitutil.ReadBlob(dir, ref, path.Join(".gitbay", e.Name), maxBodyBytes)
258 if err != nil {
259 continue
260 }
261 out = append(out, IssueTemplate{Name: e.Name, Body: string(raw)})
262 }
263 return out
264}
internal/httpd/accounts.go +34
@@ -164,6 +164,40 @@ func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.Use
164164 return repo, true
165165 }
166166
167// issueCreateForm renders the new-issue form, prefilled from the repo's
168// default issue template when one exists.
169func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
170 p, ok := s.repoFor(w, r, "")
171 if !ok {
172 return
173 }
174 p.Tab = "issues"
175 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
176 body, tplName := "", ""
177 if want := r.URL.Query().Get("template"); want != "" {
178 for _, t := range templates {
179 if t.Name == want {
180 body, tplName = t.Body, t.Name
181 }
182 }
183 } else {
184 for _, t := range templates {
185 if t.Name == "issue-template.md" || body == "" {
186 body, tplName = t.Body, t.Name
187 }
188 if t.Name == "issue-template.md" {
189 break
190 }
191 }
192 }
193 s.render(w, "issuenew.html", struct {
194 repoPage
195 Body string
196 Template string
197 Templates []control.IssueTemplate
198 }{p, body, tplName, templates})
199}
200
167201 func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
168202 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
169203 if !ok {
internal/httpd/routes.go +3
@@ -36,6 +36,7 @@ func (s *Server) Routes() []Route {
3636 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
3737 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
3838 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
39 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones},
3940 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
4041 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
4142 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
@@ -65,6 +66,8 @@ func (s *Server) Routes() []Route {
6566 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
6667 Route{Method: "POST", Pattern: "/new", Mutating: true,
6768 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
69 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new",
70 Handler: s.requireUser(s.issueCreateForm)},
6871 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true,
6972 Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))},
7073 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true,
internal/httpd/web.go +35
@@ -383,6 +383,41 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
383383 }{p, cs, base, filePath, binary, len(data), codeHTML})
384384 }
385385
386// milestones lists a repo's milestones with progress.
387func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
388 p, ok := s.repoFor(w, r, "")
389 if !ok {
390 return
391 }
392 p.Tab = "issues"
393 state := r.URL.Query().Get("state")
394 if state != "closed" && state != "all" {
395 state = "open"
396 }
397 ms, err := s.st.ListMilestones(p.Repo.ID, state)
398 if err != nil {
399 http.Error(w, "internal error", http.StatusInternalServerError)
400 return
401 }
402 type msView struct {
403 store.Milestone
404 Percent int
405 }
406 var views []msView
407 for _, m := range ms {
408 v := msView{Milestone: m}
409 if total := m.OpenItems + m.ClosedItems; total > 0 {
410 v.Percent = m.ClosedItems * 100 / total
411 }
412 views = append(views, v)
413 }
414 s.render(w, "milestones.html", struct {
415 repoPage
416 State string
417 Milestones []msView
418 }{p, state, views})
419}
420
386421 // search runs a bounded literal git grep over the repo's default branch.
387422 func (s *Server) search(w http.ResponseWriter, r *http.Request) {
388423 p, ok := s.repoFor(w, r, "")
internal/store/issues.go +11 −5
@@ -14,6 +14,7 @@ type Issue struct {
1414 Title string
1515 Body string
1616 State string // open | closed
17 Milestone string
1718 CreatedAt string
1819 UpdatedAt string
1920 Labels []string
@@ -52,10 +53,12 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64,
5253 func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
5354 var i Issue
5455 err := s.DB.QueryRow(`
55 SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at
56 SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
57 COALESCE(m.title, ''), i.created_at, i.updated_at
5658 FROM issues i JOIN users u ON u.id = i.author_id
59 LEFT JOIN milestones m ON m.id = i.milestone_id
5760 WHERE i.repo_id = ? AND i.number = ?`, repoID, number).
58 Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt)
61 Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt)
5962 if errors.Is(err, sql.ErrNoRows) {
6063 return i, ErrNotFound
6164 }
@@ -92,8 +95,11 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
9295
9396 // ListIssues returns issues for a repo; state is "open", "closed", or "all".
9497 func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) {
95 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at
96 FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ?`
98 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
99 COALESCE(m.title, ''), i.created_at, i.updated_at
100 FROM issues i JOIN users u ON u.id = i.author_id
101 LEFT JOIN milestones m ON m.id = i.milestone_id
102 WHERE i.repo_id = ?`
97103 args := []any{repoID}
98104 if state != "all" {
99105 q += " AND i.state = ?"
@@ -108,7 +114,7 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) {
108114 var out []Issue
109115 for rows.Next() {
110116 var i Issue
111 if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt); err != nil {
117 if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt); err != nil {
112118 return nil, err
113119 }
114120 out = append(out, i)
internal/store/migrations/0013_milestones.down.sql added +3
@@ -0,0 +1,3 @@
1ALTER TABLE issues DROP COLUMN milestone_id;
2ALTER TABLE merge_requests DROP COLUMN milestone_id;
3DROP TABLE milestones;
internal/store/migrations/0013_milestones.up.sql added +12
@@ -0,0 +1,12 @@
1CREATE TABLE milestones (
2 id INTEGER PRIMARY KEY,
3 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
4 title TEXT NOT NULL,
5 description TEXT NOT NULL DEFAULT '',
6 due_date TEXT NOT NULL DEFAULT '',
7 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
8 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 UNIQUE (repo_id, title)
10);
11ALTER TABLE issues ADD COLUMN milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL;
12ALTER TABLE merge_requests ADD COLUMN milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL;
internal/store/milestones.go added +115
@@ -0,0 +1,115 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9type Milestone struct {
10 ID int64
11 RepoID int64
12 Title string
13 Description string
14 DueDate string
15 State string // open | closed
16 CreatedAt string
17 OpenItems int // open issues + open MRs attached
18 ClosedItems int // closed issues + merged/closed MRs attached
19}
20
21func (s *Store) CreateMilestone(repoID int64, title, description, due string) (int64, error) {
22 res, err := s.DB.Exec(
23 "INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)",
24 repoID, title, description, due)
25 if err != nil {
26 if isUniqueErr(err) {
27 return 0, fmt.Errorf("milestone %q already exists", title)
28 }
29 return 0, err
30 }
31 return res.LastInsertId()
32}
33
34const milestoneSelect = `
35 SELECT m.id, m.repo_id, m.title, m.description, m.due_date, m.state, m.created_at,
36 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open')
37 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('open','source_gone')),
38 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed')
39 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed'))
40 FROM milestones m`
41
42func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) {
43 var m Milestone
44 err := row.Scan(&m.ID, &m.RepoID, &m.Title, &m.Description, &m.DueDate, &m.State,
45 &m.CreatedAt, &m.OpenItems, &m.ClosedItems)
46 return m, err
47}
48
49func (s *Store) MilestoneByTitle(repoID int64, title string) (Milestone, error) {
50 m, err := scanMilestone(s.DB.QueryRow(
51 milestoneSelect+" WHERE m.repo_id = ? AND m.title = ?", repoID, title))
52 if errors.Is(err, sql.ErrNoRows) {
53 return m, ErrNotFound
54 }
55 return m, err
56}
57
58func (s *Store) ListMilestones(repoID int64, state string) ([]Milestone, error) {
59 q := milestoneSelect + " WHERE m.repo_id = ?"
60 args := []any{repoID}
61 if state != "all" {
62 q += " AND m.state = ?"
63 args = append(args, state)
64 }
65 q += " ORDER BY m.due_date = '', m.due_date, m.title"
66 rows, err := s.DB.Query(q, args...)
67 if err != nil {
68 return nil, err
69 }
70 defer rows.Close()
71 var out []Milestone
72 for rows.Next() {
73 m, err := scanMilestone(rows)
74 if err != nil {
75 return nil, err
76 }
77 out = append(out, m)
78 }
79 return out, rows.Err()
80}
81
82func (s *Store) SetMilestoneState(id int64, state string) error {
83 res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id)
84 if err != nil {
85 return err
86 }
87 if n, _ := res.RowsAffected(); n == 0 {
88 return ErrNotFound
89 }
90 return nil
91}
92
93// SetIssueMilestone attaches (or with milestoneID 0 clears) a milestone.
94func (s *Store) SetIssueMilestone(issueID, milestoneID int64) error {
95 return s.setItemMilestone("issues", issueID, milestoneID)
96}
97
98func (s *Store) SetMRMilestone(mrID, milestoneID int64) error {
99 return s.setItemMilestone("merge_requests", mrID, milestoneID)
100}
101
102func (s *Store) setItemMilestone(table string, id, milestoneID int64) error {
103 var v any
104 if milestoneID != 0 {
105 v = milestoneID
106 }
107 res, err := s.DB.Exec("UPDATE "+table+" SET milestone_id = ? WHERE id = ?", v, id)
108 if err != nil {
109 return err
110 }
111 if n, _ := res.RowsAffected(); n == 0 {
112 return ErrNotFound
113 }
114 return nil
115}
internal/store/mrs.go +6 −3
@@ -17,6 +17,7 @@ type MR struct {
1717 Title string
1818 Body string
1919 State string // open | merged | closed | source_gone
20 Milestone string
2021 HeadSHA string
2122 MergedBase string // target tip at merge time; base for historical diffs
2223 CreatedAt string
@@ -57,18 +58,20 @@ const mrSelect = `
5758 SELECT m.id, m.repo_id, m.number, u.username,
5859 COALESCE(m.source_repo_id, 0),
5960 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
60 m.source_ref, m.target_ref, m.title, m.body, m.state, m.head_sha,
61 m.source_ref, m.target_ref, m.title, m.body, m.state,
62 COALESCE(ms.title, ''), m.head_sha,
6163 m.merged_base, m.created_at, m.updated_at
6264 FROM merge_requests m
6365 JOIN users u ON u.id = m.author_id
6466 LEFT JOIN repos sr ON sr.id = m.source_repo_id
6567 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
66 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id`
68 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id
69 LEFT JOIN milestones ms ON ms.id = m.milestone_id`
6770
6871 func scanMR(row interface{ Scan(...any) error }) (MR, error) {
6972 var m MR
7073 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
71 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
74 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
7275 return m, err
7376 }
7477
internal/web/static/style.css +25
@@ -514,6 +514,31 @@ ul.issuelist p { margin: 0; }
514514 ul.issuelist .title a { color: var(--fg); font-weight: 550; }
515515 ul.issuelist .title a:hover { color: var(--accent); }
516516
517/* milestones */
518ul.milestonelist {
519 list-style: none;
520 margin: var(--sp-2) 0;
521 padding: 0;
522 border: 1px solid var(--line);
523 border-radius: var(--r-lg);
524 overflow: hidden;
525}
526ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
527ul.milestonelist li:last-child { border-bottom: none; }
528ul.milestonelist li.empty { color: var(--muted); }
529ul.milestonelist p { margin: 0; }
530ul.milestonelist .title { font-weight: 550; }
531.progress {
532 height: 6px;
533 background: var(--faint);
534 border-radius: var(--r-pill);
535 margin-top: var(--sp-2);
536 overflow: hidden;
537 max-width: 24rem;
538}
539.progress .bar { height: 100%; background: var(--ok); }
540.listhead .spacer { flex: 1; }
541
517542 /* issue / MR detail */
518543 h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); }
519544 .issuenumber { color: var(--muted); font-weight: 400; }
internal/web/templates/issue.html +2 −1
@@ -5,7 +5,8 @@
55 <p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span>
66 {{.Issue.Author}} opened this on {{when .Issue.CreatedAt}}
77 {{if .Issue.Labels}} · {{range .Issue.Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}{{end}}
8{{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p>
8{{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}
9{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
910 {{if .BodyHTML}}<article class="comment">
1011 <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
1112 <div class="rendered">{{.BodyHTML}}</div>
internal/web/templates/issuenew.html added +12
@@ -0,0 +1,12 @@
1{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<h2>new issue</h2>
5{{if gt (len .Templates) 1}}<p class="meta">template:
6{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
8<p><input type="text" name="title" placeholder="title" required></p>
9<p><textarea name="body" rows="12">{{.Body}}</textarea></p>
10<p><button type="submit">open issue</button></p>
11</form>
12{{end}}
internal/web/templates/issues.html +3 −1
@@ -8,13 +8,15 @@
88 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
99 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
1010 </nav>
11 <span class="spacer"></span>
12 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
1113 </div>
1214 <ul class="issuelist">
1315 {{range .Issues}}<li>
1416 <div class="issuemain">
1517 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a>
1618 {{range .Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}</p>
17 <p class="meta">#{{.Number}} opened by {{.Author}}</p>
19 <p class="meta">#{{.Number}} opened by {{.Author}}{{if .Milestone}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones">{{.Milestone}}</a>{{end}}</p>
1820 </div>
1921 <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>
2022 </li>
internal/web/templates/milestones.html added +23
@@ -0,0 +1,23 @@
1{{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead">
5 <h2>milestones</h2>
6 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
9 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
10 </nav>
11</div>
12<ul class="milestonelist">
13{{range .Milestones}}<li>
14 <div class="msmain">
15 <p class="title">{{.Title}} <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span></p>
16 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
17 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
18 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
19 </div>
20</li>
21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}milestones — create one with <code>gitbay milestone create {{.Repo.OwnerName}}/{{.Repo.Name}} "v1.0"</code></li>{{end}}
22</ul>
23{{end}}
internal/web/templates/mr.html +1 −1
@@ -4,7 +4,7 @@
44 <h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2>
55 <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
66 {{.MR.Author}} wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}}
7 at <code>{{short .MR.HeadSHA}}</code></p>
7 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p>
88 {{if .BodyHTML}}<article class="comment">
99 <header class="commenthead"><strong>{{.MR.Author}}</strong></header>
1010 <div class="rendered">{{.BodyHTML}}</div>