~gitpocket/gitpocket

1

feat(basin): pr reviews, labels and branch protection

commit
3904633224f70281a82799fa2727141a04ae1eb4 browse tree
parent
7db58612d2bb8f4814c5b26fe7970e0cb12a451f
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-03 18:05 UTC

19 files changed, +836 −18

edit.go added

@@ -0,0 +1,257 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "html/template"
8+ "net/http"
9+ "os"
10+ "path"
11+ "path/filepath"
12+ "strings"
13+)
14+
15+func installHook(root, dir string) {
16+ binary, err := os.Executable()
17+ if err != nil {
18+ return
19+ }
20+ hooks := filepath.Join(dir, "hooks")
21+ os.MkdirAll(hooks, 0o755)
22+ script := fmt.Sprintf("#!/bin/sh\nd=$(cd \"$(dirname \"$0\")/..\" && pwd)\nexec %s -root %s hook pre-receive \"$(basename \"$(dirname \"$d\")\")|$(basename \"$d\")\"\n", binary, root)
23+ os.WriteFile(filepath.Join(hooks, "pre-receive"), []byte(script), 0o755)
24+}
25+
26+func (s *Server) installHooks() {
27+ owners, err := os.ReadDir(filepath.Join(s.root, "repos"))
28+ if err != nil {
29+ return
30+ }
31+ for _, o := range owners {
32+ repos, _ := os.ReadDir(filepath.Join(s.root, "repos", o.Name()))
33+ for _, r := range repos {
34+ if strings.HasSuffix(r.Name(), ".git") {
35+ installHook(s.root, filepath.Join(s.root, "repos", o.Name(), r.Name()))
36+ }
37+ }
38+ }
39+}
40+
41+func hook(root, kind, spec string) int {
42+ if kind != "pre-receive" {
43+ return 0
44+ }
45+ owner, repoDir, ok := strings.Cut(spec, "|")
46+ if !ok {
47+ return 0
48+ }
49+ name := strings.TrimSuffix(repoDir, ".git")
50+ store, err := openStore(filepath.Join(root, "basin.db"), true)
51+ if err != nil {
52+ return 0
53+ }
54+ repo, err := store.RepoByPath(owner, name)
55+ if err != nil {
56+ return 0
57+ }
58+ for _, kv := range os.Environ() {
59+ if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") {
60+ passEnv = append(passEnv, kv)
61+ }
62+ }
63+ g := Git{Dir: filepath.Join(root, "repos", owner, repoDir)}
64+ zero := strings.Repeat("0", 40)
65+ scanner := bufio.NewScanner(os.Stdin)
66+ for scanner.Scan() {
67+ f := strings.Fields(scanner.Text())
68+ if len(f) != 3 || !strings.HasPrefix(f[2], "refs/heads/") {
69+ continue
70+ }
71+ branch := strings.TrimPrefix(f[2], "refs/heads/")
72+ prot, ok := store.Protection(repo.ID, branch)
73+ if !ok {
74+ continue
75+ }
76+ if f[1] == zero {
77+ fmt.Fprintf(os.Stderr, "basin: %s is protected and cannot be deleted\n", branch)
78+ return 1
79+ }
80+ if prot.RequirePR && f[0] != zero {
81+ fmt.Fprintf(os.Stderr, "basin: %s only accepts changes through pull requests\n", branch)
82+ return 1
83+ }
84+ if f[0] != zero {
85+ if _, err := g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil {
86+ fmt.Fprintf(os.Stderr, "basin: %s is protected, force pushes are refused\n", branch)
87+ return 1
88+ }
89+ }
90+ }
91+ return 0
92+}
93+
94+func (g Git) Commit1(branch, parent, filePath string, content []byte, remove bool, message, name, email string) (string, error) {
95+ index, err := os.CreateTemp("", "basin-index")
96+ if err != nil {
97+ return "", err
98+ }
99+ index.Close()
100+ defer os.Remove(index.Name())
101+ env := []string{"GIT_INDEX_FILE=" + index.Name()}
102+ if _, err := g.runEnv(env, nil, "read-tree", parent); err != nil {
103+ return "", err
104+ }
105+ if remove {
106+ if _, err := g.runEnv(env, []byte("0 "+strings.Repeat("0", 40)+"\t"+filePath+"\n"), "update-index", "--index-info"); err != nil {
107+ return "", err
108+ }
109+ } else {
110+ blob, err := g.run(content, "hash-object", "-w", "--stdin")
111+ if err != nil {
112+ return "", err
113+ }
114+ if _, err := g.runEnv(env, nil, "update-index", "--add", "--cacheinfo", "100644,"+strings.TrimSpace(string(blob))+","+filePath); err != nil {
115+ return "", err
116+ }
117+ }
118+ tree, err := g.runEnv(env, nil, "write-tree")
119+ if err != nil {
120+ return "", err
121+ }
122+ commit, err := g.runEnv(ident(name, email, timeNow()), []byte(message), "commit-tree", strings.TrimSpace(string(tree)), "-p", parent)
123+ if err != nil {
124+ return "", err
125+ }
126+ hash := strings.TrimSpace(string(commit))
127+ if _, err := g.run(nil, "update-ref", "refs/heads/"+branch, hash, parent); err != nil {
128+ return "", errors.New("the branch moved while you were editing, reload and try again")
129+ }
130+ return hash, nil
131+}
132+
133+func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo, branch string) (repoPage, Git, bool) {
134+ rp, g, err := s.repoPage(r, repo, branch)
135+ if err != nil {
136+ s.fail(w, r, 404, "No such branch")
137+ return rp, g, false
138+ }
139+ if rp.User == nil {
140+ http.Redirect(w, r, "/login", http.StatusSeeOther)
141+ return rp, g, false
142+ }
143+ if !rp.Owner {
144+ s.fail(w, r, 403, "You cannot write to this repository")
145+ return rp, g, false
146+ }
147+ if repo.Archived {
148+ s.fail(w, r, 403, "This repository is archived")
149+ return rp, g, false
150+ }
151+ if _, err := g.Resolve("refs/heads/" + branch); err != nil {
152+ s.fail(w, r, 404, "Edits need a branch, not a tag or commit")
153+ return rp, g, false
154+ }
155+ if prot, ok := s.store.Protection(repo.ID, branch); ok && prot.RequirePR {
156+ s.fail(w, r, 403, branch+" only accepts changes through pull requests")
157+ return rp, g, false
158+ }
159+ rp.Tab = "tree"
160+ return rp, g, true
161+}
162+
163+func (s *Server) edit(w http.ResponseWriter, r *http.Request, repo Repo, spec string, create bool) {
164+ g := Git{Dir: s.repoDir(repo)}
165+ branch, filePath, ok := s.splitRef(g, spec)
166+ if !ok {
167+ s.fail(w, r, 404, "No such branch")
168+ return
169+ }
170+ filePath, ok = cleanPath(filePath)
171+ if !ok {
172+ s.fail(w, r, 404, "No such path")
173+ return
174+ }
175+ rp, g, ok := s.editAllowed(w, r, repo, branch)
176+ if !ok {
177+ return
178+ }
179+ data := map[string]any{"Page": rp, "Path": filePath, "Create": create, "Dir": filePath}
180+ if r.Method == "GET" {
181+ if !create {
182+ if g.Kind(rp.Commit, filePath) != "blob" {
183+ s.fail(w, r, 404, "No such file")
184+ return
185+ }
186+ content, err := g.Blob(rp.Commit, filePath)
187+ if err != nil || isBinary(content) || len(content) > maxRender {
188+ s.fail(w, r, 400, "Only text files under 4 MiB can be edited here")
189+ return
190+ }
191+ data["Content"] = string(content)
192+ data["Message"] = "Update " + path.Base(filePath)
193+ } else {
194+ data["Message"] = "Create "
195+ }
196+ s.render(w, r, "edit", page{Title: "Edit " + filePath, Data: data})
197+ return
198+ }
199+ target := filePath
200+ if create {
201+ name, ok := cleanPath(strings.TrimSpace(r.FormValue("name")))
202+ if !ok || name == "" {
203+ s.fail(w, r, 400, "Give the new file a name")
204+ return
205+ }
206+ target = path.Join(filePath, name)
207+ if g.Kind(rp.Commit, target) != "" {
208+ s.fail(w, r, 400, target+" already exists")
209+ return
210+ }
211+ }
212+ content := strings.ReplaceAll(r.FormValue("content"), "\r\n", "\n")
213+ message := strings.TrimSpace(r.FormValue("message"))
214+ if message == "" {
215+ message = "Update " + path.Base(target)
216+ }
217+ hash, err := g.Commit1(branch, rp.Commit, target, []byte(content), false, message, rp.User.Name, rp.User.Name+"@"+s.host)
218+ if err != nil {
219+ data["Content"], data["Message"], data["Name"] = r.FormValue("content"), message, r.FormValue("name")
220+ w.WriteHeader(400)
221+ s.render(w, r, "edit", page{Title: "Edit " + filePath, Error: err.Error(), Data: data})
222+ return
223+ }
224+ _ = hash
225+ http.Redirect(w, r, "/"+repo.Path()+"/tree/"+branch+"/"+target, http.StatusSeeOther)
226+}
227+
228+func (s *Server) removeFile(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
229+ g := Git{Dir: s.repoDir(repo)}
230+ branch, filePath, ok := s.splitRef(g, spec)
231+ if !ok {
232+ s.fail(w, r, 404, "No such branch")
233+ return
234+ }
235+ filePath, ok = cleanPath(filePath)
236+ if !ok || filePath == "" {
237+ s.fail(w, r, 404, "No such path")
238+ return
239+ }
240+ rp, g, ok := s.editAllowed(w, r, repo, branch)
241+ if !ok {
242+ return
243+ }
244+ if g.Kind(rp.Commit, filePath) != "blob" {
245+ s.fail(w, r, 404, "No such file")
246+ return
247+ }
248+ if _, err := g.Commit1(branch, rp.Commit, filePath, nil, true, "Delete "+path.Base(filePath), rp.User.Name, rp.User.Name+"@"+s.host); err != nil {
249+ s.fail(w, r, 400, template.HTMLEscapeString(err.Error()))
250+ return
251+ }
252+ dir := path.Dir(filePath)
253+ if dir == "." {
254+ dir = ""
255+ }
256+ http.Redirect(w, r, "/"+repo.Path()+"/tree/"+branch+"/"+dir, http.StatusSeeOther)
257+}

git.go modified

@@ -36,6 +36,8 @@ var signersFile string
3636
3737 var gnupgHome string
3838
39+var passEnv []string
40+
3941 type Entry struct {
4042 Mode string
4143 Type string
@@ -82,7 +84,8 @@ func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error)
8284 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
8385 defer cancel()
8486 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...)
85- cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8", "PATH=" + os.Getenv("PATH")}, env...)
87+ cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8", "PATH=" + os.Getenv("PATH")}, passEnv...)
88+ cmd.Env = append(cmd.Env, env...)
8689 if signersFile != "" {
8790 cmd.Env = append(cmd.Env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile", "GIT_CONFIG_VALUE_0="+signersFile)
8891 }
@@ -166,6 +169,8 @@ func (g Git) mergeTree(base, ours, theirs string) (string, error) {
166169 return tree, nil
167170 }
168171
172+func timeNow() time.Time { return time.Now() }
173+
169174 func ident(name, email string, when time.Time) []string {
170175 stamp := strconv.FormatInt(when.Unix(), 10) + " +0000"
171176 return []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_AUTHOR_DATE=" + stamp, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email, "GIT_COMMITTER_DATE=" + stamp}

issues.go modified

@@ -145,7 +145,23 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request, repo Repo) {
145145 s.fail(w, r, 500, err.Error())
146146 return
147147 }
148- s.render(w, r, "issues", page{Title: repo.Path() + " issues", Data: map[string]any{"Page": rp, "Issues": issues, "State": r.URL.Query().Get("state")}})
148+ labels, _ := s.store.Labels(repo.ID)
149+ filter, _ := strconv.ParseInt(r.URL.Query().Get("label"), 10, 64)
150+ if filter != 0 {
151+ keep := s.store.ItemsWithLabel("issue", filter)
152+ var filtered []Issue
153+ for _, i := range issues {
154+ if keep[i.ID] {
155+ filtered = append(filtered, i)
156+ }
157+ }
158+ issues = filtered
159+ }
160+ itemLabels := map[int64][]Label{}
161+ for _, i := range issues {
162+ itemLabels[i.ID] = s.store.ItemLabels("issue", i.ID)
163+ }
164+ s.render(w, r, "issues", page{Title: repo.Path() + " issues", Data: map[string]any{"Page": rp, "Issues": issues, "State": r.URL.Query().Get("state"), "Labels": labels, "Filter": filter, "ItemLabels": itemLabels}})
149165 }
150166
151167 func (s *Server) newIssue(w http.ResponseWriter, r *http.Request, repo Repo) {
@@ -222,6 +238,9 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
222238 s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", user.Name, issue.Number, r.FormValue("body"), link))
223239 }
224240 }
241+ case "meta":
242+ s.setMeta(w, r, repo, "issue", issue.ID, back)
243+ return
225244 case "close":
226245 if canManage {
227246 s.store.SetIssueState(issue.ID, "closed")
@@ -244,5 +263,16 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
244263 }
245264 body := s.renderMarkdown([]byte(issue.Body))
246265 s.render(w, r, "issue", page{Title: fmt.Sprintf("#%d %s", issue.Number, issue.Title), Error: r.URL.Query().Get("error"), Math: hasMath(body),
247- Data: map[string]any{"Page": rp, "Issue": issue, "Body": body, "Comments": rendered, "CanManage": canManage}})
266+ Data: map[string]any{"Page": rp, "Issue": issue, "Body": body, "Comments": rendered, "CanManage": canManage, "Labels": s.store.ItemLabels("issue", issue.ID), "Assignees": s.store.Assignees("issue", issue.ID), "AllLabels": func() []Label {
267+ if !rp.Owner {
268+ return nil
269+ }
270+ l, _ := s.store.Labels(repo.ID)
271+ return l
272+ }(), "Assignable": func() []User {
273+ if !rp.Owner {
274+ return nil
275+ }
276+ return s.store.Assignable(repo)
277+ }()}})
248278 }

labels.go added

@@ -0,0 +1,237 @@
1+package main
2+
3+import (
4+ "errors"
5+ "html/template"
6+ "net/http"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+)
11+
12+type Label struct {
13+ ID int64
14+ Name string
15+ Colour string
16+}
17+
18+var colourRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
19+
20+func (s *Store) Labels(repo int64) ([]Label, error) {
21+ rows, err := s.db.Query(`select id, name, colour from labels where repo = ? order by name`, repo)
22+ if err != nil {
23+ return nil, err
24+ }
25+ defer rows.Close()
26+ var out []Label
27+ for rows.Next() {
28+ var l Label
29+ if err := rows.Scan(&l.ID, &l.Name, &l.Colour); err != nil {
30+ return nil, err
31+ }
32+ out = append(out, l)
33+ }
34+ return out, rows.Err()
35+}
36+
37+func (s *Store) AddLabel(repo int64, name, colour string) error {
38+ name = strings.TrimSpace(name)
39+ if name == "" || len(name) > 40 {
40+ return errors.New("Label names are 1 to 40 characters")
41+ }
42+ if !colourRe.MatchString(colour) {
43+ colour = "#6c757d"
44+ }
45+ _, err := s.db.Exec(`insert into labels(repo, name, colour) values (?, ?, ?)`, repo, name, strings.ToLower(colour))
46+ if err != nil && strings.Contains(err.Error(), "UNIQUE") {
47+ return errors.New("That label already exists")
48+ }
49+ return err
50+}
51+
52+func (s *Store) DeleteLabel(repo, id int64) {
53+ s.db.Exec(`delete from labels where repo = ? and id = ?`, repo, id)
54+}
55+
56+func (s *Store) ItemLabels(kind string, item int64) []Label {
57+ rows, err := s.db.Query(`select l.id, l.name, l.colour from labelled x join labels l on l.id = x.label where x.kind = ? and x.item = ? order by l.name`, kind, item)
58+ if err != nil {
59+ return nil
60+ }
61+ defer rows.Close()
62+ var out []Label
63+ for rows.Next() {
64+ var l Label
65+ rows.Scan(&l.ID, &l.Name, &l.Colour)
66+ out = append(out, l)
67+ }
68+ return out
69+}
70+
71+func (s *Store) SetItemLabels(kind string, item int64, repo int64, ids []int64) {
72+ s.db.Exec(`delete from labelled where kind = ? and item = ?`, kind, item)
73+ for _, id := range ids {
74+ s.db.Exec(`insert or ignore into labelled(kind, item, label) select ?, ?, id from labels where id = ? and repo = ?`, kind, item, id, repo)
75+ }
76+}
77+
78+func (s *Store) Assignees(kind string, item int64) []User {
79+ rows, err := s.db.Query(`select u.id, u.name from assigned a join users u on u.id = a.user where a.kind = ? and a.item = ? order by u.name`, kind, item)
80+ if err != nil {
81+ return nil
82+ }
83+ defer rows.Close()
84+ var out []User
85+ for rows.Next() {
86+ var u User
87+ rows.Scan(&u.ID, &u.Name)
88+ out = append(out, u)
89+ }
90+ return out
91+}
92+
93+func (s *Store) SetAssignees(kind string, item int64, ids []int64) {
94+ s.db.Exec(`delete from assigned where kind = ? and item = ?`, kind, item)
95+ for _, id := range ids {
96+ s.db.Exec(`insert or ignore into assigned(kind, item, user) values (?, ?, ?)`, kind, item, id)
97+ }
98+}
99+
100+func (s *Store) Assignable(repo Repo) []User {
101+ users := []User{repo.Owner}
102+ collabs, _ := s.Collaborators(repo.ID)
103+ return append(users, collabs...)
104+}
105+
106+func (s *Store) ItemsWithLabel(kind string, label int64) map[int64]bool {
107+ out := map[int64]bool{}
108+ rows, err := s.db.Query(`select item from labelled where kind = ? and label = ?`, kind, label)
109+ if err != nil {
110+ return out
111+ }
112+ defer rows.Close()
113+ for rows.Next() {
114+ var id int64
115+ rows.Scan(&id)
116+ out[id] = true
117+ }
118+ return out
119+}
120+
121+type Protection struct {
122+ Branch string
123+ RequirePR bool
124+ Approvals int
125+}
126+
127+func (s *Store) Protections(repo int64) []Protection {
128+ rows, err := s.db.Query(`select branch, require_pr, approvals from protected where repo = ? order by branch`, repo)
129+ if err != nil {
130+ return nil
131+ }
132+ defer rows.Close()
133+ var out []Protection
134+ for rows.Next() {
135+ var p Protection
136+ var rp int
137+ rows.Scan(&p.Branch, &rp, &p.Approvals)
138+ p.RequirePR = rp != 0
139+ out = append(out, p)
140+ }
141+ return out
142+}
143+
144+func (s *Store) Protection(repo int64, branch string) (Protection, bool) {
145+ var p Protection
146+ var rp int
147+ err := s.db.QueryRow(`select branch, require_pr, approvals from protected where repo = ? and branch = ?`, repo, branch).Scan(&p.Branch, &rp, &p.Approvals)
148+ p.RequirePR = rp != 0
149+ return p, err == nil
150+}
151+
152+func (s *Store) SetProtection(repo int64, branch string, requirePR bool, approvals int) error {
153+ branch = strings.TrimSpace(branch)
154+ if branch == "" || strings.HasPrefix(branch, "-") || strings.Contains(branch, " ") {
155+ return errors.New("Give a branch name")
156+ }
157+ if approvals < 0 || approvals > 10 {
158+ approvals = 0
159+ }
160+ _, err := s.db.Exec(`insert into protected(repo, branch, require_pr, approvals) values (?, ?, ?, ?) on conflict(repo, branch) do update set require_pr = excluded.require_pr, approvals = excluded.approvals`, repo, branch, requirePR, approvals)
161+ return err
162+}
163+
164+func (s *Store) DeleteProtection(repo int64, branch string) {
165+ s.db.Exec(`delete from protected where repo = ? and branch = ?`, repo, branch)
166+}
167+
168+func parseIDs(values []string) []int64 {
169+ var out []int64
170+ for _, v := range values {
171+ if id, err := strconv.ParseInt(v, 10, 64); err == nil {
172+ out = append(out, id)
173+ }
174+ }
175+ return out
176+}
177+
178+func (s *Server) manageLabels(w http.ResponseWriter, r *http.Request, repo Repo) {
179+ rp, _, _ := s.repoPage(r, repo, "")
180+ if !rp.IsOwner {
181+ s.fail(w, r, 403, "Not your repository")
182+ return
183+ }
184+ back := "/" + repo.Path() + "/settings"
185+ if id, err := strconv.ParseInt(r.FormValue("remove"), 10, 64); err == nil {
186+ s.store.DeleteLabel(repo.ID, id)
187+ } else if err := s.store.AddLabel(repo.ID, r.FormValue("name"), r.FormValue("colour")); err != nil {
188+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
189+ return
190+ }
191+ http.Redirect(w, r, back+"#labels", http.StatusSeeOther)
192+}
193+
194+func (s *Server) manageProtection(w http.ResponseWriter, r *http.Request, repo Repo) {
195+ rp, _, _ := s.repoPage(r, repo, "")
196+ if !rp.IsOwner {
197+ s.fail(w, r, 403, "Not your repository")
198+ return
199+ }
200+ back := "/" + repo.Path() + "/settings"
201+ if branch := r.FormValue("remove"); branch != "" {
202+ s.store.DeleteProtection(repo.ID, branch)
203+ } else {
204+ approvals, _ := strconv.Atoi(r.FormValue("approvals"))
205+ if err := s.store.SetProtection(repo.ID, r.FormValue("branch"), r.FormValue("require_pr") == "on", approvals); err != nil {
206+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
207+ return
208+ }
209+ }
210+ http.Redirect(w, r, back+"#protection", http.StatusSeeOther)
211+}
212+
213+func (s *Server) setMeta(w http.ResponseWriter, r *http.Request, repo Repo, kind string, item int64, back string) {
214+ rp, _, _ := s.repoPage(r, repo, "")
215+ if !rp.Owner {
216+ s.fail(w, r, 403, "Only collaborators can change labels and assignees")
217+ return
218+ }
219+ r.ParseForm()
220+ if _, ok := r.Form["labels_form"]; ok {
221+ s.store.SetItemLabels(kind, item, repo.ID, parseIDs(r.Form["label"]))
222+ }
223+ if _, ok := r.Form["assignees_form"]; ok {
224+ allowed := map[int64]bool{}
225+ for _, u := range s.store.Assignable(repo) {
226+ allowed[u.ID] = true
227+ }
228+ var ids []int64
229+ for _, id := range parseIDs(r.Form["assignee"]) {
230+ if allowed[id] {
231+ ids = append(ids, id)
232+ }
233+ }
234+ s.store.SetAssignees(kind, item, ids)
235+ }
236+ http.Redirect(w, r, back, http.StatusSeeOther)
237+}

main.go modified

@@ -38,6 +38,11 @@ func main() {
3838 case "keys":
3939 binary, _ := os.Executable()
4040 os.Exit(keys(*root, binary, args[1:]))
41+ case "hook":
42+ if len(args) != 3 {
43+ os.Exit(2)
44+ }
45+ os.Exit(hook(*root, args[1], args[2]))
4146 case "shell":
4247 if len(args) != 2 {
4348 os.Exit(2)

pull.go modified

@@ -65,6 +65,74 @@ type Pull struct {
6565 Updated time.Time
6666 Merged string
6767 Comments int
68+ Draft bool
69+}
70+
71+type PRReview struct {
72+ ID int64
73+ Author User
74+ State string
75+ Body string
76+ Head string
77+ Created time.Time
78+ HTML template.HTML
79+}
80+
81+func (s *Store) AddPRReview(pull int64, author User, state, body, head string) error {
82+ switch state {
83+ case "approved", "changes", "comment":
84+ default:
85+ return errors.New("Pick a review outcome")
86+ }
87+ body = strings.TrimSpace(body)
88+ if state == "comment" && body == "" {
89+ return errors.New("A comment review needs some text")
90+ }
91+ if len(body) > 20000 {
92+ return errors.New("Review is too long")
93+ }
94+ _, err := s.db.Exec(`insert into prreviews(pull, author, state, body, head, created) values (?, ?, ?, ?, ?, ?)`, pull, author.ID, state, body, head, time.Now().Unix())
95+ s.TouchPull(pull)
96+ return err
97+}
98+
99+func (s *Store) PRReviews(pull int64) []PRReview {
100+ rows, err := s.db.Query(`select r.id, r.state, r.body, r.head, r.created, u.id, u.name from prreviews r join users u on u.id = r.author where r.pull = ? order by r.id`, pull)
101+ if err != nil {
102+ return nil
103+ }
104+ defer rows.Close()
105+ var out []PRReview
106+ for rows.Next() {
107+ var r PRReview
108+ var created int64
109+ rows.Scan(&r.ID, &r.State, &r.Body, &r.Head, &created, &r.Author.ID, &r.Author.Name)
110+ r.Created = time.Unix(created, 0)
111+ out = append(out, r)
112+ }
113+ return out
114+}
115+
116+func latestVerdicts(reviews []PRReview, head string) (approvals int, changes int) {
117+ latest := map[int64]string{}
118+ for _, r := range reviews {
119+ if r.State == "comment" || r.Head != head {
120+ continue
121+ }
122+ latest[r.Author.ID] = r.State
123+ }
124+ for _, st := range latest {
125+ if st == "approved" {
126+ approvals++
127+ } else {
128+ changes++
129+ }
130+ }
131+ return
132+}
133+
134+func (s *Store) SetDraft(id int64, draft bool) {
135+ s.db.Exec(`update pulls set draft = ?, updated = ? where id = ?`, draft, time.Now().Unix(), id)
68136 }
69137
70138 type Review struct {
@@ -132,9 +200,11 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) {
132200 var p Pull
133201 var created, updated, aCreated int64
134202 var sourceID int64
135- if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &p.Author.ID, &p.Author.Name, &aCreated); err != nil {
203+ var draft int
204+ if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &draft, &p.Author.ID, &p.Author.Name, &aCreated); err != nil {
136205 return nil, err
137206 }
207+ p.Draft = draft != 0
138208 p.Created, p.Updated, p.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(aCreated, 0)
139209 p.SourceRepo.ID = sourceID
140210 pulls = append(pulls, p)
@@ -143,7 +213,7 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) {
143213 }
144214
145215 const pullColumns = `p.id, p.number, p.title, p.body, p.source_repo, p.source_branch, p.target_branch, p.state, p.created, p.updated, p.merged,
146- (select count(*) from comments c where c.pull = p.id), u.id, u.name, u.created from pulls p join users u on u.id = p.author`
216+ (select count(*) from comments c where c.pull = p.id), p.draft, u.id, u.name, u.created from pulls p join users u on u.id = p.author`
147217
148218 func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) {
149219 rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and (? = '' or p.state = ?) order by p.number desc`, repo.ID, state, state)
@@ -325,7 +395,23 @@ func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) {
325395 s.fail(w, r, 500, err.Error())
326396 return
327397 }
328- s.render(w, r, "pulls", page{Title: repo.Path() + " pull requests", Data: map[string]any{"Page": rp, "Pulls": pulls, "State": r.URL.Query().Get("state")}})
398+ labels, _ := s.store.Labels(repo.ID)
399+ filter, _ := strconv.ParseInt(r.URL.Query().Get("label"), 10, 64)
400+ if filter != 0 {
401+ keep := s.store.ItemsWithLabel("pull", filter)
402+ var filtered []Pull
403+ for _, p := range pulls {
404+ if keep[p.ID] {
405+ filtered = append(filtered, p)
406+ }
407+ }
408+ pulls = filtered
409+ }
410+ itemLabels := map[int64][]Label{}
411+ for _, p := range pulls {
412+ itemLabels[p.ID] = s.store.ItemLabels("pull", p.ID)
413+ }
414+ s.render(w, r, "pulls", page{Title: repo.Path() + " pull requests", Data: map[string]any{"Page": rp, "Pulls": pulls, "State": r.URL.Query().Get("state"), "Labels": labels, "Filter": filter, "ItemLabels": itemLabels}})
329415 }
330416
331417 type sourceOption struct {
@@ -432,6 +518,9 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
432518 retry(err.Error())
433519 return
434520 }
521+ if r.FormValue("draft") == "on" {
522+ s.store.SetDraft(pull.ID, true)
523+ }
435524 if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil {
436525 retry("Could not fetch the source branch: " + err.Error())
437526 return
@@ -499,6 +588,30 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
499588 }
500589 back += "#r-" + r.FormValue("path") + ":" + r.FormValue("side") + ":" + r.FormValue("line")
501590 s.notifyPull(pull, *user, "reviewed "+r.FormValue("path")+" line "+r.FormValue("line")+" on", r.FormValue("body"))
591+ case "verdict":
592+ if user.ID == pull.Author.ID && r.FormValue("state") != "comment" {
593+ http.Redirect(w, r, back+"?error=You+cannot+approve+your+own+pull+request", http.StatusSeeOther)
594+ return
595+ }
596+ head, _ := g.Resolve(pull.Ref())
597+ if err := s.store.AddPRReview(pull.ID, *user, r.FormValue("state"), r.FormValue("body"), head); err != nil {
598+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
599+ return
600+ }
601+ verb := map[string]string{"approved": "approved", "changes": "requested changes on", "comment": "reviewed"}[r.FormValue("state")]
602+ s.notifyPull(pull, *user, verb, r.FormValue("body"))
603+ back += "#reviews"
604+ case "ready":
605+ if isOwner || isAuthor {
606+ s.store.SetDraft(pull.ID, false)
607+ }
608+ case "draft":
609+ if isOwner || isAuthor {
610+ s.store.SetDraft(pull.ID, true)
611+ }
612+ case "meta":
613+ s.setMeta(w, r, repo, "pull", pull.ID, back)
614+ return
502615 case "resolve", "unresolve", "delete":
503616 id, _ := strconv.ParseInt(r.FormValue("review"), 10, 64)
504617 owner := s.store.ReviewAuthor(id)
@@ -528,6 +641,18 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
528641 s.fail(w, r, 403, "Only the repository owner can merge")
529642 return
530643 }
644+ if pull.Draft {
645+ http.Redirect(w, r, back+"?error=Draft+pull+requests+cannot+be+merged", http.StatusSeeOther)
646+ return
647+ }
648+ if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok && prot.Approvals > 0 {
649+ head, _ := g.Resolve(pull.Ref())
650+ approvals, changes := latestVerdicts(s.store.PRReviews(pull.ID), head)
651+ if approvals < prot.Approvals || changes > 0 {
652+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(fmt.Sprintf("%s needs %d approving reviews of the current head and no outstanding change requests", pull.TargetBranch, prot.Approvals)), http.StatusSeeOther)
653+ return
654+ }
655+ }
531656 title := strings.TrimSpace(r.FormValue("title"))
532657 if title == "" {
533658 title = mergeTitle(pull)
@@ -565,7 +690,21 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
565690 return
566691 }
567692 data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor,
568- "Body": s.renderMarkdown([]byte(pull.Body))}
693+ "Body": s.renderMarkdown([]byte(pull.Body)), "Labels": s.store.ItemLabels("pull", pull.ID), "AllLabels": nil, "Assignees": s.store.Assignees("pull", pull.ID)}
694+ if isOwner {
695+ data["AllLabels"], _ = s.store.Labels(repo.ID)
696+ data["Assignable"] = s.store.Assignable(repo)
697+ }
698+ reviews := s.store.PRReviews(pull.ID)
699+ for i := range reviews {
700+ reviews[i].HTML = s.renderMarkdown([]byte(reviews[i].Body))
701+ }
702+ data["Reviews"] = reviews
703+ approvals, changes := latestVerdicts(reviews, head)
704+ data["Approvals"], data["Changes"] = approvals, changes
705+ if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok {
706+ data["Protection"] = prot
707+ }
569708 base := pull.TargetBranch
570709 if pull.State == "merged" && pull.Merged != "" {
571710 base = pull.Merged + "^1"
@@ -618,6 +757,13 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
618757 if cur, err := g.Resolve("refs/heads/" + pull.TargetBranch); err == nil && cur == head {
619758 data["Mergeable"] = false
620759 }
760+ if pull.Draft {
761+ data["Mergeable"] = false
762+ }
763+ if prot, ok := data["Protection"].(Protection); ok && prot.Approvals > 0 && (approvals < prot.Approvals || changes > 0) {
764+ data["Blocked"] = fmt.Sprintf("needs %d approving reviews of the current head and no change requests", prot.Approvals)
765+ data["Mergeable"] = false
766+ }
621767 if isOwner && data["Mergeable"] == true && r.URL.Query().Get("merge") == "1" {
622768 data["Confirm"] = true
623769 data["MergeTitle"] = mergeTitle(pull)
@@ -680,5 +826,6 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) {
680826 s.fail(w, r, 500, err.Error())
681827 return
682828 }
829+ installHook(s.root, dir)
683830 http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther)
684831 }

static/style.css modified

@@ -268,3 +268,26 @@ table.blame td.n{border-right:1px solid var(--bar)}
268268 .event h4{display:flex;align-items:baseline}
269269 .event h4 .tag{margin-left:.4rem}
270270 .event h4 .starcount{margin-left:auto;color:var(--muted);font-size:.85rem;font-weight:400}
271+.label{display:inline-block;color:#fff;font-size:.75rem;padding:0 .45rem;border-radius:.6rem;vertical-align:middle;line-height:1.4;font-weight:500;text-shadow:0 0 2px rgba(0,0,0,.3)}
272+a.label{opacity:.75}a.label.on,a.label:hover{opacity:1;text-decoration:none}
273+.labelbar{margin:-.4rem 0 1rem}
274+.state.draft{background:var(--muted)}
275+.verdict{font-size:.8rem;padding:0 .35rem;border-radius:3px;border:1px solid}
276+.verdict.approved{color:#1a7f37;border-color:#1a7f37}
277+.verdict.changes{color:#c0392b;border-color:#c0392b}
278+.verdict.comment{color:var(--muted);border-color:var(--line)}
279+.comment.review.approved .who{background:#e6ffec}
280+.comment.review.changes .who{background:#ffebe9}
281+.reviewverdict{margin-top:1.2rem;border-top:1px solid var(--line);padding-top:.6rem}
282+.reviewverdict h4{margin:0 0 .3rem}
283+.reviewverdict label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem}
284+.reviewverdict label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
285+.panel.meta h4{margin:.2rem 0 .3rem}
286+.metaform{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.6rem}
287+.metaform label.check{display:flex;align-items:center;gap:.4rem;font-size:.9rem}
288+.btn.small{font-size:.8rem;padding:.1rem .5rem;align-self:flex-start;margin-top:.3rem}
289+.editform{max-width:none}
290+.editform textarea{width:100%;font-size:13px;line-height:1.45;tab-size:4}
291+.inlineform{display:inline}
292+.linkbutton{background:none;border:0;padding:0;font:inherit;color:var(--muted);cursor:pointer}
293+.linkbutton:hover{color:var(--del);text-decoration:underline}

store.go modified

@@ -74,6 +74,7 @@ var migrations = []string{
7474 `alter table users add column banned integer not null default 0`,
7575 `alter table users add column totp text not null default ''`,
7676 `alter table users add column recovery text not null default ''`,
77+ `alter table pulls add column draft integer not null default 0`,
7778 }
7879
7980 const extraSchema = `
@@ -102,6 +103,41 @@ create table if not exists issues (
102103 updated integer not null,
103104 unique(repo, number)
104105 );
106+create table if not exists labels (
107+ id integer primary key,
108+ repo integer not null references repos(id) on delete cascade,
109+ name text not null,
110+ colour text not null default '#6c757d',
111+ unique(repo, name)
112+);
113+create table if not exists labelled (
114+ kind text not null,
115+ item integer not null,
116+ label integer not null references labels(id) on delete cascade,
117+ primary key(kind, item, label)
118+);
119+create table if not exists assigned (
120+ kind text not null,
121+ item integer not null,
122+ user integer not null references users(id) on delete cascade,
123+ primary key(kind, item, user)
124+);
125+create table if not exists protected (
126+ repo integer not null references repos(id) on delete cascade,
127+ branch text not null,
128+ require_pr integer not null default 0,
129+ approvals integer not null default 0,
130+ primary key(repo, branch)
131+);
132+create table if not exists prreviews (
133+ id integer primary key,
134+ pull integer not null references pulls(id) on delete cascade,
135+ author integer not null references users(id) on delete cascade,
136+ state text not null,
137+ body text not null default '',
138+ head text not null default '',
139+ created integer not null
140+);
105141 create table if not exists issue_comments (
106142 id integer primary key,
107143 issue integer not null references issues(id) on delete cascade,

templates/blob.html modified

@@ -14,7 +14,7 @@
1414 <div class="filebar">
1515 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
1616 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
17-<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>
17+<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/edit/{{$p.Ref}}/{{.Path}}">Edit</a><form method="post" action="/{{$p.Repo.Path}}/remove/{{$p.Ref}}/{{.Path}}" class="inlineform"><button class="linkbutton">Delete</button></form>{{end}}
1818 <span class="spacer"></span>
1919 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
2020 </div>

templates/edit.html added

@@ -0,0 +1,10 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h3 class="plain">{{if .Data.Create}}New file in {{$p.Repo.Name}}/{{.Data.Dir}}{{else}}Edit {{.Data.Path}}{{end}} <span class="muted">on {{$p.Ref}}</span></h3>
4+<form method="post" class="stack editform">
5+{{if .Data.Create}}<label>File name <input name="name" required placeholder="docs/notes.md" value="{{.Data.Name}}"></label>{{end}}
6+<textarea name="content" rows="28" spellcheck="false">{{.Data.Content}}</textarea>
7+<label>Commit message <input name="message" value="{{.Data.Message}}" maxlength="200"></label>
8+<div class="actions left"><button class="btn primary">Commit to {{$p.Ref}} ▸</button><a class="btn" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">Cancel</a></div>
9+</form>
10+{{end}}

templates/issue.html modified

@@ -2,7 +2,7 @@
22 {{$p := .Data.Page}}
33 {{$i := .Data.Issue}}
44 <div class="pulltitle">
5-<h3 class="plain"><span class="state {{$i.State}}">{{$i.State}}</span> {{$i.Title}} <span class="muted">#{{$i.Number}}</span></h3>
5+<h3 class="plain"><span class="state {{$i.State}}">{{$i.State}}</span> {{$i.Title}} <span class="muted">#{{$i.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3>
66 <p class="muted"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> opened this issue {{ago $i.Created}}{{if .Data.Comments}} · {{len .Data.Comments}} comments{{end}}</p>
77 </div>
88 <div class="columns pullbody">
@@ -16,6 +16,8 @@
1616 </div>
1717 <aside><div class="panel">
1818 {{if .Data.CanManage}}{{if eq $i.State "open"}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/close"><button class="btn block">Close issue ▸</button></form>{{else}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}}{{else}}<p class="muted small">Only the author and the repository owner can close this.</p>{{end}}
19-</div></aside>
19+</div>
20+{{template "meta" (dict "Action" (printf "/%s/issues/%d/meta" $p.Repo.Path $i.Number) "Labels" .Data.Labels "AllLabels" .Data.AllLabels "Assignees" .Data.Assignees "Assignable" .Data.Assignable "Owner" $p.Owner)}}
21+</aside>
2022 </div>
2123 {{end}}

templates/issues.html modified

@@ -4,5 +4,6 @@
44 <nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav>
55 {{if and $p.User (not $p.Repo.Archived)}}<a class="btn primary" href="/{{$p.Repo.Path}}/issues/new">New issue ▸</a>{{end}}
66 </div>
7-{{if .Data.Issues}}<div class="events">{{range .Data.Issues}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/issues/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span></h4><p class="muted small">opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}} · updated {{ago .Updated}}</p></div>{{end}}</div>{{else}}<p class="info">No issues here.</p>{{end}}
7+{{if .Data.Labels}}<p class="labelbar">{{range .Data.Labels}}<a class="label{{if eq .ID $.Data.Filter}} on{{end}}" style="background:{{.Colour}}" href="?state={{$.Data.State}}&label={{.ID}}">{{.Name}}</a> {{end}}{{if .Data.Filter}}<a class="muted small" href="?state={{.Data.State}}">clear</a>{{end}}</p>{{end}}
8+{{if .Data.Issues}}<div class="events">{{range .Data.Issues}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/issues/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span>{{range index $.Data.ItemLabels .ID}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h4><p class="muted small">opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}} · updated {{ago .Updated}}</p></div>{{end}}</div>{{else}}<p class="info">No issues here.</p>{{end}}
89 {{end}}

templates/meta.html added

@@ -0,0 +1,10 @@
1+{{define "meta"}}
2+<div class="panel meta">
3+<h4>Labels</h4>
4+{{if .AllLabels}}<form method="post" action="{{.Action}}" class="metaform"><input type="hidden" name="labels_form" value="1">{{$have := .Labels}}{{range .AllLabels}}<label class="check"><input type="checkbox" name="label" value="{{.ID}}"{{range $have}}{{if eq .ID $.ID}}{{end}}{{end}}{{$id := .ID}}{{range $have}}{{if eq .ID $id}} checked{{end}}{{end}}> <span class="label" style="background:{{.Colour}}">{{.Name}}</span></label>{{end}}<button class="btn small">Save ▸</button></form>
5+{{else if .Labels}}{{range .Labels}}<span class="label" style="background:{{.Colour}}">{{.Name}}</span> {{end}}{{else}}<p class="muted small">None</p>{{end}}
6+<h4>Assignees</h4>
7+{{if .Assignable}}<form method="post" action="{{.Action}}" class="metaform"><input type="hidden" name="assignees_form" value="1">{{$have := .Assignees}}{{range .Assignable}}{{$id := .ID}}<label class="check"><input type="checkbox" name="assignee" value="{{.ID}}"{{range $have}}{{if eq .ID $id}} checked{{end}}{{end}}> ~{{.Name}}</label>{{end}}<button class="btn small">Save ▸</button></form>
8+{{else if .Assignees}}{{range .Assignees}}<a href="/~{{.Name}}">~{{.Name}}</a> {{end}}{{else}}<p class="muted small">Nobody</p>{{end}}
9+</div>
10+{{end}}

templates/newpull.html modified

@@ -9,6 +9,7 @@
99 </div>
1010 <div>
1111 <label>Source branch <select name="source">{{range .Data.Sources}}<option value="{{.Value}}"{{if eq .Value $.Data.Source}} selected{{end}}>{{.Label}}</option>{{end}}</select><small>Branches of this repository and of your forks. Push a branch first if it is not listed.</small></label>
12+<label class="check"><input type="checkbox" name="draft"> Open as a draft<small>Drafts cannot be merged until marked ready</small></label>
1213 <label>Target branch <select name="target">{{range .Data.Branches}}<option value="{{.Name}}"{{if eq .Name $.Data.Target}} selected{{else if and (not $.Data.Target) (eq .Name $p.Ref)}} selected{{end}}>{{.Name}}</option>{{end}}</select></label>
1314 </div>
1415 </form>

templates/pull.html modified

@@ -2,23 +2,36 @@
22 {{$p := .Data.Page}}
33 {{$pr := .Data.Pull}}
44 <div class="pulltitle">
5-<h3 class="plain"><span class="state {{$pr.State}}">{{$pr.State}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span></h3>
5+<h3 class="plain"><span class="state {{$pr.State}}{{if $pr.Draft}} draft{{end}}">{{if and $pr.Draft (eq $pr.State "open")}}draft{{else}}{{$pr.State}}{{end}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3>
66 <p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commits from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p>
77 </div>
88 <div class="columns pullbody">
99 <div class="main">
1010 {{if $pr.Body}}<div class="comment"><div class="who"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> <span class="muted">{{ago $pr.Created}}</span></div><div class="readme">{{.Data.Body}}</div></div>{{end}}
1111 {{range .Data.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span></div><div class="readme">{{.Body}}</div></div>{{end}}
12+<div id="reviews">{{range .Data.Reviews}}<div class="comment review {{.State}}"><div class="who"><a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> <span class="verdict {{.State}}">{{if eq .State "approved"}}approved{{else if eq .State "changes"}}requested changes{{else}}reviewed{{end}}</span> <span class="muted">{{ago .Created}}{{if ne .Head $.Data.Head}} · on an earlier revision{{end}}</span></div>{{if .Body}}<div class="readme">{{.HTML}}</div>{{end}}</div>{{end}}</div>
1213 {{if .User}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/comment" class="stack wide">
1314 <label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label>
1415 <div class="actions"><button class="btn">Comment ▸</button></div>
15-</form>{{else}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
16+</form>
17+{{if and (eq $pr.State "open") (ne .User.ID $pr.Author.ID)}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/verdict" class="stack wide reviewverdict">
18+<h4>Review</h4>
19+<label class="radio"><input type="radio" name="state" value="approved"> Approve<small>The changes are good to merge</small></label>
20+<label class="radio"><input type="radio" name="state" value="changes"> Request changes<small>Something needs to change first</small></label>
21+<label class="radio"><input type="radio" name="state" value="comment" checked> Comment<small>Feedback without a verdict</small></label>
22+<label>Summary <textarea name="body" rows="3" placeholder="Markdown supported"></textarea></label>
23+<div class="actions"><button class="btn primary">Submit review ▸</button></div>
24+</form>{{end}}
25+{{else}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
1626 </div>
1727 <aside>
1828 <div class="panel">
29+{{if or .Data.Approvals .Data.Changes}}<p>{{if .Data.Approvals}}<span class="verdict approved">{{.Data.Approvals}} approved</span> {{end}}{{if .Data.Changes}}<span class="verdict changes">{{.Data.Changes}} requested changes</span>{{end}}</p>{{end}}
30+{{with .Data.Protection}}<p class="muted small">{{.Branch}} is protected{{if .Approvals}}, {{.Approvals}} approving reviews required{{end}}.</p>{{end}}
31+{{if and $pr.Draft (eq $pr.State "open")}}<p>This is a draft. {{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/ready"><button class="btn block">Ready for review ▸</button></form>{{end}}</p>{{end}}
1932 {{if eq $pr.State "open"}}
2033 {{if .Data.Unrelated}}<p>The branches have no common history.</p>
21-{{else if .Data.Mergeable}}<p>This branch can be merged automatically.</p>{{else}}<p>This branch has conflicts or nothing new to merge. Resolve locally, push, then update.</p>{{end}}
34+{{else if .Data.Mergeable}}<p>This branch can be merged automatically.</p>{{else if .Data.Blocked}}<p>Merging is blocked, {{.Data.Blocked}}.</p>{{else if $pr.Draft}}<p>Drafts cannot be merged.</p>{{else}}<p>This branch has conflicts or nothing new to merge. Resolve locally, push, then update.</p>{{end}}
2235 {{if .Data.Owner}}{{if .Data.Confirm}}
2336 <form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/merge" class="stack mergeform">
2437 <label class="radio"><input type="radio" name="strategy" value="merge" checked> Merge commit<small>Both parents kept</small></label>
@@ -37,6 +50,7 @@
3750 {{else}}<p>Merged into <code>{{$pr.TargetBranch}}</code>.</p>{{end}}
3851 <p class="muted small">Fetch locally with<br><code>git fetch https://{{.Host}}/{{$p.Repo.Path}} {{$pr.Ref}}</code></p>
3952 </div>
53+{{template "meta" (dict "Action" (printf "/%s/pulls/%d/meta" $p.Repo.Path $pr.Number) "Labels" .Data.Labels "AllLabels" .Data.AllLabels "Assignees" .Data.Assignees "Assignable" .Data.Assignable "Owner" .Data.Owner)}}
4054 </aside>
4155 </div>
4256 {{if .Data.Commits}}

templates/pulls.html modified

@@ -4,5 +4,6 @@
44 <nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=merged"{{if eq .Data.State "merged"}} class="active"{{end}}>Merged</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav>
55 {{if $p.User}}<a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}}
66 </div>
7-{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span></h4><p class="muted small">{{if .SameRepo}}{{.SourceBranch}}{{else}}{{.SourceRepo.Path}}:{{.SourceBranch}}{{end}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}}
7+{{if .Data.Labels}}<p class="labelbar">{{range .Data.Labels}}<a class="label{{if eq .ID $.Data.Filter}} on{{end}}" style="background:{{.Colour}}" href="?state={{$.Data.State}}&label={{.ID}}">{{.Name}}</a> {{end}}{{if .Data.Filter}}<a class="muted small" href="?state={{.Data.State}}">clear</a>{{end}}</p>{{end}}
8+{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}{{if .Draft}} draft{{end}}">{{if and .Draft (eq .State "open")}}draft{{else}}{{.State}}{{end}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span>{{range index $.Data.ItemLabels .ID}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h4><p class="muted small">{{if .SameRepo}}{{.SourceBranch}}{{else}}{{.SourceRepo.Path}}:{{.SourceBranch}}{{end}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}}
89 {{end}}

templates/reposettings.html modified

@@ -29,6 +29,20 @@
2929 <h3>Archive</h3>
3030 <p>{{if $p.Repo.Archived}}This repository is archived, nobody can push, open pull requests or comment.{{else}}Archiving makes the repository read only. Nobody can push, open pull requests or comment until it is unarchived.{{end}}</p>
3131 <form method="post" action="/{{$p.Repo.Path}}/archive"><button class="btn">{{if $p.Repo.Archived}}Unarchive ▸{{else}}Archive ▸{{end}}</button></form>
32+<h3 id="labels">Labels</h3>
33+{{if .Data.Labels}}<table class="keys">{{range .Data.Labels}}<tr><td><span class="label" style="background:{{.Colour}}">{{.Name}}</span></td><td><code>{{.Colour}}</code></td><td><form method="post" action="/{{$p.Repo.Path}}/labels"><input type="hidden" name="remove" value="{{.ID}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}}
34+<form method="post" action="/{{$p.Repo.Path}}/labels" class="stack">
35+<div class="pair"><label>Name <input name="name" required maxlength="40" placeholder="bug"></label><label>Colour <input name="colour" type="color" value="#0640e0"></label></div>
36+<div class="actions left"><button class="btn primary">Add label ▸</button></div>
37+</form>
38+<h3 id="protection">Branch protection</h3>
39+<p>A protected branch cannot be deleted or force pushed. Optionally require all changes to arrive through pull requests, and a number of approving reviews before one can be merged.</p>
40+{{if .Data.Protections}}<table class="keys">{{range .Data.Protections}}<tr><td><code>{{.Branch}}</code></td><td class="muted">{{if .RequirePR}}pull requests only{{else}}direct pushes allowed{{end}}{{if .Approvals}}, {{.Approvals}} approvals{{end}}</td><td><form method="post" action="/{{$p.Repo.Path}}/protection"><input type="hidden" name="remove" value="{{.Branch}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}}
41+<form method="post" action="/{{$p.Repo.Path}}/protection" class="stack">
42+<div class="pair"><label>Branch <input name="branch" required placeholder="main"></label><label>Approvals required <input name="approvals" type="number" min="0" max="10" value="0"></label></div>
43+<label class="check"><input type="checkbox" name="require_pr"> Only accept changes through pull requests</label>
44+<div class="actions left"><button class="btn primary">Protect ▸</button></div>
45+</form>
3246 <h3>Collaborators</h3>
3347 <p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p>
3448 {{if .Data.Collaborators}}<table class="keys">{{range .Data.Collaborators}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/{{$p.Repo.Path}}/collaborators"><input type="hidden" name="remove" value="{{.ID}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}}

templates/tree.html modified

@@ -18,7 +18,7 @@
1818 <div class="filebar">
1919 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
2020 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
21-{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}}
21+{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/{{.Path}}">New file</a>{{end}}{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/">New file</a>{{end}}{{end}}
2222 <span class="spacer"></span>
2323 <form method="get" action="/{{$p.Repo.Path}}/search" class="filesearch"><input type="hidden" name="ref" value="{{$p.Ref}}"><input name="q" placeholder="Search files" value=""></form>
2424 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}

web.go modified

@@ -82,6 +82,15 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv
8282 "perm": perm,
8383 "signer": s.signer,
8484 "mark": mark,
85+ "dict": func(kv ...any) map[string]any {
86+ m := map[string]any{}
87+ for i := 0; i+1 < len(kv); i += 2 {
88+ if k, ok := kv[i].(string); ok {
89+ m[k] = kv[i+1]
90+ }
91+ }
92+ return m
93+ },
8594 "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
8695 "string": func(b []byte) string { return string(b) },
8796 }
@@ -91,7 +100,10 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv
91100 if base == "layout.html" {
92101 continue
93102 }
94- s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", name))
103+ if base == "meta.html" {
104+ continue
105+ }
106+ s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", "templates/meta.html", name))
95107 }
96108 s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax,
97109 highlighting.NewHighlighting(highlighting.WithFormatOptions(chromahtml.WithClasses(true)))))
@@ -99,6 +111,7 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv
99111 s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code")
100112 s.writeSigners()
101113 s.writeKeyring()
114+ s.installHooks()
102115 sum := sha256.New()
103116 css, _ := staticFS.ReadFile("static/style.css")
104117 sum.Write(css)
@@ -329,6 +342,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
329342 s.fail(w, r, 500, err.Error())
330343 return
331344 }
345+ installHook(s.root, dir)
332346 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
333347 }
334348
@@ -760,6 +774,16 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
760774 s.deleteRepo(w, r, repo)
761775 case rest == "collaborators" && r.Method == "POST":
762776 s.collaborators(w, r, repo)
777+ case rest == "labels" && r.Method == "POST":
778+ s.manageLabels(w, r, repo)
779+ case rest == "protection" && r.Method == "POST":
780+ s.manageProtection(w, r, repo)
781+ case strings.HasPrefix(rest, "edit/"):
782+ s.edit(w, r, repo, strings.TrimPrefix(rest, "edit/"), false)
783+ case strings.HasPrefix(rest, "new/"):
784+ s.edit(w, r, repo, strings.TrimPrefix(rest, "new/"), true)
785+ case strings.HasPrefix(rest, "remove/") && r.Method == "POST":
786+ s.removeFile(w, r, repo, strings.TrimPrefix(rest, "remove/"))
763787 case rest == "rename" && r.Method == "POST":
764788 s.renameRepo(w, r, repo)
765789 case rest == "transfer" && r.Method == "POST":
@@ -1186,7 +1210,8 @@ func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo)
11861210 }
11871211 rp.Tab = "settings"
11881212 collabs, _ := s.store.Collaborators(repo.ID)
1189- s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs}})
1213+ labels, _ := s.store.Labels(repo.ID)
1214+ s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs, "Labels": labels, "Protections": s.store.Protections(repo.ID)}})
11901215 }
11911216
11921217 func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) {