Commit d8bcf1584c

d8bcf1584ceb0dae5906070084c9b7b85174cfa2

parent: ab56677031

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 23:02 UTC

M6: issues

- issue create/list/show/comment/close/reopen/label/assign as control
  commands; per-repo numbering from the repo counter in-transaction
- permissions: read access files and comments; author or write access
  closes/reopens; write access manages labels and assignees
- bodies via inline flag or --file - (stdin), 64KB cap
- labels auto-created per repo on first use; events recorded for
  create/comment/close/reopen
- empty list results serialize as [] never null (emit fix, all commands)
- web read views: /issues list with state filter, /issues/{n} detail
- e2e: full lifecycle over bare ssh with normalized-JSON golden checks,
  permission edges, not-found codes, private-repo hiding on ssh and web

Layout: unified · split

e2e/issue_test.go added +158
@@ -0,0 +1,158 @@
1package e2e
2
3import (
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.
12var tsPat = regexp.MustCompile(`"\d{4}-\d{2}-\d{2}T[0-9:.]+Z?"`)
13
14func 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
26func 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 "encoding/json" 7 "encoding/json"
8 "fmt" 8 "fmt"
9 "io" 9 "io"
10 "reflect"
10 "slices" 11 "slices"
11 12
12 "github.com/krazywarez/forge/internal/config" 13 "github.com/krazywarez/forge/internal/config"
@@ -91,6 +92,10 @@ func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
91// emit writes data as the command result: a JSON envelope under --json, 92// emit writes data as the command result: a JSON envelope under --json,
92// otherwise via the plain formatter. 93// otherwise via the plain formatter.
93func (c *Ctx) emit(data any, plain func(w io.Writer)) int { 94func (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 if c.JSON { 99 if c.JSON {
95 enc := json.NewEncoder(c.Stdout) 100 enc := json.NewEncoder(c.Stdout)
96 enc.SetEscapeHTML(false) 101 enc.SetEscapeHTML(false)
internal/control/issue.go added +410
@@ -0,0 +1,410 @@
1package control
2
3import (
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
15const maxBodyBytes = 64 << 10
16
17func 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.
39func 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).
62func 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
76type 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
87func 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
96func 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
147func 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
187func 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
230func 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
271func 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
300func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") }
301func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
302
303// addRemoveFlags parses repeated --add/--remove flags.
304func 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
326func 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
360func 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 Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit}, 37 Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit},
38 Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, 38 Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs},
39 Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, 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 // Account-mode routes (login, web edits) are appended here in M8 — 44 // Account-mode routes (login, web edits) are appended here in M8 —
internal/httpd/web.go +49
@@ -6,6 +6,7 @@ import (
6 "html/template" 6 "html/template"
7 "net/http" 7 "net/http"
8 "path" 8 "path"
9 "strconv"
9 "strings" 10 "strings"
10 "time" 11 "time"
11 12
@@ -364,6 +365,54 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
364 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines}) 365 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
365} 366}
366 367
368func (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
389func (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
367func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 416func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
368 p, ok := s.repoFor(w, r, "") 417 p, ok := s.repoFor(w, r, "")
369 if !ok { 418 if !ok {
internal/store/issues.go added +232
@@ -0,0 +1,232 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9type 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
23type 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.
31func (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
52func (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
76func (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".
94func (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
119func (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
132func (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
150func (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.
172func (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.
205func (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).
224func (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}}