diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /issues.go | |
| download | git-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz | |
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.go | 555 |
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 +} |