krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
d8bcf1584ceb0dae5906070084c9b7b85174cfa2
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:02:14Z
e2e/issue_test.go | 158 ++++++++++++++ internal/control/control.go | 5 + internal/control/issue.go | 410 +++++++++++++++++++++++++++++++++++++ internal/httpd/routes.go | 2 + internal/httpd/web.go | 49 +++++ internal/store/issues.go | 232 +++++++++++++++++++++ internal/web/templates/issue.html | 12 ++ internal/web/templates/issues.html | 16 ++ 8 files changed, 884 insertions(+) new file mode 100644 @@ -0,0 +1,158 @@ +package e2e + +import ( + "encoding/json" + "regexp" + "strings" + "testing" +) + +// normalizeJSON parses JSON and blanks volatile timestamp fields so the +// remainder can be compared as a golden value. +var tsPat = regexp.MustCompile(`"\d{4}-\d{2}-\d{2}T[0-9:.]+Z?"`) + +func golden(t *testing.T, raw string) string { + t.Helper() + norm := tsPat.ReplaceAllString(strings.TrimSpace(raw), `"TS"`) + // Re-encode compactly for stable comparison. + var v any + if err := json.Unmarshal([]byte(norm), &v); err != nil { + t.Fatalf("not JSON: %v\n%s", err, raw) + } + out, _ := json.Marshal(v) + return string(out) +} + +func TestIssueLifecycleOverBareSSH(t *testing.T) { + inst := startInstance(t) + + aliceKey := inst.newKey(t, "alice") + bobKey := inst.newKey(t, "bob") + eveKey := inst.newKey(t, "eve") + inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") + + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 { + t.Fatalf("repo create: %s", errOut) + } + + // Create with inline body; numbering starts at 1. + out, errOut, code := inst.ssh(t, aliceKey, "", + "issue", "create", "alice/proj", "--title", "'first bug'", "--body", "'it is broken'", "--json") + if code != 0 { + t.Fatalf("issue create: %s", errOut) + } + if g := golden(t, out); g != `{"data":{"number":1},"protocol_version":1}` { + t.Fatalf("create output: %s", g) + } + + // Bob (no grant, public repo) can file an issue too — body over stdin. + _, errOut, code = inst.ssh(t, bobKey, "long body\nfrom stdin\n", + "issue", "create", "alice/proj", "--title", "'from bob'", "--file", "-") + if code != 0 { + t.Fatalf("bob issue create: %s", errOut) + } + + // Comment, label, assign, close. + if _, errOut, code = inst.ssh(t, bobKey, "", "issue", "comment", "alice/proj", "1", "--message", "'me too'"); code != 0 { + t.Fatalf("comment: %s", errOut) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "label", "alice/proj", "1", "--add", "bug", "--add", "urgent"); code != 0 { + t.Fatalf("label: %s", errOut) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "assign", "alice/proj", "1", "--add", "bob"); code != 0 { + t.Fatalf("assign: %s", errOut) + } + + // Golden check on issue show --json. + out, _, code = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "1", "--json") + if code != 0 { + t.Fatal("issue show failed") + } + wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` + + `"comments":[{"author":"bob","body":"me too","created_at":"TS"}],` + + `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` + + `"title":"first bug"},"protocol_version":1}` + if g := golden(t, out); g != wantShow { + t.Fatalf("issue show golden mismatch:\ngot %s\nwant %s", g, wantShow) + } + + // Permission edges: eve (read-only public) cannot label or close + // someone else's issue; bob can close his own. + _, errOut, code = inst.ssh(t, eveKey, "", "issue", "label", "alice/proj", "1", "--add", "spam") + if code != 4 { + t.Fatalf("eve label: exit %d (want 4), %s", code, errOut) + } + _, errOut, code = inst.ssh(t, eveKey, "", "issue", "close", "alice/proj", "1") + if code != 4 { + t.Fatalf("eve close: exit %d (want 4), %s", code, errOut) + } + if _, errOut, code = inst.ssh(t, bobKey, "", "issue", "close", "alice/proj", "2"); code != 0 { + t.Fatalf("bob closing own issue: %s", errOut) + } + + // Close, list filters, reopen. + if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "close", "alice/proj", "1"); code != 0 { + t.Fatalf("close: %s", errOut) + } + out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/proj", "--json") + if g := golden(t, out); g != `{"data":[],"protocol_version":1}` { // empty list is [], never null + t.Fatalf("open list after close: %s", g) + } + out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/proj", "--state", "closed", "--json") + if !strings.Contains(out, `"number":2`) || !strings.Contains(out, `"number":1`) { + t.Fatalf("closed list: %s", out) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 0 { + t.Fatalf("reopen: %s", errOut) + } + // Double-reopen is a usage error. + if _, _, code = inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 2 { + t.Fatalf("double reopen: exit %d, want 2", code) + } + + // Label removal and assignee removal. + if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "label", "alice/proj", "1", "--remove", "urgent"); code != 0 { + t.Fatalf("label remove: %s", errOut) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "assign", "alice/proj", "1", "--remove", "bob"); code != 0 { + t.Fatalf("assign remove: %s", errOut) + } + out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "1", "--json") + if strings.Contains(out, "urgent") || strings.Contains(out, "assignees") { + t.Fatalf("removal not reflected: %s", out) + } + + // Missing issue and missing repo produce not-found exit codes. + if _, _, code = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "99"); code != 3 { + t.Fatalf("missing issue: exit %d, want 3", code) + } + if _, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/nope"); code != 3 { + t.Fatalf("missing repo: exit %d, want 3", code) + } + + // Web read views: list shows the issue, detail shows the comment. + status, body := inst.get(t, "/alice/proj/issues") + if status != 200 || !strings.Contains(body, "first bug") { + t.Fatalf("issues page: %d", status) + } + status, body = inst.get(t, "/alice/proj/issues/1") + if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") { + t.Fatalf("issue detail: %d\n%s", status, body) + } + + // Private repos hide their issues from non-readers, as not-found. + if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { + t.Fatal("create private failed") + } + if _, _, code = inst.ssh(t, aliceKey, "", "issue", "create", "alice/secret", "--title", "hidden"); code != 0 { + t.Fatal("issue in private repo failed") + } + if _, _, code = inst.ssh(t, eveKey, "", "issue", "show", "alice/secret", "1"); code != 3 { + t.Fatalf("eve sees private issue: exit %d, want 3", code) + } + if status, _ := inst.get(t, "/alice/secret/issues"); status != 404 { + t.Fatalf("anonymous issues page on private repo: %d, want 404", status) + } +} @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "reflect" "slices" "github.com/krazywarez/forge/internal/config" @@ -91,6 +92,10 @@ func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } // emit writes data as the command result: a JSON envelope under --json, // otherwise via the plain formatter. func (c *Ctx) emit(data any, plain func(w io.Writer)) int { + // A nil slice would serialize as null; consumers should see []. + if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() { + data = reflect.MakeSlice(v.Type(), 0, 0).Interface() + } if c.JSON { enc := json.NewEncoder(c.Stdout) enc.SetEscapeHTML(false) new file mode 100644 @@ -0,0 +1,410 @@ +package control + +import ( + "errors" + "fmt" + "io" + "strconv" + "strings" + + "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +const maxBodyBytes = 64 << 10 + +func init() { + register(Command{Path: []string{"issue", "create"}, + Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", + ReadsStdin: true, Run: runIssueCreate}) + register(Command{Path: []string{"issue", "list"}, + Summary: "list issues: issue list <owner/name> [--state open|closed|all]", Run: runIssueList}) + register(Command{Path: []string{"issue", "show"}, + Summary: "show an issue with comments: issue show <owner/name> <n>", Run: runIssueShow}) + register(Command{Path: []string{"issue", "comment"}, + Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", + ReadsStdin: true, Run: runIssueComment}) + register(Command{Path: []string{"issue", "close"}, + Summary: "close an issue: issue close <owner/name> <n>", Run: runIssueClose}) + register(Command{Path: []string{"issue", "reopen"}, + Summary: "reopen an issue: issue reopen <owner/name> <n>", Run: runIssueReopen}) + register(Command{Path: []string{"issue", "label"}, + Summary: "labels: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", Run: runIssueLabel}) + register(Command{Path: []string{"issue", "assign"}, + Summary: "assignees: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", Run: runIssueAssign}) +} + +// issueArgs parses "<owner/name> <n>" plus flags handled by the caller. +func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) { + if len(args) < 2 { + return store.Repo{}, store.Issue{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>") + } + repo, code := resolveRepo(c, args[0], perm) + if code >= 0 { + return repo, store.Issue{}, code + } + n, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + return repo, store.Issue{}, c.fail(protocol.ExitUsage, "bad issue number %q", args[1]) + } + issue, err := c.Store.IssueByNumber(repo.ID, n) + if errors.Is(err, store.ErrNotFound) { + return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path()) + } + if err != nil { + return repo, issue, c.fail(protocol.ExitFailure, "%v", err) + } + return repo, issue, -1 +} + +// bodyFrom resolves --body/--message inline text or --file - (stdin). +func bodyFrom(c *Ctx, inline, file string) (string, error) { + if inline != "" && file != "" { + return "", errors.New("give either an inline message or --file -, not both") + } + if file != "" { + if file != "-" { + return "", errors.New("--file only supports - (stdin) over ssh") + } + raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes)) + return string(raw), err + } + return inline, nil +} + +type issueOut struct { + Number int64 `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Author string `json:"author"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Body string `json:"body,omitempty"` + CreatedAt string `json:"created_at"` +} + +func issueToOut(i store.Issue, withBody bool) issueOut { + o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author, + Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt} + if withBody { + o.Body = i.Body + } + return o +} + +func runIssueCreate(c *Ctx, args []string) int { + var path, title, body, file string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--title requires a value") + } + title = args[i+1] + i++ + case "--body": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--body requires a value") + } + body = args[i+1] + i++ + case "--file": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--file requires a value") + } + file = args[i+1] + i++ + default: + if path != "" { + return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) + } + path = args[i] + } + } + if path == "" || title == "" { + return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -]") + } + // Anyone who can read the repo can file an issue. + repo, code := resolveRepo(c, path, policy.CanRead) + if code >= 0 { + return code + } + b, err := bodyFrom(c, body, file) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) + return c.emit(map[string]any{"number": n}, func(w io.Writer) { + fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) + }) +} + +func runIssueList(c *Ctx, args []string) int { + state := "open" + var path string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--state": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--state requires open|closed|all") + } + state = args[i+1] + i++ + default: + if path != "" { + return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) + } + path = args[i] + } + } + if path == "" || (state != "open" && state != "closed" && state != "all") { + return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all]") + } + repo, code := resolveRepo(c, path, policy.CanRead) + if code >= 0 { + return code + } + issues, err := c.Store.ListIssues(repo.ID, state) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + var ds []issueOut + for _, i := range issues { + ds = append(ds, issueToOut(i, false)) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author) + } + }) +} + +func runIssueShow(c *Ctx, args []string) int { + repo, issue, code := issueRef(c, args, policy.CanRead) + if code >= 0 { + return code + } + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>") + } + comments, err := c.Store.ListIssueComments(issue.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type commentOut struct { + Author string `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + } + var cs []commentOut + for _, cm := range comments { + cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt}) + } + d := struct { + issueOut + Comments []commentOut `json:"comments,omitempty"` + }{issueToOut(issue, true), cs} + _ = repo + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author) + if len(d.Labels) > 0 { + fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", ")) + } + if len(d.Assignees) > 0 { + fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", ")) + } + if d.Body != "" { + fmt.Fprintf(w, "\n%s\n", d.Body) + } + for _, cm := range cs { + fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) + } + }) +} + +func runIssueComment(c *Ctx, args []string) int { + var rest []string + var message, file string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--message": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--message requires a value") + } + message = args[i+1] + i++ + case "--file": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--file requires a value") + } + file = args[i+1] + i++ + default: + rest = append(rest, args[i]) + } + } + repo, issue, code := issueRef(c, rest, policy.CanRead) + if code >= 0 { + return code + } + body, err := bodyFrom(c, message, file) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if strings.TrimSpace(body) == "" { + return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") + } + if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number)) + return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { + fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number) + }) +} + +func setIssueState(c *Ctx, args []string, state string) int { + // Author may close/reopen their own issue; otherwise write access. + repo, issue, code := issueRef(c, args, policy.CanRead) + if code >= 0 { + return code + } + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state) + } + grant, err := c.Store.AccessRole(repo.ID, c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { + return c.fail(protocol.ExitDenied, "only the author or users with write access can %s this issue", + map[string]string{"open": "reopen", "closed": "close"}[state]) + } + if issue.State == state { + return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state) + } + if err := c.Store.SetIssueState(issue.ID, state); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) + return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) { + fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state) + }) +} + +func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") } +func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") } + +// addRemoveFlags parses repeated --add/--remove flags. +func addRemoveFlags(args []string) (rest, adds, removes []string, err error) { + for i := 0; i < len(args); i++ { + switch args[i] { + case "--add": + if i+1 >= len(args) { + return nil, nil, nil, errors.New("--add requires a value") + } + adds = append(adds, args[i+1]) + i++ + case "--remove": + if i+1 >= len(args) { + return nil, nil, nil, errors.New("--remove requires a value") + } + removes = append(removes, args[i+1]) + i++ + default: + rest = append(rest, args[i]) + } + } + return rest, adds, removes, nil +} + +func runIssueLabel(c *Ctx, args []string) int { + rest, adds, removes, err := addRemoveFlags(args) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if len(adds)+len(removes) == 0 { + return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...") + } + repo, issue, code := issueRef(c, rest, policy.CanWrite) + if code >= 0 { + return code + } + for _, l := range adds { + if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + } + for _, l := range removes { + if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "%v", err) + } + return c.fail(protocol.ExitFailure, "%v", err) + } + } + updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) { + fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", ")) + }) +} + +func runIssueAssign(c *Ctx, args []string) int { + rest, adds, removes, err := addRemoveFlags(args) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if len(adds)+len(removes) == 0 { + return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...") + } + repo, issue, code := issueRef(c, rest, policy.CanWrite) + if code >= 0 { + return code + } + resolve := func(name string) (store.User, int) { + u, err := c.Store.UserByUsername(name) + if errors.Is(err, store.ErrNotFound) { + return u, c.fail(protocol.ExitNotFound, "no such user %q", name) + } + if err != nil { + return u, c.fail(protocol.ExitFailure, "%v", err) + } + return u, -1 + } + for _, name := range adds { + u, code := resolve(name) + if code >= 0 { + return code + } + if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + } + for _, name := range removes { + u, code := resolve(name) + if code >= 0 { + return code + } + if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "%s is not assigned", name) + } + return c.fail(protocol.ExitFailure, "%v", err) + } + } + updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) { + fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", ")) + }) +} @@ -37,6 +37,8 @@ func (s *Server) Routes() []Route { Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit}, Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/{n}", Handler: s.issue}, ) // Account-mode routes (login, web edits) are appended here in M8 — @@ -6,6 +6,7 @@ import ( "html/template" "net/http" "path" + "strconv" "strings" "time" @@ -364,6 +365,54 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines}) } +func (s *Server) issues(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + state := r.URL.Query().Get("state") + if state != "closed" && state != "all" { + state = "open" + } + issues, err := s.st.ListIssues(p.Repo.ID, state) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, "issues.html", struct { + repoPage + State string + Issues []store.Issue + }{p, state, issues}) +} + +func (s *Server) issue(w http.ResponseWriter, r *http.Request) { + p, ok := s.repoFor(w, r, "") + if !ok { + return + } + n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + iss, err := s.st.IssueByNumber(p.Repo.ID, n) + if err != nil { + http.NotFound(w, r) + return + } + comments, err := s.st.ListIssueComments(iss.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, "issue.html", struct { + repoPage + Issue store.Issue + Comments []store.IssueComment + }{p, iss, comments}) +} + func (s *Server) refs(w http.ResponseWriter, r *http.Request) { p, ok := s.repoFor(w, r, "") if !ok { new file mode 100644 @@ -0,0 +1,232 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +type Issue struct { + ID int64 + RepoID int64 + Number int64 + Author string + Title string + Body string + State string // open | closed + CreatedAt string + UpdatedAt string + Labels []string + Assignees []string +} + +type IssueComment struct { + Author string + Body string + CreatedAt string +} + +// CreateIssue allocates the per-repo number from the repo counter inside the +// same transaction as the insert — MAX(number)+1 races. +func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, error) { + tx, err := s.DB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + if _, err := tx.Exec("UPDATE repos SET issue_counter = issue_counter + 1 WHERE id = ?", repoID); err != nil { + return 0, err + } + var n int64 + if err := tx.QueryRow("SELECT issue_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil { + return 0, err + } + if _, err := tx.Exec( + "INSERT INTO issues (repo_id, number, author_id, title, body) VALUES (?, ?, ?, ?, ?)", + repoID, n, authorID, title, body); err != nil { + return 0, err + } + return n, tx.Commit() +} + +func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { + var i Issue + err := s.DB.QueryRow(` + SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at + FROM issues i JOIN users u ON u.id = i.author_id + WHERE i.repo_id = ? AND i.number = ?`, repoID, number). + Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return i, ErrNotFound + } + if err != nil { + return i, err + } + if i.Labels, err = s.issueStrings(i.ID, ` + SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id + WHERE il.issue_id = ? ORDER BY l.name`); err != nil { + return i, err + } + i.Assignees, err = s.issueStrings(i.ID, ` + SELECT u.username FROM issue_assignees ia JOIN users u ON u.id = ia.user_id + WHERE ia.issue_id = ? ORDER BY u.username`) + return i, err +} + +func (s *Store) issueStrings(issueID int64, query string) ([]string, error) { + rows, err := s.DB.Query(query, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// ListIssues returns issues for a repo; state is "open", "closed", or "all". +func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { + q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at + FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ?` + args := []any{repoID} + if state != "all" { + q += " AND i.state = ?" + args = append(args, state) + } + q += " ORDER BY i.number DESC" + rows, err := s.DB.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Issue + for rows.Next() { + var i Issue + if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt); err != nil { + return nil, err + } + out = append(out, i) + } + return out, rows.Err() +} + +func (s *Store) SetIssueState(issueID int64, state string) error { + res, err := s.DB.Exec( + "UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + state, issueID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) AddIssueComment(issueID, authorID int64, body string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec( + "INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)", + issueID, authorID, body); err != nil { + return err + } + if _, err := tx.Exec( + "UPDATE issues SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", issueID); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { + rows, err := s.DB.Query(` + SELECT u.username, c.body, c.created_at + FROM issue_comments c JOIN users u ON u.id = c.author_id + WHERE c.issue_id = ? ORDER BY c.id`, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []IssueComment + for rows.Next() { + var c IssueComment + if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// SetIssueLabel attaches (add) or detaches a label, creating the repo label +// on first use. +func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if add { + if _, err := tx.Exec( + "INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING", + repoID, name); err != nil { + return err + } + if _, err := tx.Exec(` + INSERT INTO issue_labels (issue_id, label_id) + SELECT ?, id FROM labels WHERE repo_id = ? AND name = ? + ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil { + return err + } + } else { + res, err := tx.Exec(` + DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN + (SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("label %q: %w", name, ErrNotFound) + } + } + return tx.Commit() +} + +// SetIssueAssignee adds or removes an assignee by user id. +func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error { + if add { + _, err := s.DB.Exec( + "INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", + issueID, userID) + return err + } + res, err := s.DB.Exec( + "DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// RecordEvent appends to the event log (the forward hook CI will consume). +func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error { + if dataJSON == "" { + dataJSON = "{}" + } + _, err := s.DB.Exec( + "INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)", + repoID, actorID, kind, dataJSON) + return err +} new file mode 100644 @@ -0,0 +1,12 @@ +{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<h2>#{{.Issue.Number}} {{.Issue.Title}} <span class="badge badge-unsigned">{{.Issue.State}}</span></h2> +<p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} +{{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} +{{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> +{{if .Issue.Body}}<pre class="message">{{.Issue.Body}}</pre>{{end}} +{{range .Comments}} +<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> +{{end}} +{{end}} new file mode 100644 @@ -0,0 +1,16 @@ +{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +{{template "repoheader" .}} +<h2>issues ({{.State}})</h2> +<p class="crumbs"><a href="?state=open">open</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p> +<table> +{{range .Issues}}<tr> + <td>#{{.Number}}</td> + <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a></td> + <td>{{.State}}</td> + <td>{{.Author}}</td> + <td>{{range .Labels}}<span class="badge badge-unsigned">{{.}}</span> {{end}}</td> +</tr> +{{else}}<tr><td>no issues</td></tr>{{end}} +</table> +{{end}}