Commit d8bcf1584c
Verified · cmc
Layout: unified · split
e2e/issue_test.go added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "regexp" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // normalizeJSON parses JSON and blanks volatile timestamp fields so the | |
| 11 | // remainder can be compared as a golden value. | |
| 12 | var tsPat = regexp.MustCompile(`"\d{4}-\d{2}-\d{2}T[0-9:.]+Z?"`) | |
| 13 | ||
| 14 | func golden(t *testing.T, raw string) string { | |
| 15 | t.Helper() | |
| 16 | norm := tsPat.ReplaceAllString(strings.TrimSpace(raw), `"TS"`) | |
| 17 | // Re-encode compactly for stable comparison. | |
| 18 | var v any | |
| 19 | if err := json.Unmarshal([]byte(norm), &v); err != nil { | |
| 20 | t.Fatalf("not JSON: %v\n%s", err, raw) | |
| 21 | } | |
| 22 | out, _ := json.Marshal(v) | |
| 23 | return string(out) | |
| 24 | } | |
| 25 | ||
| 26 | func TestIssueLifecycleOverBareSSH(t *testing.T) { | |
| 27 | inst := startInstance(t) | |
| 28 | ||
| 29 | aliceKey := inst.newKey(t, "alice") | |
| 30 | bobKey := inst.newKey(t, "bob") | |
| 31 | eveKey := inst.newKey(t, "eve") | |
| 32 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 33 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 34 | inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") | |
| 35 | ||
| 36 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 { | |
| 37 | t.Fatalf("repo create: %s", errOut) | |
| 38 | } | |
| 39 | ||
| 40 | // Create with inline body; numbering starts at 1. | |
| 41 | out, errOut, code := inst.ssh(t, aliceKey, "", | |
| 42 | "issue", "create", "alice/proj", "--title", "'first bug'", "--body", "'it is broken'", "--json") | |
| 43 | if code != 0 { | |
| 44 | t.Fatalf("issue create: %s", errOut) | |
| 45 | } | |
| 46 | if g := golden(t, out); g != `{"data":{"number":1},"protocol_version":1}` { | |
| 47 | t.Fatalf("create output: %s", g) | |
| 48 | } | |
| 49 | ||
| 50 | // Bob (no grant, public repo) can file an issue too — body over stdin. | |
| 51 | _, errOut, code = inst.ssh(t, bobKey, "long body\nfrom stdin\n", | |
| 52 | "issue", "create", "alice/proj", "--title", "'from bob'", "--file", "-") | |
| 53 | if code != 0 { | |
| 54 | t.Fatalf("bob issue create: %s", errOut) | |
| 55 | } | |
| 56 | ||
| 57 | // Comment, label, assign, close. | |
| 58 | if _, errOut, code = inst.ssh(t, bobKey, "", "issue", "comment", "alice/proj", "1", "--message", "'me too'"); code != 0 { | |
| 59 | t.Fatalf("comment: %s", errOut) | |
| 60 | } | |
| 61 | if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "label", "alice/proj", "1", "--add", "bug", "--add", "urgent"); code != 0 { | |
| 62 | t.Fatalf("label: %s", errOut) | |
| 63 | } | |
| 64 | if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "assign", "alice/proj", "1", "--add", "bob"); code != 0 { | |
| 65 | t.Fatalf("assign: %s", errOut) | |
| 66 | } | |
| 67 | ||
| 68 | // Golden check on issue show --json. | |
| 69 | out, _, code = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "1", "--json") | |
| 70 | if code != 0 { | |
| 71 | t.Fatal("issue show failed") | |
| 72 | } | |
| 73 | wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` + | |
| 74 | `"comments":[{"author":"bob","body":"me too","created_at":"TS"}],` + | |
| 75 | `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` + | |
| 76 | `"title":"first bug"},"protocol_version":1}` | |
| 77 | if g := golden(t, out); g != wantShow { | |
| 78 | t.Fatalf("issue show golden mismatch:\ngot %s\nwant %s", g, wantShow) | |
| 79 | } | |
| 80 | ||
| 81 | // Permission edges: eve (read-only public) cannot label or close | |
| 82 | // someone else's issue; bob can close his own. | |
| 83 | _, errOut, code = inst.ssh(t, eveKey, "", "issue", "label", "alice/proj", "1", "--add", "spam") | |
| 84 | if code != 4 { | |
| 85 | t.Fatalf("eve label: exit %d (want 4), %s", code, errOut) | |
| 86 | } | |
| 87 | _, errOut, code = inst.ssh(t, eveKey, "", "issue", "close", "alice/proj", "1") | |
| 88 | if code != 4 { | |
| 89 | t.Fatalf("eve close: exit %d (want 4), %s", code, errOut) | |
| 90 | } | |
| 91 | if _, errOut, code = inst.ssh(t, bobKey, "", "issue", "close", "alice/proj", "2"); code != 0 { | |
| 92 | t.Fatalf("bob closing own issue: %s", errOut) | |
| 93 | } | |
| 94 | ||
| 95 | // Close, list filters, reopen. | |
| 96 | if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "close", "alice/proj", "1"); code != 0 { | |
| 97 | t.Fatalf("close: %s", errOut) | |
| 98 | } | |
| 99 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/proj", "--json") | |
| 100 | if g := golden(t, out); g != `{"data":[],"protocol_version":1}` { // empty list is [], never null | |
| 101 | t.Fatalf("open list after close: %s", g) | |
| 102 | } | |
| 103 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/proj", "--state", "closed", "--json") | |
| 104 | if !strings.Contains(out, `"number":2`) || !strings.Contains(out, `"number":1`) { | |
| 105 | t.Fatalf("closed list: %s", out) | |
| 106 | } | |
| 107 | if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 0 { | |
| 108 | t.Fatalf("reopen: %s", errOut) | |
| 109 | } | |
| 110 | // Double-reopen is a usage error. | |
| 111 | if _, _, code = inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 2 { | |
| 112 | t.Fatalf("double reopen: exit %d, want 2", code) | |
| 113 | } | |
| 114 | ||
| 115 | // Label removal and assignee removal. | |
| 116 | if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "label", "alice/proj", "1", "--remove", "urgent"); code != 0 { | |
| 117 | t.Fatalf("label remove: %s", errOut) | |
| 118 | } | |
| 119 | if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "assign", "alice/proj", "1", "--remove", "bob"); code != 0 { | |
| 120 | t.Fatalf("assign remove: %s", errOut) | |
| 121 | } | |
| 122 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "1", "--json") | |
| 123 | if strings.Contains(out, "urgent") || strings.Contains(out, "assignees") { | |
| 124 | t.Fatalf("removal not reflected: %s", out) | |
| 125 | } | |
| 126 | ||
| 127 | // Missing issue and missing repo produce not-found exit codes. | |
| 128 | if _, _, code = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "99"); code != 3 { | |
| 129 | t.Fatalf("missing issue: exit %d, want 3", code) | |
| 130 | } | |
| 131 | if _, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/nope"); code != 3 { | |
| 132 | t.Fatalf("missing repo: exit %d, want 3", code) | |
| 133 | } | |
| 134 | ||
| 135 | // Web read views: list shows the issue, detail shows the comment. | |
| 136 | status, body := inst.get(t, "/alice/proj/issues") | |
| 137 | if status != 200 || !strings.Contains(body, "first bug") { | |
| 138 | t.Fatalf("issues page: %d", status) | |
| 139 | } | |
| 140 | status, body = inst.get(t, "/alice/proj/issues/1") | |
| 141 | if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") { | |
| 142 | t.Fatalf("issue detail: %d\n%s", status, body) | |
| 143 | } | |
| 144 | ||
| 145 | // Private repos hide their issues from non-readers, as not-found. | |
| 146 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { | |
| 147 | t.Fatal("create private failed") | |
| 148 | } | |
| 149 | if _, _, code = inst.ssh(t, aliceKey, "", "issue", "create", "alice/secret", "--title", "hidden"); code != 0 { | |
| 150 | t.Fatal("issue in private repo failed") | |
| 151 | } | |
| 152 | if _, _, code = inst.ssh(t, eveKey, "", "issue", "show", "alice/secret", "1"); code != 3 { | |
| 153 | t.Fatalf("eve sees private issue: exit %d, want 3", code) | |
| 154 | } | |
| 155 | if status, _ := inst.get(t, "/alice/secret/issues"); status != 404 { | |
| 156 | t.Fatalf("anonymous issues page on private repo: %d, want 404", status) | |
| 157 | } | |
| 158 | } | |
internal/control/control.go +5
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "encoding/json" |
| 8 | 8 | "fmt" |
| 9 | 9 | "io" |
| 10 | "reflect" | |
| 10 | 11 | "slices" |
| 11 | 12 | |
| 12 | 13 | "github.com/krazywarez/forge/internal/config" |
| @@ -91,6 +92,10 @@ func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } | ||
| 91 | 92 | // emit writes data as the command result: a JSON envelope under --json, |
| 92 | 93 | // otherwise via the plain formatter. |
| 93 | 94 | func (c *Ctx) emit(data any, plain func(w io.Writer)) int { |
| 95 | // A nil slice would serialize as null; consumers should see []. | |
| 96 | if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() { | |
| 97 | data = reflect.MakeSlice(v.Type(), 0, 0).Interface() | |
| 98 | } | |
| 94 | 99 | if c.JSON { |
| 95 | 100 | enc := json.NewEncoder(c.Stdout) |
| 96 | 101 | enc.SetEscapeHTML(false) |
internal/control/issue.go added +410
| @@ -0,0 +1,410 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "strconv" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "github.com/krazywarez/forge/internal/policy" | |
| 11 | "github.com/krazywarez/forge/internal/protocol" | |
| 12 | "github.com/krazywarez/forge/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | const maxBodyBytes = 64 << 10 | |
| 16 | ||
| 17 | func init() { | |
| 18 | register(Command{Path: []string{"issue", "create"}, | |
| 19 | Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", | |
| 20 | ReadsStdin: true, Run: runIssueCreate}) | |
| 21 | register(Command{Path: []string{"issue", "list"}, | |
| 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", Run: runIssueList}) | |
| 23 | register(Command{Path: []string{"issue", "show"}, | |
| 24 | Summary: "show an issue with comments: issue show <owner/name> <n>", Run: runIssueShow}) | |
| 25 | register(Command{Path: []string{"issue", "comment"}, | |
| 26 | Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", | |
| 27 | ReadsStdin: true, Run: runIssueComment}) | |
| 28 | register(Command{Path: []string{"issue", "close"}, | |
| 29 | Summary: "close an issue: issue close <owner/name> <n>", Run: runIssueClose}) | |
| 30 | register(Command{Path: []string{"issue", "reopen"}, | |
| 31 | Summary: "reopen an issue: issue reopen <owner/name> <n>", Run: runIssueReopen}) | |
| 32 | register(Command{Path: []string{"issue", "label"}, | |
| 33 | Summary: "labels: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", Run: runIssueLabel}) | |
| 34 | register(Command{Path: []string{"issue", "assign"}, | |
| 35 | Summary: "assignees: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", Run: runIssueAssign}) | |
| 36 | } | |
| 37 | ||
| 38 | // issueArgs parses "<owner/name> <n>" plus flags handled by the caller. | |
| 39 | func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) { | |
| 40 | if len(args) < 2 { | |
| 41 | return store.Repo{}, store.Issue{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>") | |
| 42 | } | |
| 43 | repo, code := resolveRepo(c, args[0], perm) | |
| 44 | if code >= 0 { | |
| 45 | return repo, store.Issue{}, code | |
| 46 | } | |
| 47 | n, err := strconv.ParseInt(args[1], 10, 64) | |
| 48 | if err != nil { | |
| 49 | return repo, store.Issue{}, c.fail(protocol.ExitUsage, "bad issue number %q", args[1]) | |
| 50 | } | |
| 51 | issue, err := c.Store.IssueByNumber(repo.ID, n) | |
| 52 | if errors.Is(err, store.ErrNotFound) { | |
| 53 | return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path()) | |
| 54 | } | |
| 55 | if err != nil { | |
| 56 | return repo, issue, c.fail(protocol.ExitFailure, "%v", err) | |
| 57 | } | |
| 58 | return repo, issue, -1 | |
| 59 | } | |
| 60 | ||
| 61 | // bodyFrom resolves --body/--message inline text or --file - (stdin). | |
| 62 | func bodyFrom(c *Ctx, inline, file string) (string, error) { | |
| 63 | if inline != "" && file != "" { | |
| 64 | return "", errors.New("give either an inline message or --file -, not both") | |
| 65 | } | |
| 66 | if file != "" { | |
| 67 | if file != "-" { | |
| 68 | return "", errors.New("--file only supports - (stdin) over ssh") | |
| 69 | } | |
| 70 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes)) | |
| 71 | return string(raw), err | |
| 72 | } | |
| 73 | return inline, nil | |
| 74 | } | |
| 75 | ||
| 76 | type issueOut struct { | |
| 77 | Number int64 `json:"number"` | |
| 78 | Title string `json:"title"` | |
| 79 | State string `json:"state"` | |
| 80 | Author string `json:"author"` | |
| 81 | Labels []string `json:"labels,omitempty"` | |
| 82 | Assignees []string `json:"assignees,omitempty"` | |
| 83 | Body string `json:"body,omitempty"` | |
| 84 | CreatedAt string `json:"created_at"` | |
| 85 | } | |
| 86 | ||
| 87 | func issueToOut(i store.Issue, withBody bool) issueOut { | |
| 88 | 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 | if withBody { | |
| 91 | o.Body = i.Body | |
| 92 | } | |
| 93 | return o | |
| 94 | } | |
| 95 | ||
| 96 | func runIssueCreate(c *Ctx, args []string) int { | |
| 97 | var path, title, body, file string | |
| 98 | for i := 0; i < len(args); i++ { | |
| 99 | switch args[i] { | |
| 100 | case "--title": | |
| 101 | if i+1 >= len(args) { | |
| 102 | return c.fail(protocol.ExitUsage, "--title requires a value") | |
| 103 | } | |
| 104 | title = args[i+1] | |
| 105 | i++ | |
| 106 | case "--body": | |
| 107 | if i+1 >= len(args) { | |
| 108 | return c.fail(protocol.ExitUsage, "--body requires a value") | |
| 109 | } | |
| 110 | body = args[i+1] | |
| 111 | i++ | |
| 112 | case "--file": | |
| 113 | if i+1 >= len(args) { | |
| 114 | return c.fail(protocol.ExitUsage, "--file requires a value") | |
| 115 | } | |
| 116 | file = args[i+1] | |
| 117 | i++ | |
| 118 | default: | |
| 119 | if path != "" { | |
| 120 | return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) | |
| 121 | } | |
| 122 | path = args[i] | |
| 123 | } | |
| 124 | } | |
| 125 | if path == "" || title == "" { | |
| 126 | return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -]") | |
| 127 | } | |
| 128 | // Anyone who can read the repo can file an issue. | |
| 129 | repo, code := resolveRepo(c, path, policy.CanRead) | |
| 130 | if code >= 0 { | |
| 131 | return code | |
| 132 | } | |
| 133 | b, err := bodyFrom(c, body, file) | |
| 134 | if err != nil { | |
| 135 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 136 | } | |
| 137 | n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b) | |
| 138 | if err != nil { | |
| 139 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 140 | } | |
| 141 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) | |
| 142 | return c.emit(map[string]any{"number": n}, func(w io.Writer) { | |
| 143 | fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) | |
| 144 | }) | |
| 145 | } | |
| 146 | ||
| 147 | func runIssueList(c *Ctx, args []string) int { | |
| 148 | state := "open" | |
| 149 | var path string | |
| 150 | for i := 0; i < len(args); i++ { | |
| 151 | switch args[i] { | |
| 152 | case "--state": | |
| 153 | if i+1 >= len(args) { | |
| 154 | return c.fail(protocol.ExitUsage, "--state requires open|closed|all") | |
| 155 | } | |
| 156 | state = args[i+1] | |
| 157 | i++ | |
| 158 | default: | |
| 159 | if path != "" { | |
| 160 | return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) | |
| 161 | } | |
| 162 | path = args[i] | |
| 163 | } | |
| 164 | } | |
| 165 | if path == "" || (state != "open" && state != "closed" && state != "all") { | |
| 166 | return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all]") | |
| 167 | } | |
| 168 | repo, code := resolveRepo(c, path, policy.CanRead) | |
| 169 | if code >= 0 { | |
| 170 | return code | |
| 171 | } | |
| 172 | issues, err := c.Store.ListIssues(repo.ID, state) | |
| 173 | if err != nil { | |
| 174 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 175 | } | |
| 176 | var ds []issueOut | |
| 177 | for _, i := range issues { | |
| 178 | ds = append(ds, issueToOut(i, false)) | |
| 179 | } | |
| 180 | return c.emit(ds, func(w io.Writer) { | |
| 181 | for _, d := range ds { | |
| 182 | fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author) | |
| 183 | } | |
| 184 | }) | |
| 185 | } | |
| 186 | ||
| 187 | func runIssueShow(c *Ctx, args []string) int { | |
| 188 | repo, issue, code := issueRef(c, args, policy.CanRead) | |
| 189 | if code >= 0 { | |
| 190 | return code | |
| 191 | } | |
| 192 | if len(args) != 2 { | |
| 193 | return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>") | |
| 194 | } | |
| 195 | comments, err := c.Store.ListIssueComments(issue.ID) | |
| 196 | if err != nil { | |
| 197 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 198 | } | |
| 199 | type commentOut struct { | |
| 200 | Author string `json:"author"` | |
| 201 | Body string `json:"body"` | |
| 202 | CreatedAt string `json:"created_at"` | |
| 203 | } | |
| 204 | var cs []commentOut | |
| 205 | for _, cm := range comments { | |
| 206 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt}) | |
| 207 | } | |
| 208 | d := struct { | |
| 209 | issueOut | |
| 210 | Comments []commentOut `json:"comments,omitempty"` | |
| 211 | }{issueToOut(issue, true), cs} | |
| 212 | _ = repo | |
| 213 | return c.emit(d, func(w io.Writer) { | |
| 214 | fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author) | |
| 215 | if len(d.Labels) > 0 { | |
| 216 | fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", ")) | |
| 217 | } | |
| 218 | if len(d.Assignees) > 0 { | |
| 219 | fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", ")) | |
| 220 | } | |
| 221 | if d.Body != "" { | |
| 222 | fmt.Fprintf(w, "\n%s\n", d.Body) | |
| 223 | } | |
| 224 | for _, cm := range cs { | |
| 225 | fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) | |
| 226 | } | |
| 227 | }) | |
| 228 | } | |
| 229 | ||
| 230 | func runIssueComment(c *Ctx, args []string) int { | |
| 231 | var rest []string | |
| 232 | var message, file string | |
| 233 | for i := 0; i < len(args); i++ { | |
| 234 | switch args[i] { | |
| 235 | case "--message": | |
| 236 | if i+1 >= len(args) { | |
| 237 | return c.fail(protocol.ExitUsage, "--message requires a value") | |
| 238 | } | |
| 239 | message = args[i+1] | |
| 240 | i++ | |
| 241 | case "--file": | |
| 242 | if i+1 >= len(args) { | |
| 243 | return c.fail(protocol.ExitUsage, "--file requires a value") | |
| 244 | } | |
| 245 | file = args[i+1] | |
| 246 | i++ | |
| 247 | default: | |
| 248 | rest = append(rest, args[i]) | |
| 249 | } | |
| 250 | } | |
| 251 | repo, issue, code := issueRef(c, rest, policy.CanRead) | |
| 252 | if code >= 0 { | |
| 253 | return code | |
| 254 | } | |
| 255 | body, err := bodyFrom(c, message, file) | |
| 256 | if err != nil { | |
| 257 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 258 | } | |
| 259 | if strings.TrimSpace(body) == "" { | |
| 260 | return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") | |
| 261 | } | |
| 262 | if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body); err != nil { | |
| 263 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 264 | } | |
| 265 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number)) | |
| 266 | return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { | |
| 267 | fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number) | |
| 268 | }) | |
| 269 | } | |
| 270 | ||
| 271 | func setIssueState(c *Ctx, args []string, state string) int { | |
| 272 | // Author may close/reopen their own issue; otherwise write access. | |
| 273 | repo, issue, code := issueRef(c, args, policy.CanRead) | |
| 274 | if code >= 0 { | |
| 275 | return code | |
| 276 | } | |
| 277 | if len(args) != 2 { | |
| 278 | return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state) | |
| 279 | } | |
| 280 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | |
| 281 | if err != nil { | |
| 282 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 283 | } | |
| 284 | if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { | |
| 285 | return c.fail(protocol.ExitDenied, "only the author or users with write access can %s this issue", | |
| 286 | map[string]string{"open": "reopen", "closed": "close"}[state]) | |
| 287 | } | |
| 288 | if issue.State == state { | |
| 289 | return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state) | |
| 290 | } | |
| 291 | if err := c.Store.SetIssueState(issue.ID, state); err != nil { | |
| 292 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 293 | } | |
| 294 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) | |
| 295 | return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) { | |
| 296 | fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state) | |
| 297 | }) | |
| 298 | } | |
| 299 | ||
| 300 | func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") } | |
| 301 | func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") } | |
| 302 | ||
| 303 | // addRemoveFlags parses repeated --add/--remove flags. | |
| 304 | func addRemoveFlags(args []string) (rest, adds, removes []string, err error) { | |
| 305 | for i := 0; i < len(args); i++ { | |
| 306 | switch args[i] { | |
| 307 | case "--add": | |
| 308 | if i+1 >= len(args) { | |
| 309 | return nil, nil, nil, errors.New("--add requires a value") | |
| 310 | } | |
| 311 | adds = append(adds, args[i+1]) | |
| 312 | i++ | |
| 313 | case "--remove": | |
| 314 | if i+1 >= len(args) { | |
| 315 | return nil, nil, nil, errors.New("--remove requires a value") | |
| 316 | } | |
| 317 | removes = append(removes, args[i+1]) | |
| 318 | i++ | |
| 319 | default: | |
| 320 | rest = append(rest, args[i]) | |
| 321 | } | |
| 322 | } | |
| 323 | return rest, adds, removes, nil | |
| 324 | } | |
| 325 | ||
| 326 | func runIssueLabel(c *Ctx, args []string) int { | |
| 327 | rest, adds, removes, err := addRemoveFlags(args) | |
| 328 | if err != nil { | |
| 329 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 330 | } | |
| 331 | if len(adds)+len(removes) == 0 { | |
| 332 | return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...") | |
| 333 | } | |
| 334 | repo, issue, code := issueRef(c, rest, policy.CanWrite) | |
| 335 | if code >= 0 { | |
| 336 | return code | |
| 337 | } | |
| 338 | for _, l := range adds { | |
| 339 | if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil { | |
| 340 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 341 | } | |
| 342 | } | |
| 343 | for _, l := range removes { | |
| 344 | if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil { | |
| 345 | if errors.Is(err, store.ErrNotFound) { | |
| 346 | return c.fail(protocol.ExitNotFound, "%v", err) | |
| 347 | } | |
| 348 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 349 | } | |
| 350 | } | |
| 351 | updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) | |
| 352 | if err != nil { | |
| 353 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 354 | } | |
| 355 | return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) { | |
| 356 | fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", ")) | |
| 357 | }) | |
| 358 | } | |
| 359 | ||
| 360 | func runIssueAssign(c *Ctx, args []string) int { | |
| 361 | rest, adds, removes, err := addRemoveFlags(args) | |
| 362 | if err != nil { | |
| 363 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 364 | } | |
| 365 | if len(adds)+len(removes) == 0 { | |
| 366 | return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...") | |
| 367 | } | |
| 368 | repo, issue, code := issueRef(c, rest, policy.CanWrite) | |
| 369 | if code >= 0 { | |
| 370 | return code | |
| 371 | } | |
| 372 | resolve := func(name string) (store.User, int) { | |
| 373 | u, err := c.Store.UserByUsername(name) | |
| 374 | if errors.Is(err, store.ErrNotFound) { | |
| 375 | return u, c.fail(protocol.ExitNotFound, "no such user %q", name) | |
| 376 | } | |
| 377 | if err != nil { | |
| 378 | return u, c.fail(protocol.ExitFailure, "%v", err) | |
| 379 | } | |
| 380 | return u, -1 | |
| 381 | } | |
| 382 | for _, name := range adds { | |
| 383 | u, code := resolve(name) | |
| 384 | if code >= 0 { | |
| 385 | return code | |
| 386 | } | |
| 387 | if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil { | |
| 388 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 389 | } | |
| 390 | } | |
| 391 | for _, name := range removes { | |
| 392 | u, code := resolve(name) | |
| 393 | if code >= 0 { | |
| 394 | return code | |
| 395 | } | |
| 396 | if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil { | |
| 397 | if errors.Is(err, store.ErrNotFound) { | |
| 398 | return c.fail(protocol.ExitNotFound, "%s is not assigned", name) | |
| 399 | } | |
| 400 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 401 | } | |
| 402 | } | |
| 403 | updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) | |
| 404 | if err != nil { | |
| 405 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 406 | } | |
| 407 | return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) { | |
| 408 | fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", ")) | |
| 409 | }) | |
| 410 | } | |
internal/httpd/routes.go +2
| @@ -37,6 +37,8 @@ func (s *Server) Routes() []Route { | ||
| 37 | 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit}, |
| 38 | 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, |
| 39 | 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, |
| 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, | |
| 41 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/{n}", Handler: s.issue}, | |
| 40 | 42 | ) |
| 41 | 43 | |
| 42 | 44 | // Account-mode routes (login, web edits) are appended here in M8 — |
internal/httpd/web.go +49
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "html/template" |
| 7 | 7 | "net/http" |
| 8 | 8 | "path" |
| 9 | "strconv" | |
| 9 | 10 | "strings" |
| 10 | 11 | "time" |
| 11 | 12 | |
| @@ -364,6 +365,54 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 364 | 365 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines}) |
| 365 | 366 | } |
| 366 | 367 | |
| 368 | func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | |
| 369 | p, ok := s.repoFor(w, r, "") | |
| 370 | if !ok { | |
| 371 | return | |
| 372 | } | |
| 373 | state := r.URL.Query().Get("state") | |
| 374 | if state != "closed" && state != "all" { | |
| 375 | state = "open" | |
| 376 | } | |
| 377 | issues, err := s.st.ListIssues(p.Repo.ID, state) | |
| 378 | if err != nil { | |
| 379 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 380 | return | |
| 381 | } | |
| 382 | s.render(w, "issues.html", struct { | |
| 383 | repoPage | |
| 384 | State string | |
| 385 | Issues []store.Issue | |
| 386 | }{p, state, issues}) | |
| 387 | } | |
| 388 | ||
| 389 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | |
| 390 | p, ok := s.repoFor(w, r, "") | |
| 391 | if !ok { | |
| 392 | return | |
| 393 | } | |
| 394 | n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) | |
| 395 | if err != nil { | |
| 396 | http.NotFound(w, r) | |
| 397 | return | |
| 398 | } | |
| 399 | iss, err := s.st.IssueByNumber(p.Repo.ID, n) | |
| 400 | if err != nil { | |
| 401 | http.NotFound(w, r) | |
| 402 | return | |
| 403 | } | |
| 404 | comments, err := s.st.ListIssueComments(iss.ID) | |
| 405 | if err != nil { | |
| 406 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 407 | return | |
| 408 | } | |
| 409 | s.render(w, "issue.html", struct { | |
| 410 | repoPage | |
| 411 | Issue store.Issue | |
| 412 | Comments []store.IssueComment | |
| 413 | }{p, iss, comments}) | |
| 414 | } | |
| 415 | ||
| 367 | 416 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
| 368 | 417 | p, ok := s.repoFor(w, r, "") |
| 369 | 418 | if !ok { |
internal/store/issues.go added +232
| @@ -0,0 +1,232 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | ) | |
| 8 | ||
| 9 | type Issue struct { | |
| 10 | ID int64 | |
| 11 | RepoID int64 | |
| 12 | Number int64 | |
| 13 | Author string | |
| 14 | Title string | |
| 15 | Body string | |
| 16 | State string // open | closed | |
| 17 | CreatedAt string | |
| 18 | UpdatedAt string | |
| 19 | Labels []string | |
| 20 | Assignees []string | |
| 21 | } | |
| 22 | ||
| 23 | type IssueComment struct { | |
| 24 | Author string | |
| 25 | Body string | |
| 26 | CreatedAt string | |
| 27 | } | |
| 28 | ||
| 29 | // CreateIssue allocates the per-repo number from the repo counter inside the | |
| 30 | // same transaction as the insert — MAX(number)+1 races. | |
| 31 | func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, error) { | |
| 32 | tx, err := s.DB.Begin() | |
| 33 | if err != nil { | |
| 34 | return 0, err | |
| 35 | } | |
| 36 | defer tx.Rollback() | |
| 37 | if _, err := tx.Exec("UPDATE repos SET issue_counter = issue_counter + 1 WHERE id = ?", repoID); err != nil { | |
| 38 | return 0, err | |
| 39 | } | |
| 40 | var n int64 | |
| 41 | if err := tx.QueryRow("SELECT issue_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil { | |
| 42 | return 0, err | |
| 43 | } | |
| 44 | if _, err := tx.Exec( | |
| 45 | "INSERT INTO issues (repo_id, number, author_id, title, body) VALUES (?, ?, ?, ?, ?)", | |
| 46 | repoID, n, authorID, title, body); err != nil { | |
| 47 | return 0, err | |
| 48 | } | |
| 49 | return n, tx.Commit() | |
| 50 | } | |
| 51 | ||
| 52 | func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { | |
| 53 | var i Issue | |
| 54 | 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 | FROM issues i JOIN users u ON u.id = i.author_id | |
| 57 | 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) | |
| 59 | if errors.Is(err, sql.ErrNoRows) { | |
| 60 | return i, ErrNotFound | |
| 61 | } | |
| 62 | if err != nil { | |
| 63 | return i, err | |
| 64 | } | |
| 65 | if i.Labels, err = s.issueStrings(i.ID, ` | |
| 66 | SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id | |
| 67 | WHERE il.issue_id = ? ORDER BY l.name`); err != nil { | |
| 68 | return i, err | |
| 69 | } | |
| 70 | i.Assignees, err = s.issueStrings(i.ID, ` | |
| 71 | SELECT u.username FROM issue_assignees ia JOIN users u ON u.id = ia.user_id | |
| 72 | WHERE ia.issue_id = ? ORDER BY u.username`) | |
| 73 | return i, err | |
| 74 | } | |
| 75 | ||
| 76 | func (s *Store) issueStrings(issueID int64, query string) ([]string, error) { | |
| 77 | rows, err := s.DB.Query(query, issueID) | |
| 78 | if err != nil { | |
| 79 | return nil, err | |
| 80 | } | |
| 81 | defer rows.Close() | |
| 82 | var out []string | |
| 83 | for rows.Next() { | |
| 84 | var v string | |
| 85 | if err := rows.Scan(&v); err != nil { | |
| 86 | return nil, err | |
| 87 | } | |
| 88 | out = append(out, v) | |
| 89 | } | |
| 90 | return out, rows.Err() | |
| 91 | } | |
| 92 | ||
| 93 | // ListIssues returns issues for a repo; state is "open", "closed", or "all". | |
| 94 | 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 = ?` | |
| 97 | args := []any{repoID} | |
| 98 | if state != "all" { | |
| 99 | q += " AND i.state = ?" | |
| 100 | args = append(args, state) | |
| 101 | } | |
| 102 | q += " ORDER BY i.number DESC" | |
| 103 | rows, err := s.DB.Query(q, args...) | |
| 104 | if err != nil { | |
| 105 | return nil, err | |
| 106 | } | |
| 107 | defer rows.Close() | |
| 108 | var out []Issue | |
| 109 | for rows.Next() { | |
| 110 | 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 { | |
| 112 | return nil, err | |
| 113 | } | |
| 114 | out = append(out, i) | |
| 115 | } | |
| 116 | return out, rows.Err() | |
| 117 | } | |
| 118 | ||
| 119 | func (s *Store) SetIssueState(issueID int64, state string) error { | |
| 120 | res, err := s.DB.Exec( | |
| 121 | "UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", | |
| 122 | state, issueID) | |
| 123 | if err != nil { | |
| 124 | return err | |
| 125 | } | |
| 126 | if n, _ := res.RowsAffected(); n == 0 { | |
| 127 | return ErrNotFound | |
| 128 | } | |
| 129 | return nil | |
| 130 | } | |
| 131 | ||
| 132 | func (s *Store) AddIssueComment(issueID, authorID int64, body string) error { | |
| 133 | tx, err := s.DB.Begin() | |
| 134 | if err != nil { | |
| 135 | return err | |
| 136 | } | |
| 137 | defer tx.Rollback() | |
| 138 | if _, err := tx.Exec( | |
| 139 | "INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)", | |
| 140 | issueID, authorID, body); err != nil { | |
| 141 | return err | |
| 142 | } | |
| 143 | if _, err := tx.Exec( | |
| 144 | "UPDATE issues SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", issueID); err != nil { | |
| 145 | return err | |
| 146 | } | |
| 147 | return tx.Commit() | |
| 148 | } | |
| 149 | ||
| 150 | func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { | |
| 151 | rows, err := s.DB.Query(` | |
| 152 | SELECT u.username, c.body, c.created_at | |
| 153 | FROM issue_comments c JOIN users u ON u.id = c.author_id | |
| 154 | WHERE c.issue_id = ? ORDER BY c.id`, issueID) | |
| 155 | if err != nil { | |
| 156 | return nil, err | |
| 157 | } | |
| 158 | defer rows.Close() | |
| 159 | var out []IssueComment | |
| 160 | for rows.Next() { | |
| 161 | var c IssueComment | |
| 162 | if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil { | |
| 163 | return nil, err | |
| 164 | } | |
| 165 | out = append(out, c) | |
| 166 | } | |
| 167 | return out, rows.Err() | |
| 168 | } | |
| 169 | ||
| 170 | // SetIssueLabel attaches (add) or detaches a label, creating the repo label | |
| 171 | // on first use. | |
| 172 | func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error { | |
| 173 | tx, err := s.DB.Begin() | |
| 174 | if err != nil { | |
| 175 | return err | |
| 176 | } | |
| 177 | defer tx.Rollback() | |
| 178 | if add { | |
| 179 | if _, err := tx.Exec( | |
| 180 | "INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING", | |
| 181 | repoID, name); err != nil { | |
| 182 | return err | |
| 183 | } | |
| 184 | if _, err := tx.Exec(` | |
| 185 | INSERT INTO issue_labels (issue_id, label_id) | |
| 186 | SELECT ?, id FROM labels WHERE repo_id = ? AND name = ? | |
| 187 | ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil { | |
| 188 | return err | |
| 189 | } | |
| 190 | } else { | |
| 191 | res, err := tx.Exec(` | |
| 192 | DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN | |
| 193 | (SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name) | |
| 194 | if err != nil { | |
| 195 | return err | |
| 196 | } | |
| 197 | if n, _ := res.RowsAffected(); n == 0 { | |
| 198 | return fmt.Errorf("label %q: %w", name, ErrNotFound) | |
| 199 | } | |
| 200 | } | |
| 201 | return tx.Commit() | |
| 202 | } | |
| 203 | ||
| 204 | // SetIssueAssignee adds or removes an assignee by user id. | |
| 205 | func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error { | |
| 206 | if add { | |
| 207 | _, err := s.DB.Exec( | |
| 208 | "INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", | |
| 209 | issueID, userID) | |
| 210 | return err | |
| 211 | } | |
| 212 | res, err := s.DB.Exec( | |
| 213 | "DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID) | |
| 214 | if err != nil { | |
| 215 | return err | |
| 216 | } | |
| 217 | if n, _ := res.RowsAffected(); n == 0 { | |
| 218 | return ErrNotFound | |
| 219 | } | |
| 220 | return nil | |
| 221 | } | |
| 222 | ||
| 223 | // RecordEvent appends to the event log (the forward hook CI will consume). | |
| 224 | func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error { | |
| 225 | if dataJSON == "" { | |
| 226 | dataJSON = "{}" | |
| 227 | } | |
| 228 | _, err := s.DB.Exec( | |
| 229 | "INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)", | |
| 230 | repoID, actorID, kind, dataJSON) | |
| 231 | return err | |
| 232 | } | |
internal/web/templates/issue.html added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | {{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2>#{{.Issue.Number}} {{.Issue.Title}} <span class="badge badge-unsigned">{{.Issue.State}}</span></h2> | |
| 5 | <p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} | |
| 6 | {{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} | |
| 7 | {{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> | |
| 8 | {{if .Issue.Body}}<pre class="message">{{.Issue.Body}}</pre>{{end}} | |
| 9 | {{range .Comments}} | |
| 10 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> | |
| 11 | {{end}} | |
| 12 | {{end}} | |
internal/web/templates/issues.html added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2>issues ({{.State}})</h2> | |
| 5 | <p class="crumbs"><a href="?state=open">open</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p> | |
| 6 | <table> | |
| 7 | {{range .Issues}}<tr> | |
| 8 | <td>#{{.Number}}</td> | |
| 9 | <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a></td> | |
| 10 | <td>{{.State}}</td> | |
| 11 | <td>{{.Author}}</td> | |
| 12 | <td>{{range .Labels}}<span class="badge badge-unsigned">{{.}}</span> {{end}}</td> | |
| 13 | </tr> | |
| 14 | {{else}}<tr><td>no issues</td></tr>{{end}} | |
| 15 | </table> | |
| 16 | {{end}} | |