Commit c813bd2840
Verified · cmc
cmd/gitbay/local.go +6 −1
| @@ -27,7 +27,7 @@ func hasBodyFlag(args []string) bool { | ||
| 27 | 27 | // body, none was given, and we are on a terminal. The result is passed to |
| 28 | 28 | // the server via --file - on stdin. Returns the (possibly extended) args, |
| 29 | 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 | 31 | if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) { |
| 32 | 32 | return args, nil, true, nil |
| 33 | 33 | } |
| @@ -42,6 +42,11 @@ func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, e | ||
| 42 | 42 | return nil, nil, false, err |
| 43 | 43 | } |
| 44 | 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 | 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 | 51 | f.Close() |
| 47 | 52 | |
cmd/gitbay/main.go +22 −1
| @@ -32,6 +32,7 @@ func main() { | ||
| 32 | 32 | ), |
| 33 | 33 | repoCmd(), |
| 34 | 34 | issueCmd(), |
| 35 | milestoneCmd(), | |
| 35 | 36 | mrCmd(), |
| 36 | 37 | webCmd(), |
| 37 | 38 | orgCmd(), |
| @@ -97,7 +98,13 @@ func runPass(o passOpts, args []string) int { | ||
| 97 | 98 | |
| 98 | 99 | var stdin io.Reader = strings.NewReader("") |
| 99 | 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 | 108 | if err != nil { |
| 102 | 109 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| 103 | 110 | return protocol.ExitFailure |
| @@ -265,6 +272,19 @@ func issueCmd() *cobra.Command { | ||
| 265 | 272 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), |
| 266 | 273 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), |
| 267 | 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 | 304 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), |
| 285 | 305 | pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
| 286 | 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 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "encoding/json" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "io" |
| 6 | 7 | "os" |
| @@ -110,6 +111,54 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int { | ||
| 110 | 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 | 162 | // withRepo prepends the repo path to args unless the user already gave one |
| 114 | 163 | // explicitly (a first argument containing '/'). Commands' server parsers |
| 115 | 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 | 138 | gitbay issue close 4 / reopen 4 |
| 139 | 139 | gitbay issue label 4 --add bug --remove wontfix |
| 140 | 140 | gitbay issue assign 4 --add alice |
| 141 | gitbay issue milestone 4 v1.0 # or "none" to clear | |
| 141 | 142 | #+end_src |
| 142 | 143 | |
| 143 | 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 | 146 | first argument. Long text: =--body= inline, =--file -= from stdin, or |
| 146 | 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 | 164 | * Merge requests |
| 149 | 165 | |
| 150 | 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 | 78 | Title string `json:"title"` |
| 79 | 79 | State string `json:"state"` |
| 80 | 80 | Author string `json:"author"` |
| 81 | Milestone string `json:"milestone,omitempty"` | |
| 81 | 82 | Labels []string `json:"labels,omitempty"` |
| 82 | 83 | Assignees []string `json:"assignees,omitempty"` |
| 83 | 84 | Body string `json:"body,omitempty"` |
| @@ -86,7 +87,7 @@ type issueOut struct { | ||
| 86 | 87 | |
| 87 | 88 | func issueToOut(i store.Issue, withBody bool) issueOut { |
| 88 | 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 | 91 | if withBody { |
| 91 | 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 | 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 | 201 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 168 | 202 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 169 | 203 | if !ok { |
internal/httpd/routes.go +3
| @@ -36,6 +36,7 @@ func (s *Server) Routes() []Route { | ||
| 36 | 36 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, |
| 37 | 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, |
| 38 | 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, |
| 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, | |
| 39 | 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
| 40 | 41 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 41 | 42 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, |
| @@ -65,6 +66,8 @@ func (s *Server) Routes() []Route { | ||
| 65 | 66 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 66 | 67 | Route{Method: "POST", Pattern: "/new", Mutating: true, |
| 67 | 68 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, |
| 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", | |
| 70 | Handler: s.requireUser(s.issueCreateForm)}, | |
| 68 | 71 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, |
| 69 | 72 | Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, |
| 70 | 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 | 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 | 421 | // search runs a bounded literal git grep over the repo's default branch. |
| 387 | 422 | func (s *Server) search(w http.ResponseWriter, r *http.Request) { |
| 388 | 423 | p, ok := s.repoFor(w, r, "") |
internal/store/issues.go +11 −5
| @@ -14,6 +14,7 @@ type Issue struct { | ||
| 14 | 14 | Title string |
| 15 | 15 | Body string |
| 16 | 16 | State string // open | closed |
| 17 | Milestone string | |
| 17 | 18 | CreatedAt string |
| 18 | 19 | UpdatedAt string |
| 19 | 20 | Labels []string |
| @@ -52,10 +53,12 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, | ||
| 52 | 53 | func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { |
| 53 | 54 | var i Issue |
| 54 | 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 | 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 | 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 | 62 | if errors.Is(err, sql.ErrNoRows) { |
| 60 | 63 | return i, ErrNotFound |
| 61 | 64 | } |
| @@ -92,8 +95,11 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) { | ||
| 92 | 95 | |
| 93 | 96 | // ListIssues returns issues for a repo; state is "open", "closed", or "all". |
| 94 | 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 | |
| 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 = ?` | |
| 97 | 103 | args := []any{repoID} |
| 98 | 104 | if state != "all" { |
| 99 | 105 | q += " AND i.state = ?" |
| @@ -108,7 +114,7 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | ||
| 108 | 114 | var out []Issue |
| 109 | 115 | for rows.Next() { |
| 110 | 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 | 118 | return nil, err |
| 113 | 119 | } |
| 114 | 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 | 17 | Title string |
| 18 | 18 | Body string |
| 19 | 19 | State string // open | merged | closed | source_gone |
| 20 | Milestone string | |
| 20 | 21 | HeadSHA string |
| 21 | 22 | MergedBase string // target tip at merge time; base for historical diffs |
| 22 | 23 | CreatedAt string |
| @@ -57,18 +58,20 @@ const mrSelect = ` | ||
| 57 | 58 | SELECT m.id, m.repo_id, m.number, u.username, |
| 58 | 59 | COALESCE(m.source_repo_id, 0), |
| 59 | 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 | 63 | m.merged_base, m.created_at, m.updated_at |
| 62 | 64 | FROM merge_requests m |
| 63 | 65 | JOIN users u ON u.id = m.author_id |
| 64 | 66 | LEFT JOIN repos sr ON sr.id = m.source_repo_id |
| 65 | 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 | 71 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { |
| 69 | 72 | var m MR |
| 70 | 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 | 75 | return m, err |
| 73 | 76 | } |
| 74 | 77 | |
internal/web/static/style.css +25
| @@ -514,6 +514,31 @@ ul.issuelist p { margin: 0; } | ||
| 514 | 514 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } |
| 515 | 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 | 542 | /* issue / MR detail */ |
| 518 | 543 | h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); } |
| 519 | 544 | .issuenumber { color: var(--muted); font-weight: 400; } |
internal/web/templates/issue.html +2 −1
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | <p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> |
| 6 | 6 | {{.Issue.Author}} opened this on {{when .Issue.CreatedAt}} |
| 7 | 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 | 10 | {{if .BodyHTML}}<article class="comment"> |
| 10 | 11 | <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> |
| 11 | 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 | 8 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> |
| 9 | 9 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> |
| 10 | 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 | 13 | </div> |
| 12 | 14 | <ul class="issuelist"> |
| 13 | 15 | {{range .Issues}}<li> |
| 14 | 16 | <div class="issuemain"> |
| 15 | 17 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> |
| 16 | 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 | 20 | </div> |
| 19 | 21 | <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span> |
| 20 | 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 | 4 | <h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2> |
| 5 | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | 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 | 8 | {{if .BodyHTML}}<article class="comment"> |
| 9 | 9 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> |
| 10 | 10 | <div class="rendered">{{.BodyHTML}}</div> |