feat(basin): pr reviews and drafts, labels and assignees, branch protection, browser file editing
19 files changed, +836 −18
- edit.go added +257 −0
- git.go modified +6 −1
- issues.go modified +32 −2
- labels.go added +237 −0
- main.go modified +5 −0
- pull.go modified +151 −4
- static/style.css modified +23 −0
- store.go modified +36 −0
- templates/blob.html modified +1 −1
- templates/edit.html added +10 −0
- templates/issue.html modified +4 −2
- templates/issues.html modified +2 −1
- templates/meta.html added +10 −0
- templates/newpull.html modified +1 −0
- templates/pull.html modified +17 −3
- templates/pulls.html modified +2 −1
- templates/reposettings.html modified +14 −0
- templates/tree.html modified +1 −1
- web.go modified +27 −2
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 | ||
| 36 | 36 | |
| 37 | 37 | var gnupgHome string |
| 38 | 38 | |
| 39 | +var passEnv []string | |
| 40 | + | |
| 39 | 41 | type Entry struct { |
| 40 | 42 | Mode string |
| 41 | 43 | Type string |
| @@ -82,7 +84,8 @@ func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) | ||
| 82 | 84 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 83 | 85 | defer cancel() |
| 84 | 86 | 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...) | |
| 86 | 89 | if signersFile != "" { |
| 87 | 90 | cmd.Env = append(cmd.Env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile", "GIT_CONFIG_VALUE_0="+signersFile) |
| 88 | 91 | } |
| @@ -166,6 +169,8 @@ func (g Git) mergeTree(base, ours, theirs string) (string, error) { | ||
| 166 | 169 | return tree, nil |
| 167 | 170 | } |
| 168 | 171 | |
| 172 | +func timeNow() time.Time { return time.Now() } | |
| 173 | + | |
| 169 | 174 | func ident(name, email string, when time.Time) []string { |
| 170 | 175 | stamp := strconv.FormatInt(when.Unix(), 10) + " +0000" |
| 171 | 176 | 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) { | ||
| 145 | 145 | s.fail(w, r, 500, err.Error()) |
| 146 | 146 | return |
| 147 | 147 | } |
| 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}}) | |
| 149 | 165 | } |
| 150 | 166 | |
| 151 | 167 | 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 | ||
| 222 | 238 | 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)) |
| 223 | 239 | } |
| 224 | 240 | } |
| 241 | + case "meta": | |
| 242 | + s.setMeta(w, r, repo, "issue", issue.ID, back) | |
| 243 | + return | |
| 225 | 244 | case "close": |
| 226 | 245 | if canManage { |
| 227 | 246 | s.store.SetIssueState(issue.ID, "closed") |
| @@ -244,5 +263,16 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s | ||
| 244 | 263 | } |
| 245 | 264 | body := s.renderMarkdown([]byte(issue.Body)) |
| 246 | 265 | 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 | + }()}}) | |
| 248 | 278 | } |
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() { | ||
| 38 | 38 | case "keys": |
| 39 | 39 | binary, _ := os.Executable() |
| 40 | 40 | 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])) | |
| 41 | 46 | case "shell": |
| 42 | 47 | if len(args) != 2 { |
| 43 | 48 | os.Exit(2) |
pull.go modified
| @@ -65,6 +65,74 @@ type Pull struct { | ||
| 65 | 65 | Updated time.Time |
| 66 | 66 | Merged string |
| 67 | 67 | 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) | |
| 68 | 136 | } |
| 69 | 137 | |
| 70 | 138 | type Review struct { |
| @@ -132,9 +200,11 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) { | ||
| 132 | 200 | var p Pull |
| 133 | 201 | var created, updated, aCreated int64 |
| 134 | 202 | 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 { | |
| 136 | 205 | return nil, err |
| 137 | 206 | } |
| 207 | + p.Draft = draft != 0 | |
| 138 | 208 | p.Created, p.Updated, p.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(aCreated, 0) |
| 139 | 209 | p.SourceRepo.ID = sourceID |
| 140 | 210 | pulls = append(pulls, p) |
| @@ -143,7 +213,7 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) { | ||
| 143 | 213 | } |
| 144 | 214 | |
| 145 | 215 | 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` | |
| 147 | 217 | |
| 148 | 218 | func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) { |
| 149 | 219 | 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) { | ||
| 325 | 395 | s.fail(w, r, 500, err.Error()) |
| 326 | 396 | return |
| 327 | 397 | } |
| 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}}) | |
| 329 | 415 | } |
| 330 | 416 | |
| 331 | 417 | type sourceOption struct { |
| @@ -432,6 +518,9 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 432 | 518 | retry(err.Error()) |
| 433 | 519 | return |
| 434 | 520 | } |
| 521 | + if r.FormValue("draft") == "on" { | |
| 522 | + s.store.SetDraft(pull.ID, true) | |
| 523 | + } | |
| 435 | 524 | if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil { |
| 436 | 525 | retry("Could not fetch the source branch: " + err.Error()) |
| 437 | 526 | return |
| @@ -499,6 +588,30 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 499 | 588 | } |
| 500 | 589 | back += "#r-" + r.FormValue("path") + ":" + r.FormValue("side") + ":" + r.FormValue("line") |
| 501 | 590 | 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 | |
| 502 | 615 | case "resolve", "unresolve", "delete": |
| 503 | 616 | id, _ := strconv.ParseInt(r.FormValue("review"), 10, 64) |
| 504 | 617 | owner := s.store.ReviewAuthor(id) |
| @@ -528,6 +641,18 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 528 | 641 | s.fail(w, r, 403, "Only the repository owner can merge") |
| 529 | 642 | return |
| 530 | 643 | } |
| 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 | + } | |
| 531 | 656 | title := strings.TrimSpace(r.FormValue("title")) |
| 532 | 657 | if title == "" { |
| 533 | 658 | title = mergeTitle(pull) |
| @@ -565,7 +690,21 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 565 | 690 | return |
| 566 | 691 | } |
| 567 | 692 | 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 | + } | |
| 569 | 708 | base := pull.TargetBranch |
| 570 | 709 | if pull.State == "merged" && pull.Merged != "" { |
| 571 | 710 | base = pull.Merged + "^1" |
| @@ -618,6 +757,13 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 618 | 757 | if cur, err := g.Resolve("refs/heads/" + pull.TargetBranch); err == nil && cur == head { |
| 619 | 758 | data["Mergeable"] = false |
| 620 | 759 | } |
| 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 | + } | |
| 621 | 767 | if isOwner && data["Mergeable"] == true && r.URL.Query().Get("merge") == "1" { |
| 622 | 768 | data["Confirm"] = true |
| 623 | 769 | data["MergeTitle"] = mergeTitle(pull) |
| @@ -680,5 +826,6 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 680 | 826 | s.fail(w, r, 500, err.Error()) |
| 681 | 827 | return |
| 682 | 828 | } |
| 829 | + installHook(s.root, dir) | |
| 683 | 830 | http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) |
| 684 | 831 | } |
static/style.css modified
| @@ -268,3 +268,26 @@ table.blame td.n{border-right:1px solid var(--bar)} | ||
| 268 | 268 | .event h4{display:flex;align-items:baseline} |
| 269 | 269 | .event h4 .tag{margin-left:.4rem} |
| 270 | 270 | .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{ | ||
| 74 | 74 | `alter table users add column banned integer not null default 0`, |
| 75 | 75 | `alter table users add column totp text not null default ''`, |
| 76 | 76 | `alter table users add column recovery text not null default ''`, |
| 77 | + `alter table pulls add column draft integer not null default 0`, | |
| 77 | 78 | } |
| 78 | 79 | |
| 79 | 80 | const extraSchema = ` |
| @@ -102,6 +103,41 @@ create table if not exists issues ( | ||
| 102 | 103 | updated integer not null, |
| 103 | 104 | unique(repo, number) |
| 104 | 105 | ); |
| 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 | +); | |
| 105 | 141 | create table if not exists issue_comments ( |
| 106 | 142 | id integer primary key, |
| 107 | 143 | issue integer not null references issues(id) on delete cascade, |
templates/blob.html modified
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | <div class="filebar"> |
| 15 | 15 | <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> |
| 16 | 16 | {{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}} | |
| 18 | 18 | <span class="spacer"></span> |
| 19 | 19 | {{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}} |
| 20 | 20 | </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 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{$i := .Data.Issue}} |
| 4 | 4 | <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> | |
| 6 | 6 | <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> |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns pullbody"> |
| @@ -16,6 +16,8 @@ | ||
| 16 | 16 | </div> |
| 17 | 17 | <aside><div class="panel"> |
| 18 | 18 | {{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> | |
| 20 | 22 | </div> |
| 21 | 23 | {{end}} |
templates/issues.html modified
| @@ -4,5 +4,6 @@ | ||
| 4 | 4 | <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> |
| 5 | 5 | {{if and $p.User (not $p.Repo.Archived)}}<a class="btn primary" href="/{{$p.Repo.Path}}/issues/new">New issue ▸</a>{{end}} |
| 6 | 6 | </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}} | |
| 8 | 9 | {{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 @@ | ||
| 9 | 9 | </div> |
| 10 | 10 | <div> |
| 11 | 11 | <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> | |
| 12 | 13 | <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> |
| 13 | 14 | </div> |
| 14 | 15 | </form> |
templates/pull.html modified
| @@ -2,23 +2,36 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{$pr := .Data.Pull}} |
| 4 | 4 | <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> | |
| 6 | 6 | <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> |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns pullbody"> |
| 9 | 9 | <div class="main"> |
| 10 | 10 | {{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}} |
| 11 | 11 | {{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> | |
| 12 | 13 | {{if .User}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/comment" class="stack wide"> |
| 13 | 14 | <label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label> |
| 14 | 15 | <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}} | |
| 16 | 26 | </div> |
| 17 | 27 | <aside> |
| 18 | 28 | <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}} | |
| 19 | 32 | {{if eq $pr.State "open"}} |
| 20 | 33 | {{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}} | |
| 22 | 35 | {{if .Data.Owner}}{{if .Data.Confirm}} |
| 23 | 36 | <form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/merge" class="stack mergeform"> |
| 24 | 37 | <label class="radio"><input type="radio" name="strategy" value="merge" checked> Merge commit<small>Both parents kept</small></label> |
| @@ -37,6 +50,7 @@ | ||
| 37 | 50 | {{else}}<p>Merged into <code>{{$pr.TargetBranch}}</code>.</p>{{end}} |
| 38 | 51 | <p class="muted small">Fetch locally with<br><code>git fetch https://{{.Host}}/{{$p.Repo.Path}} {{$pr.Ref}}</code></p> |
| 39 | 52 | </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)}} | |
| 40 | 54 | </aside> |
| 41 | 55 | </div> |
| 42 | 56 | {{if .Data.Commits}} |
templates/pulls.html modified
| @@ -4,5 +4,6 @@ | ||
| 4 | 4 | <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> |
| 5 | 5 | {{if $p.User}}<a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}} |
| 6 | 6 | </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}} | |
| 8 | 9 | {{end}} |
templates/reposettings.html modified
| @@ -29,6 +29,20 @@ | ||
| 29 | 29 | <h3>Archive</h3> |
| 30 | 30 | <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> |
| 31 | 31 | <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> | |
| 32 | 46 | <h3>Collaborators</h3> |
| 33 | 47 | <p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p> |
| 34 | 48 | {{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 @@ | ||
| 18 | 18 | <div class="filebar"> |
| 19 | 19 | <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> |
| 20 | 20 | {{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}} | |
| 22 | 22 | <span class="spacer"></span> |
| 23 | 23 | <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> |
| 24 | 24 | {{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 | ||
| 82 | 82 | "perm": perm, |
| 83 | 83 | "signer": s.signer, |
| 84 | 84 | "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 | + }, | |
| 85 | 94 | "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") }, |
| 86 | 95 | "string": func(b []byte) string { return string(b) }, |
| 87 | 96 | } |
| @@ -91,7 +100,10 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 91 | 100 | if base == "layout.html" { |
| 92 | 101 | continue |
| 93 | 102 | } |
| 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)) | |
| 95 | 107 | } |
| 96 | 108 | s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax, |
| 97 | 109 | highlighting.NewHighlighting(highlighting.WithFormatOptions(chromahtml.WithClasses(true))))) |
| @@ -99,6 +111,7 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 99 | 111 | s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code") |
| 100 | 112 | s.writeSigners() |
| 101 | 113 | s.writeKeyring() |
| 114 | + s.installHooks() | |
| 102 | 115 | sum := sha256.New() |
| 103 | 116 | css, _ := staticFS.ReadFile("static/style.css") |
| 104 | 117 | sum.Write(css) |
| @@ -329,6 +342,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 329 | 342 | s.fail(w, r, 500, err.Error()) |
| 330 | 343 | return |
| 331 | 344 | } |
| 345 | + installHook(s.root, dir) | |
| 332 | 346 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 333 | 347 | } |
| 334 | 348 | |
| @@ -760,6 +774,16 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 760 | 774 | s.deleteRepo(w, r, repo) |
| 761 | 775 | case rest == "collaborators" && r.Method == "POST": |
| 762 | 776 | 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/")) | |
| 763 | 787 | case rest == "rename" && r.Method == "POST": |
| 764 | 788 | s.renameRepo(w, r, repo) |
| 765 | 789 | case rest == "transfer" && r.Method == "POST": |
| @@ -1186,7 +1210,8 @@ func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) | ||
| 1186 | 1210 | } |
| 1187 | 1211 | rp.Tab = "settings" |
| 1188 | 1212 | 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)}}) | |
| 1190 | 1215 | } |
| 1191 | 1216 | |
| 1192 | 1217 | func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) { |