Commit c813bd2840
Verified · cmc
Layout: unified · split
cmd/gitbay/local.go +6 −1
| @@ -27,7 +27,7 @@ func hasBodyFlag(args []string) bool { | |||
| 27 | // body, none was given, and we are on a terminal. The result is passed to | 27 | // body, none was given, and we are on a terminal. The result is passed to |
| 28 | // the server via --file - on stdin. Returns the (possibly extended) args, | 28 | // the server via --file - on stdin. Returns the (possibly extended) args, |
| 29 | // the stdin to use, and ok=false if the user aborted. | 29 | // the stdin to use, and ok=false if the user aborted. |
| 30 | func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, error) { | 30 | func maybeEditor(args []string, kind string, prefill func() string) ([]string, *strings.Reader, bool, error) { |
| 31 | if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) { | 31 | if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) { |
| 32 | return args, nil, true, nil | 32 | return args, nil, true, nil |
| 33 | } | 33 | } |
| @@ -42,6 +42,11 @@ func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, e | |||
| 42 | return nil, nil, false, err | 42 | return nil, nil, false, err |
| 43 | } | 43 | } |
| 44 | defer os.Remove(f.Name()) | 44 | 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 | } | ||
| 45 | 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) | 50 | 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) |
| 46 | f.Close() | 51 | f.Close() |
| 47 | 52 | ||
cmd/gitbay/main.go +22 −1
| @@ -32,6 +32,7 @@ func main() { | |||
| 32 | ), | 32 | ), |
| 33 | repoCmd(), | 33 | repoCmd(), |
| 34 | issueCmd(), | 34 | issueCmd(), |
| 35 | milestoneCmd(), | ||
| 35 | mrCmd(), | 36 | mrCmd(), |
| 36 | webCmd(), | 37 | webCmd(), |
| 37 | orgCmd(), | 38 | orgCmd(), |
| @@ -97,7 +98,13 @@ func runPass(o passOpts, args []string) int { | |||
| 97 | 98 | ||
| 98 | var stdin io.Reader = strings.NewReader("") | 99 | var stdin io.Reader = strings.NewReader("") |
| 99 | if o.editor != "" { | 100 | 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) | ||
| 101 | if err != nil { | 108 | if err != nil { |
| 102 | fmt.Fprintln(os.Stderr, "gitbay:", err) | 109 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| 103 | return protocol.ExitFailure | 110 | return protocol.ExitFailure |
| @@ -265,6 +272,19 @@ func issueCmd() *cobra.Command { | |||
| 265 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), | 272 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), |
| 266 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), | 273 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), |
| 267 | pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), | 274 | 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 | |||
| 280 | func 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}), | ||
| 268 | ) | 288 | ) |
| 269 | } | 289 | } |
| 270 | 290 | ||
| @@ -284,6 +304,7 @@ func mrCmd() *cobra.Command { | |||
| 284 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), | 304 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), |
| 285 | pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), | 305 | pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
| 286 | pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}), | 306 | 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}), | ||
| 287 | ) | 308 | ) |
| 288 | } | 309 | } |
| 289 | 310 | ||
cmd/gitbay/ssh.go +49
| @@ -1,6 +1,7 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | ||
| 4 | "fmt" | 5 | "fmt" |
| 5 | "io" | 6 | "io" |
| 6 | "os" | 7 | "os" |
| @@ -110,6 +111,54 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int { | |||
| 110 | return protocol.ExitProtocol | 111 | return protocol.ExitProtocol |
| 111 | } | 112 | } |
| 112 | 113 | ||
| 114 | // sshCapture runs a server command and returns its stdout, discarding | ||
| 115 | // stderr. Used for quiet metadata fetches like issue templates. | ||
| 116 | func 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). | ||
| 140 | func 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 | |||
| 113 | // withRepo prepends the repo path to args unless the user already gave one | 162 | // withRepo prepends the repo path to args unless the user already gave one |
| 114 | // explicitly (a first argument containing '/'). Commands' server parsers | 163 | // explicitly (a first argument containing '/'). Commands' server parsers |
| 115 | // accept the path at any position, so the front is always safe. | 164 | // 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" | |||
| 138 | gitbay issue close 4 / reopen 4 | 138 | gitbay issue close 4 / reopen 4 |
| 139 | gitbay issue label 4 --add bug --remove wontfix | 139 | gitbay issue label 4 --add bug --remove wontfix |
| 140 | gitbay issue assign 4 --add alice | 140 | gitbay issue assign 4 --add alice |
| 141 | gitbay issue milestone 4 v1.0 # or "none" to clear | ||
| 141 | #+end_src | 142 | #+end_src |
| 142 | 143 | ||
| 143 | Inside a clone, the repository is inferred from the =origin= remote — | 144 | 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 | |||
| 145 | first argument. Long text: =--body= inline, =--file -= from stdin, or | 146 | first argument. Long text: =--body= inline, =--file -= from stdin, or |
| 146 | neither on a terminal and =$EDITOR= opens. | 147 | neither on a terminal and =$EDITOR= opens. |
| 147 | 148 | ||
| 149 | Milestones group issues and MRs toward a release (write access to | ||
| 150 | manage, attach with =issue milestone= / =mr milestone=; progress shows | ||
| 151 | on the web at =/owner/name/milestones=): | ||
| 152 | |||
| 153 | #+begin_src sh | ||
| 154 | gitbay milestone create v1.0 --description "first release" --due 2027-01-01 | ||
| 155 | gitbay milestone list [--state open|closed|all] | ||
| 156 | gitbay milestone close v1.0 / reopen v1.0 | ||
| 157 | #+end_src | ||
| 158 | |||
| 159 | Issue templates: commit =.gitbay/issue-template.md= (and optional | ||
| 160 | =issue-template-<name>.md= variants) to the default branch. =gitbay | ||
| 161 | issue create= prefills =$EDITOR= with the default template, the web | ||
| 162 | form prefills its textarea, and =gitbay issue templates= lists them. | ||
| 163 | |||
| 148 | * Merge requests | 164 | * Merge requests |
| 149 | 165 | ||
| 150 | #+begin_src sh | 166 | #+begin_src sh |
e2e/milestone_test.go added +146
| @@ -0,0 +1,146 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func 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 { | |||
| 78 | Title string `json:"title"` | 78 | Title string `json:"title"` |
| 79 | State string `json:"state"` | 79 | State string `json:"state"` |
| 80 | Author string `json:"author"` | 80 | Author string `json:"author"` |
| 81 | Milestone string `json:"milestone,omitempty"` | ||
| 81 | Labels []string `json:"labels,omitempty"` | 82 | Labels []string `json:"labels,omitempty"` |
| 82 | Assignees []string `json:"assignees,omitempty"` | 83 | Assignees []string `json:"assignees,omitempty"` |
| 83 | Body string `json:"body,omitempty"` | 84 | Body string `json:"body,omitempty"` |
| @@ -86,7 +87,7 @@ type issueOut struct { | |||
| 86 | 87 | ||
| 87 | func issueToOut(i store.Issue, withBody bool) issueOut { | 88 | func issueToOut(i store.Issue, withBody bool) issueOut { |
| 88 | o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author, | 89 | 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} |
| 90 | if withBody { | 91 | if withBody { |
| 91 | o.Body = i.Body | 92 | o.Body = i.Body |
| 92 | } | 93 | } |
internal/control/milestone.go added +264
| @@ -0,0 +1,264 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 17 | func 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 | |||
| 34 | var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) | ||
| 35 | |||
| 36 | func 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 | |||
| 81 | func 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 | |||
| 131 | func runMilestoneClose(c *Ctx, args []string) int { return setMilestoneState(c, args, "closed") } | ||
| 132 | func runMilestoneReopen(c *Ctx, args []string) int { return setMilestoneState(c, args, "open") } | ||
| 133 | |||
| 134 | func 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 | |||
| 161 | func 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 | |||
| 168 | func 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 | |||
| 184 | func 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 | |||
| 200 | func 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. | ||
| 223 | func 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 | |||
| 240 | type 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. | ||
| 247 | func 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 | |||
| 164 | return repo, true | 164 | return repo, true |
| 165 | } | 165 | } |
| 166 | 166 | ||
| 167 | // issueCreateForm renders the new-issue form, prefilled from the repo's | ||
| 168 | // default issue template when one exists. | ||
| 169 | func (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 | |||
| 167 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 201 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 168 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | 202 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 169 | if !ok { | 203 | if !ok { |
internal/httpd/routes.go +3
| @@ -36,6 +36,7 @@ func (s *Server) Routes() []Route { | |||
| 36 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, | 36 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, |
| 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, | 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, |
| 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, | 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, |
| 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, | ||
| 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, | 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
| 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, | 41 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 41 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, | 42 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, |
| @@ -65,6 +66,8 @@ func (s *Server) Routes() []Route { | |||
| 65 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, | 66 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 66 | Route{Method: "POST", Pattern: "/new", Mutating: true, | 67 | Route{Method: "POST", Pattern: "/new", Mutating: true, |
| 67 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, | 68 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, |
| 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", | ||
| 70 | Handler: s.requireUser(s.issueCreateForm)}, | ||
| 68 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, | 71 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, |
| 69 | Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, | 72 | Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, |
| 70 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true, | 73 | 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) { | |||
| 383 | }{p, cs, base, filePath, binary, len(data), codeHTML}) | 383 | }{p, cs, base, filePath, binary, len(data), codeHTML}) |
| 384 | } | 384 | } |
| 385 | 385 | ||
| 386 | // milestones lists a repo's milestones with progress. | ||
| 387 | func (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 | |||
| 386 | // search runs a bounded literal git grep over the repo's default branch. | 421 | // search runs a bounded literal git grep over the repo's default branch. |
| 387 | func (s *Server) search(w http.ResponseWriter, r *http.Request) { | 422 | func (s *Server) search(w http.ResponseWriter, r *http.Request) { |
| 388 | p, ok := s.repoFor(w, r, "") | 423 | p, ok := s.repoFor(w, r, "") |
internal/store/issues.go +11 −5
| @@ -14,6 +14,7 @@ type Issue struct { | |||
| 14 | Title string | 14 | Title string |
| 15 | Body string | 15 | Body string |
| 16 | State string // open | closed | 16 | State string // open | closed |
| 17 | Milestone string | ||
| 17 | CreatedAt string | 18 | CreatedAt string |
| 18 | UpdatedAt string | 19 | UpdatedAt string |
| 19 | Labels []string | 20 | Labels []string |
| @@ -52,10 +53,12 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, | |||
| 52 | func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { | 53 | func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { |
| 53 | var i Issue | 54 | var i Issue |
| 54 | err := s.DB.QueryRow(` | 55 | 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 | ||
| 56 | FROM issues i JOIN users u ON u.id = i.author_id | 58 | FROM issues i JOIN users u ON u.id = i.author_id |
| 59 | LEFT JOIN milestones m ON m.id = i.milestone_id | ||
| 57 | WHERE i.repo_id = ? AND i.number = ?`, repoID, number). | 60 | 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) |
| 59 | if errors.Is(err, sql.ErrNoRows) { | 62 | if errors.Is(err, sql.ErrNoRows) { |
| 60 | return i, ErrNotFound | 63 | return i, ErrNotFound |
| 61 | } | 64 | } |
| @@ -92,8 +95,11 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) { | |||
| 92 | 95 | ||
| 93 | // ListIssues returns issues for a repo; state is "open", "closed", or "all". | 96 | // ListIssues returns issues for a repo; state is "open", "closed", or "all". |
| 94 | func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | 97 | 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 | 98 | q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, |
| 96 | FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ?` | 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 = ?` | ||
| 97 | args := []any{repoID} | 103 | args := []any{repoID} |
| 98 | if state != "all" { | 104 | if state != "all" { |
| 99 | q += " AND i.state = ?" | 105 | q += " AND i.state = ?" |
| @@ -108,7 +114,7 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | |||
| 108 | var out []Issue | 114 | var out []Issue |
| 109 | for rows.Next() { | 115 | for rows.Next() { |
| 110 | var i Issue | 116 | 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 { |
| 112 | return nil, err | 118 | return nil, err |
| 113 | } | 119 | } |
| 114 | out = append(out, i) | 120 | out = append(out, i) |
internal/store/migrations/0013_milestones.down.sql added +3
| @@ -0,0 +1,3 @@ | |||
| 1 | ALTER TABLE issues DROP COLUMN milestone_id; | ||
| 2 | ALTER TABLE merge_requests DROP COLUMN milestone_id; | ||
| 3 | DROP TABLE milestones; | ||
internal/store/migrations/0013_milestones.up.sql added +12
| @@ -0,0 +1,12 @@ | |||
| 1 | CREATE 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 | ); | ||
| 11 | ALTER TABLE issues ADD COLUMN milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL; | ||
| 12 | ALTER 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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | ) | ||
| 8 | |||
| 9 | type 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 | |||
| 21 | func (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 | |||
| 34 | const 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 | |||
| 42 | func 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 | |||
| 49 | func (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 | |||
| 58 | func (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 | |||
| 82 | func (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. | ||
| 94 | func (s *Store) SetIssueMilestone(issueID, milestoneID int64) error { | ||
| 95 | return s.setItemMilestone("issues", issueID, milestoneID) | ||
| 96 | } | ||
| 97 | |||
| 98 | func (s *Store) SetMRMilestone(mrID, milestoneID int64) error { | ||
| 99 | return s.setItemMilestone("merge_requests", mrID, milestoneID) | ||
| 100 | } | ||
| 101 | |||
| 102 | func (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 { | |||
| 17 | Title string | 17 | Title string |
| 18 | Body string | 18 | Body string |
| 19 | State string // open | merged | closed | source_gone | 19 | State string // open | merged | closed | source_gone |
| 20 | Milestone string | ||
| 20 | HeadSHA string | 21 | HeadSHA string |
| 21 | MergedBase string // target tip at merge time; base for historical diffs | 22 | MergedBase string // target tip at merge time; base for historical diffs |
| 22 | CreatedAt string | 23 | CreatedAt string |
| @@ -57,18 +58,20 @@ const mrSelect = ` | |||
| 57 | SELECT m.id, m.repo_id, m.number, u.username, | 58 | SELECT m.id, m.repo_id, m.number, u.username, |
| 58 | COALESCE(m.source_repo_id, 0), | 59 | COALESCE(m.source_repo_id, 0), |
| 59 | COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''), | 60 | 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, | ||
| 61 | m.merged_base, m.created_at, m.updated_at | 63 | m.merged_base, m.created_at, m.updated_at |
| 62 | FROM merge_requests m | 64 | FROM merge_requests m |
| 63 | JOIN users u ON u.id = m.author_id | 65 | JOIN users u ON u.id = m.author_id |
| 64 | LEFT JOIN repos sr ON sr.id = m.source_repo_id | 66 | LEFT JOIN repos sr ON sr.id = m.source_repo_id |
| 65 | LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id | 67 | 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` | ||
| 67 | 70 | ||
| 68 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { | 71 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { |
| 69 | var m MR | 72 | var m MR |
| 70 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, | 73 | 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) |
| 72 | return m, err | 75 | return m, err |
| 73 | } | 76 | } |
| 74 | 77 | ||
internal/web/static/style.css +25
| @@ -514,6 +514,31 @@ ul.issuelist p { margin: 0; } | |||
| 514 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } | 514 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } |
| 515 | ul.issuelist .title a:hover { color: var(--accent); } | 515 | ul.issuelist .title a:hover { color: var(--accent); } |
| 516 | 516 | ||
| 517 | /* milestones */ | ||
| 518 | ul.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 | } | ||
| 526 | ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } | ||
| 527 | ul.milestonelist li:last-child { border-bottom: none; } | ||
| 528 | ul.milestonelist li.empty { color: var(--muted); } | ||
| 529 | ul.milestonelist p { margin: 0; } | ||
| 530 | ul.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 | |||
| 517 | /* issue / MR detail */ | 542 | /* issue / MR detail */ |
| 518 | h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); } | 543 | h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); } |
| 519 | .issuenumber { color: var(--muted); font-weight: 400; } | 544 | .issuenumber { color: var(--muted); font-weight: 400; } |
internal/web/templates/issue.html +2 −1
| @@ -5,7 +5,8 @@ | |||
| 5 | <p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> | 5 | <p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> |
| 6 | {{.Issue.Author}} opened this on {{when .Issue.CreatedAt}} | 6 | {{.Issue.Author}} opened this on {{when .Issue.CreatedAt}} |
| 7 | {{if .Issue.Labels}} · {{range .Issue.Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}{{end}} | 7 | {{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> | ||
| 9 | {{if .BodyHTML}}<article class="comment"> | 10 | {{if .BodyHTML}}<article class="comment"> |
| 10 | <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> | 11 | <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> |
| 11 | <div class="rendered">{{.BodyHTML}}</div> | 12 | <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 @@ | |||
| 8 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</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> | 9 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> |
| 10 | </nav> | 10 | </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> | ||
| 11 | </div> | 13 | </div> |
| 12 | <ul class="issuelist"> | 14 | <ul class="issuelist"> |
| 13 | {{range .Issues}}<li> | 15 | {{range .Issues}}<li> |
| 14 | <div class="issuemain"> | 16 | <div class="issuemain"> |
| 15 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> | 17 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> |
| 16 | {{range .Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}</p> | 18 | {{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> |
| 18 | </div> | 20 | </div> |
| 19 | <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span> | 21 | <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span> |
| 20 | </li> | 22 | </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 @@ | |||
| 4 | <h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2> | 4 | <h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2> |
| 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | {{.MR.Author}} wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} | 6 | {{.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> |
| 8 | {{if .BodyHTML}}<article class="comment"> | 8 | {{if .BodyHTML}}<article class="comment"> |
| 9 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> | 9 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> |
| 10 | <div class="rendered">{{.BodyHTML}}</div> | 10 | <div class="rendered">{{.BodyHTML}}</div> |