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

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
 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(+)

diff --git a/e2e/issue_test.go b/e2e/issue_test.go
new file mode 100644
index 0000000..536929d
--- /dev/null
+++ b/e2e/issue_test.go
@@ -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)
+	}
+}
diff --git a/internal/control/control.go b/internal/control/control.go
index 46806d3..38859bd 100644
--- a/internal/control/control.go
+++ b/internal/control/control.go
@@ -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)
diff --git a/internal/control/issue.go b/internal/control/issue.go
new file mode 100644
index 0000000..7230656
--- /dev/null
+++ b/internal/control/issue.go
@@ -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, ", "))
+	})
+}
diff --git a/internal/httpd/routes.go b/internal/httpd/routes.go
index 9d08a57..952fe76 100644
--- a/internal/httpd/routes.go
+++ b/internal/httpd/routes.go
@@ -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 —
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 6bb187c..ab16215 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -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 {
diff --git a/internal/store/issues.go b/internal/store/issues.go
new file mode 100644
index 0000000..23d99c7
--- /dev/null
+++ b/internal/store/issues.go
@@ -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
+}
diff --git a/internal/web/templates/issue.html b/internal/web/templates/issue.html
new file mode 100644
index 0000000..ceb1b61
--- /dev/null
+++ b/internal/web/templates/issue.html
@@ -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}}
diff --git a/internal/web/templates/issues.html b/internal/web/templates/issues.html
new file mode 100644
index 0000000..3d7aca9
--- /dev/null
+++ b/internal/web/templates/issues.html
@@ -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}}