aboutsummaryrefslogtreecommitdiff
path: root/issues.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /issues.go
downloadgit-forge-master.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'issues.go')
-rw-r--r--issues.go555
1 files changed, 555 insertions, 0 deletions
diff --git a/issues.go b/issues.go
new file mode 100644
index 0000000..6c300d1
--- /dev/null
+++ b/issues.go
@@ -0,0 +1,555 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Issues live in {repo}.issues.git, where the server is the only writer. These
+// are paths inside the git tree, not files on disk:
+//
+// issues/.next next sequential id
+// issues/{id}.json one issue
+// issues/{id}/{nnnn}.json one comment, zero padded so they sort
+type Issue struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Author string `json:"author"`
+ Labels []string `json:"labels"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+}
+
+type Comment struct {
+ Author string `json:"author"`
+ Body string `json:"body"`
+ Created time.Time `json:"created"`
+}
+
+const (
+ issueOpen = "open"
+ issueClosed = "closed"
+
+ maxTitle = 200
+ maxBody = 64 << 10
+)
+
+func (f *Forge) issuesDir(path string) string {
+ return filepath.Join(f.store.reposDir, path+".issues.git")
+}
+
+// handleIssues routes everything under {repo}/issues.
+func (f *Forge) handleIssues(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) {
+ if repo.Issues == featDisabled {
+ f.fail(w, r, http.StatusNotFound, "this repository has issues turned off")
+ return
+ }
+ // rest is issues, issues/new, issues/{id}, or issues/{id}/{action}.
+ parts := strings.Split(rest, "/")
+ switch {
+ case len(parts) == 1 && isGet(r):
+ f.issueList(w, r, path, repo)
+ case len(parts) == 1 && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.issueCreate(w, r, path, repo)
+ }
+ case len(parts) == 2 && parts[1] == "new" && isGet(r):
+ f.issueForm(w, r, path, repo)
+ case len(parts) == 2 && isGet(r):
+ f.issueShow(w, r, path, repo, parts[1])
+ case len(parts) == 3 && r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.issueAction(w, r, path, repo, parts[1], parts[2])
+ }
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ }
+}
+
+type issueListPage struct {
+ Path string
+ Issues []Issue
+ Query string
+ State string
+ Labels []string
+ Can bool // whether the viewer may open one
+}
+
+type issuePage struct {
+ Path string
+ Issue Issue
+ Rendered template.HTML
+ Comments []renderedComment
+ Labels []labelState
+ Can bool // may comment
+ Triage bool // may close, reopen, and label
+}
+
+// labelState is a repository's labels with this issue's ticked.
+type labelState struct {
+ Name string
+ On bool
+}
+
+func labelStates(repo Repo, issue Issue) []labelState {
+ on := map[string]bool{}
+ for _, label := range issue.Labels {
+ on[label] = true
+ }
+ out := make([]labelState, 0, len(repo.Labels))
+ for _, label := range repo.Labels {
+ out = append(out, labelState{Name: label, On: on[label]})
+ }
+ return out
+}
+
+type renderedComment struct {
+ Comment Comment
+ Rendered template.HTML
+}
+
+// issueList is the index, filtered by state and by git grep.
+func (f *Forge) issueList(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ dir := f.issuesDir(path)
+ state := r.URL.Query().Get("state")
+ if state != issueClosed {
+ state = issueOpen
+ }
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ // The query becomes an argument to git grep, which cannot hold a NUL.
+ if hasControlByte(query) {
+ f.fail(w, r, http.StatusBadRequest, "that search has a control byte in it")
+ return
+ }
+
+ issues, err := f.readIssues(dir, query)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ shown := []Issue{}
+ for _, issue := range issues {
+ if issue.State == state {
+ shown = append(shown, issue)
+ }
+ }
+ sort.Slice(shown, func(i, j int) bool { return shown[i].ID > shown[j].ID })
+
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "issues", repo)
+ d.Data = issueListPage{
+ Path: path, Issues: shown, Query: query, State: state,
+ Labels: repo.Labels, Can: f.viewer(r).CanIssues(repo),
+ }
+ f.render(w, "issues.html", http.StatusOK, d)
+}
+
+// readIssues loads every issue, or those a search matched; a hit in a comment
+// counts for its issue.
+func (f *Forge) readIssues(dir, query string) ([]Issue, error) {
+ head, err := resolveRef(dir, branch)
+ if err != nil || head == "" {
+ return nil, err
+ }
+
+ var want []string
+ if query == "" {
+ paths, err := listTree(dir, head, "issues/")
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range paths {
+ if strings.HasSuffix(p, ".json") {
+ want = append(want, head+":"+p)
+ }
+ }
+ } else {
+ hits, err := grepTree(dir, head, query, "issues/")
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ for _, p := range hits {
+ id, ok := issueIDFromPath(p)
+ if !ok || seen[id] {
+ continue
+ }
+ seen[id] = true
+ want = append(want, head+":issues/"+id+".json")
+ }
+ }
+
+ blobs, err := catFiles(dir, want)
+ if err != nil {
+ return nil, err
+ }
+ issues := []Issue{}
+ for i, blob := range blobs {
+ var issue Issue
+ if blob == nil || json.Unmarshal(blob, &issue) != nil {
+ // Only a hand edit or a bad restore puts one here.
+ log.Printf("issues: %q does not read as an issue, leaving it out", want[i])
+ continue
+ }
+ issues = append(issues, issue)
+ }
+ return issues, nil
+}
+
+// issueIDFromPath maps issues/7.json and issues/7/0003.json alike to 7.
+func issueIDFromPath(p string) (string, bool) {
+ rest, ok := strings.CutPrefix(p, "issues/")
+ if !ok {
+ return "", false
+ }
+ id, _, _ := strings.Cut(rest, "/")
+ id = strings.TrimSuffix(id, ".json")
+ if _, err := strconv.Atoi(id); err != nil {
+ return "", false
+ }
+ return id, true
+}
+
+func (f *Forge) issueForm(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ if !f.viewer(r).CanIssues(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not open an issue here")
+ return
+ }
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "issues", repo)
+ d.Data = issueListPage{Path: path, Labels: repo.Labels, Can: true}
+ f.render(w, "issue-new.html", http.StatusOK, d)
+}
+
+func (f *Forge) issueShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText string) {
+ dir := f.issuesDir(path)
+ head, err := resolveRef(dir, branch)
+ if err != nil || head == "" {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ id, err := strconv.Atoi(idText)
+ if err != nil || id < 1 {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ issue, err := readIssue(dir, head, id)
+ if err != nil {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+
+ paths, err := listTree(dir, head, fmt.Sprintf("issues/%d/", id))
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ sort.Strings(paths) // zero padded, so this is chronological
+ specs := make([]string, 0, len(paths))
+ for _, p := range paths {
+ specs = append(specs, head+":"+p)
+ }
+ blobs, err := catFiles(dir, specs)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ comments := []renderedComment{}
+ for i, b := range blobs {
+ var c Comment
+ if b == nil || json.Unmarshal(b, &c) != nil {
+ log.Printf("issues: %q does not read as a comment, leaving it out", specs[i])
+ continue
+ }
+ comments = append(comments, renderedComment{Comment: c, Rendered: renderMarkdown(c.Body)})
+ }
+
+ v := f.viewer(r)
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "issues", repo)
+ d.Data = issuePage{
+ Path: path, Issue: issue, Rendered: renderMarkdown(issue.Body),
+ Comments: comments, Labels: labelStates(repo, issue),
+ Can: v.CanIssues(repo), Triage: f.canTriage(v, repo, issue),
+ }
+ f.render(w, "issue.html", http.StatusOK, d)
+}
+
+// canTriage is who may close, reopen, and label: the author, and whoever may
+// push.
+func (f *Forge) canTriage(v Viewer, repo Repo, issue Issue) bool {
+ return (v.SignedIn() && v.Name == issue.Author) || v.CanWrite(repo)
+}
+
+func (f *Forge) issueCreate(w http.ResponseWriter, r *http.Request, path string, repo Repo) {
+ v := f.viewer(r)
+ if !v.CanIssues(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not open an issue here")
+ return
+ }
+ if !f.issueLimit.allow(f.peer(r), v, issuesPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many issues opened in the last hour, try again later")
+ return
+ }
+ title := strings.TrimSpace(r.PostFormValue("title"))
+ body := strings.TrimSpace(r.PostFormValue("body"))
+ if title == "" || len(title) > maxTitle || len(body) > maxBody {
+ f.fail(w, r, http.StatusBadRequest, "an issue needs a title, and neither it nor the body may be enormous")
+ return
+ }
+ // The title becomes a mail subject and a log line, both one line.
+ if hasControlByte(title) {
+ f.fail(w, r, http.StatusBadRequest, "a title is one line, with no control byte in it")
+ return
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ issue := Issue{
+ Title: title, Body: body, State: issueOpen, Author: v.Name,
+ Labels: pickLabels(r, repo), Created: now, Updated: now,
+ }
+ dir := f.issuesDir(path)
+ author, committer := f.actor(r), f.store.forgeAuthor()
+
+ // Allocated inside the retry, so two at once cannot get the same number.
+ err := commitWith(dir, author, committer, func(base string) ([]change, string, error) {
+ next := 1
+ if base != "" {
+ if data, err := catFile(dir, base+":issues/.next"); err == nil {
+ if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
+ next = n
+ }
+ }
+ }
+ issue.ID = next
+ data, err := marshalJSON(issue)
+ if err != nil {
+ return nil, "", err
+ }
+ return []change{
+ {path: "issues/.next", data: []byte(strconv.Itoa(next+1) + "\n")},
+ {path: fmt.Sprintf("issues/%d.json", next), data: data},
+ }, fmt.Sprintf("issues: open %d", next), nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ f.notifyIssue(path, repo, issue, v.Name, "opened")
+ http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", path, issue.ID), http.StatusSeeOther)
+}
+
+// issueRef is one issue as a request resolved it; every write reads it again
+// inside the retry.
+type issueRef struct {
+ dir string
+ path string
+ id int
+ issue Issue
+}
+
+// Each action answers a refusal itself, and reports whether to redirect.
+func (f *Forge) issueAction(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText, action string) {
+ ref := issueRef{dir: f.issuesDir(path), path: path}
+ id, err := strconv.Atoi(idText)
+ if err != nil || id < 1 {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ ref.id = id
+ head, err := resolveRef(ref.dir, branch)
+ if err != nil || head == "" {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+ if ref.issue, err = readIssue(ref.dir, head, id); err != nil {
+ f.fail(w, r, http.StatusNotFound, "no such issue")
+ return
+ }
+
+ done := false
+ switch action {
+ case "comment":
+ done = f.issueComment(w, r, repo, ref)
+ case "state":
+ done = f.issueState(w, r, repo, ref)
+ case "labels":
+ done = f.issueLabels(w, r, repo, ref)
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ return
+ }
+ if done {
+ http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", ref.path, ref.id), http.StatusSeeOther)
+ }
+}
+
+func (f *Forge) issueComment(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool {
+ v := f.viewer(r)
+ if !v.CanIssues(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not comment here")
+ return false
+ }
+ if !f.commentLimit.allow(f.peer(r), v, commentsPerHour) {
+ f.fail(w, r, http.StatusTooManyRequests, "too many comments in the last hour, try again later")
+ return false
+ }
+ body := strings.TrimSpace(r.PostFormValue("body"))
+ if body == "" || len(body) > maxBody {
+ f.fail(w, r, http.StatusBadRequest, "a comment needs a body, and it may not be enormous")
+ return false
+ }
+ comment := Comment{Author: v.Name, Body: body, Created: time.Now().UTC().Truncate(time.Second)}
+ data, err := marshalJSON(comment)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+
+ var updated Issue
+ err = commitWith(ref.dir, f.actor(r), f.store.forgeAuthor(), func(base string) ([]change, string, error) {
+ // The sequence number and timestamp come from the tree as it is now.
+ issue, err := readIssue(ref.dir, base, ref.id)
+ if err != nil {
+ return nil, "", err
+ }
+ seq, err := nextComment(ref.dir, base, ref.id)
+ if err != nil {
+ return nil, "", err
+ }
+ issue.Updated = comment.Created
+ meta, err := marshalJSON(issue)
+ if err != nil {
+ return nil, "", err
+ }
+ updated = issue
+ return []change{
+ {path: fmt.Sprintf("issues/%d/%04d.json", ref.id, seq), data: data},
+ {path: issuePath(ref.id), data: meta},
+ }, fmt.Sprintf("issues: comment on %d", ref.id), nil
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+ f.notifyIssue(ref.path, repo, updated, v.Name, "commented on")
+ return true
+}
+
+func (f *Forge) issueState(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool {
+ v := f.viewer(r)
+ if !f.canTriage(v, repo, ref.issue) {
+ f.fail(w, r, http.StatusForbidden, "you may not close or reopen this issue")
+ return false
+ }
+ // Decided against the tree, so two people pressing at once cannot both
+ // close it.
+ var verb string
+ issue, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string {
+ if i.State == issueClosed {
+ i.State, verb = issueOpen, "reopened"
+ return fmt.Sprintf("issues: reopen %d", ref.id)
+ }
+ i.State, verb = issueClosed, "closed"
+ return fmt.Sprintf("issues: close %d", ref.id)
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+ f.notifyIssue(ref.path, repo, issue, v.Name, verb)
+ return true
+}
+
+func (f *Forge) issueLabels(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool {
+ if !f.canTriage(f.viewer(r), repo, ref.issue) {
+ f.fail(w, r, http.StatusForbidden, "you may not label this issue")
+ return false
+ }
+ labels := pickLabels(r, repo)
+ _, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string {
+ i.Labels = labels
+ return fmt.Sprintf("issues: label %d", ref.id)
+ })
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return false
+ }
+ return true
+}
+
+func readIssue(dir, commit string, id int) (Issue, error) {
+ var issue Issue
+ blob, err := catFile(dir, commit+":"+issuePath(id))
+ if err != nil {
+ return issue, err
+ }
+ return issue, json.Unmarshal(blob, &issue)
+}
+
+func issuePath(id int) string { return fmt.Sprintf("issues/%d.json", id) }
+
+// updateIssue reads the issue again on every attempt, or the second writer
+// would commit a record built from an overtaken read. apply returns the
+// subject, known only after the read.
+func (f *Forge) updateIssue(dir string, id int, author, committer Author, apply func(*Issue) string) (Issue, error) {
+ var final Issue
+ err := commitWith(dir, author, committer, func(base string) ([]change, string, error) {
+ issue, err := readIssue(dir, base, id)
+ if err != nil {
+ return nil, "", err
+ }
+ subject := apply(&issue)
+ issue.Updated = time.Now().UTC().Truncate(time.Second)
+ data, err := marshalJSON(issue)
+ if err != nil {
+ return nil, "", err
+ }
+ final = issue
+ return []change{{path: issuePath(id), data: data}}, subject, nil
+ })
+ return final, err
+}
+
+func nextComment(dir, base string, id int) (int, error) {
+ if base == "" {
+ return 1, nil
+ }
+ paths, err := listTree(dir, base, fmt.Sprintf("issues/%d/", id))
+ if err != nil {
+ return 0, err
+ }
+ next := 1
+ for _, p := range paths {
+ seq, err := strconv.Atoi(strings.TrimSuffix(filepath.Base(p), ".json"))
+ if err == nil && seq >= next {
+ next = seq + 1
+ }
+ }
+ return next, nil
+}
+
+// pickLabels keeps only labels the repository defines.
+func pickLabels(r *http.Request, repo Repo) []string {
+ chosen := map[string]bool{}
+ for _, label := range r.PostForm["label"] {
+ chosen[label] = true
+ }
+ out := []string{}
+ for _, label := range repo.Labels {
+ if chosen[label] {
+ out = append(out, label)
+ }
+ }
+ return out
+}