feat(basin): pull requests, forks and visibility
15 files changed, +906 −35
- README.md modified +5 −1
- git.go modified +77 −1
- pull.go added +498 −0
- static/style.css modified +33 −0
- store.go modified +84 −9
- templates/create.html modified +15 −7
- templates/index.html modified +2 −2
- templates/layout.html modified +3 −2
- templates/newpull.html added +15 −0
- templates/projects.html added +24 −0
- templates/pull.html added +50 −0
- templates/pulls.html added +8 −0
- templates/reposettings.html modified +16 −8
- templates/user.html modified +1 −1
- web.go modified +75 −4
README.md modified
| @@ -31,6 +31,10 @@ Anonymous clients may fetch public repositories. Private repositories and all pu | ||
| 31 | 31 | |
| 32 | 32 | Each user has a bio, location, workplace, website and an avatar. Avatar uploads are decoded on the server, cropped to the square the settings page chose with a small canvas script, resampled to 256 pixels and stored as PNG, so the served image never contains the original file's metadata or dimensions. Without JavaScript the crop falls back to the centred square. |
| 33 | 33 | |
| 34 | +## Pull requests | |
| 35 | + | |
| 36 | +Any account can fork a public repository, which runs a local bare clone, and open a pull request from a branch of the target repository or of their own fork. Creating the request fetches the source branch into `refs/pull/N/head` of the target, so the diff, commit list and mergeability are computed inside one repository with `merge-base`, `diff` and `merge-tree --write-tree`. Merging builds the merge commit with `commit-tree` and advances the branch with a compare and swap `update-ref`, so no working tree is ever checked out and a concurrent push makes the merge fail cleanly rather than clobber. Discussion is a flat list of Markdown comments. Repositories also carry a visibility of public, unlisted or private and up to three tags, which the `/projects` page searches and sorts by last push time taken from ref mtimes. | |
| 37 | + | |
| 34 | 38 | ## Limitations |
| 35 | 39 | |
| 36 | -There is no search, no collaborators beyond the owner and no disk quota, so a pushed repository is bounded only by the nginx body limit and free space. Login, registration and https basic auth share a per address failure counter of ten misses per five minutes, which nginx must feed through `X-Forwarded-For`. Blobs over 1 MiB are not rendered and over 64 MiB are not served raw. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet. | |
| 40 | +There is no search, no collaborators beyond the owner and no disk quota, so a pushed repository is bounded only by the nginx body limit and free space. Login, registration and https basic auth share a per address failure counter of ten misses per five minutes, which nginx must feed through `X-Forwarded-For`. Blobs over 1 MiB are not rendered and over 64 MiB are not served raw. Pull requests have no inline review comments, no required reviews and no rebase or squash strategies, only a merge commit. | |
git.go modified
| @@ -65,10 +65,14 @@ type File struct { | ||
| 65 | 65 | var ErrMissing = errors.New("missing") |
| 66 | 66 | |
| 67 | 67 | func (g Git) run(stdin []byte, args ...string) ([]byte, error) { |
| 68 | + return g.runEnv(nil, stdin, args...) | |
| 69 | +} | |
| 70 | + | |
| 71 | +func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) { | |
| 68 | 72 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 69 | 73 | defer cancel() |
| 70 | 74 | cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...) |
| 71 | - cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"} | |
| 75 | + cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"}, env...) | |
| 72 | 76 | if stdin != nil { |
| 73 | 77 | cmd.Stdin = bytes.NewReader(stdin) |
| 74 | 78 | } |
| @@ -90,6 +94,78 @@ func initRepo(dir string) error { | ||
| 90 | 94 | return err |
| 91 | 95 | } |
| 92 | 96 | |
| 97 | +func cloneRepo(src, dst string) error { | |
| 98 | + _, err := Git{Dir: "/"}.run(nil, "clone", "--bare", "--quiet", "--no-hardlinks", src, dst) | |
| 99 | + if err != nil { | |
| 100 | + return err | |
| 101 | + } | |
| 102 | + Git{Dir: dst}.run(nil, "remote", "remove", "origin") | |
| 103 | + return nil | |
| 104 | +} | |
| 105 | + | |
| 106 | +func (g Git) Fetch(srcDir, srcRef, dstRef string) error { | |
| 107 | + _, err := g.run(nil, "fetch", "--quiet", "--no-tags", "--force", srcDir, srcRef+":"+dstRef) | |
| 108 | + return err | |
| 109 | +} | |
| 110 | + | |
| 111 | +func (g Git) MergeBase(a, b string) (string, error) { | |
| 112 | + out, err := g.run(nil, "merge-base", a, b) | |
| 113 | + if err != nil { | |
| 114 | + return "", err | |
| 115 | + } | |
| 116 | + return strings.TrimSpace(string(out)), nil | |
| 117 | +} | |
| 118 | + | |
| 119 | +func (g Git) LogRange(base, head string, n int) ([]Commit, error) { | |
| 120 | + out, err := g.run(nil, "log", "--format="+logFormat, "-n", strconv.Itoa(n), base+".."+head) | |
| 121 | + if err != nil { | |
| 122 | + return nil, err | |
| 123 | + } | |
| 124 | + return parseCommits(out), nil | |
| 125 | +} | |
| 126 | + | |
| 127 | +func (g Git) DiffRange(base, head string) ([]File, error) { | |
| 128 | + out, err := g.run(nil, "diff", "-M", "--no-color", base, head) | |
| 129 | + if err != nil { | |
| 130 | + return nil, err | |
| 131 | + } | |
| 132 | + return parseDiff(string(out)), nil | |
| 133 | +} | |
| 134 | + | |
| 135 | +func (g Git) Mergeable(target, head string) bool { | |
| 136 | + _, err := g.run(nil, "merge-tree", "--write-tree", "--quiet", "refs/heads/"+target, head) | |
| 137 | + return err == nil | |
| 138 | +} | |
| 139 | + | |
| 140 | +func (g Git) Merge(target, head, message, name, email string) (string, error) { | |
| 141 | + targetHash, err := g.Resolve("refs/heads/" + target) | |
| 142 | + if err != nil { | |
| 143 | + return "", errors.New("target branch is missing") | |
| 144 | + } | |
| 145 | + headHash, err := g.Resolve(head) | |
| 146 | + if err != nil { | |
| 147 | + return "", errors.New("pull request head is missing") | |
| 148 | + } | |
| 149 | + if base, err := g.MergeBase(targetHash, headHash); err == nil && base == headHash { | |
| 150 | + return "", errors.New("already merged") | |
| 151 | + } | |
| 152 | + out, err := g.run(nil, "merge-tree", "--write-tree", targetHash, headHash) | |
| 153 | + if err != nil { | |
| 154 | + return "", errors.New("merge conflicts, resolve them locally and update the pull request") | |
| 155 | + } | |
| 156 | + tree, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") | |
| 157 | + ident := []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email} | |
| 158 | + commit, err := g.runEnv(ident, []byte(message), "commit-tree", tree, "-p", targetHash, "-p", headHash) | |
| 159 | + if err != nil { | |
| 160 | + return "", err | |
| 161 | + } | |
| 162 | + hash := strings.TrimSpace(string(commit)) | |
| 163 | + if _, err := g.run(nil, "update-ref", "refs/heads/"+target, hash, targetHash); err != nil { | |
| 164 | + return "", errors.New("target branch moved, try again") | |
| 165 | + } | |
| 166 | + return hash, nil | |
| 167 | +} | |
| 168 | + | |
| 93 | 169 | func (g Git) Empty() bool { |
| 94 | 170 | _, err := g.run(nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}") |
| 95 | 171 | return err != nil |
pull.go added
| @@ -0,0 +1,498 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "html/template" | |
| 8 | + "net/http" | |
| 9 | + "os" | |
| 10 | + "path/filepath" | |
| 11 | + "strconv" | |
| 12 | + "strings" | |
| 13 | + "time" | |
| 14 | +) | |
| 15 | + | |
| 16 | +const pullSchema = ` | |
| 17 | +create table if not exists pulls ( | |
| 18 | + id integer primary key, | |
| 19 | + repo integer not null references repos(id) on delete cascade, | |
| 20 | + number integer not null, | |
| 21 | + author integer not null references users(id) on delete cascade, | |
| 22 | + title text not null, | |
| 23 | + body text not null default '', | |
| 24 | + source_repo integer not null, | |
| 25 | + source_branch text not null, | |
| 26 | + target_branch text not null, | |
| 27 | + state text not null default 'open', | |
| 28 | + created integer not null, | |
| 29 | + updated integer not null, | |
| 30 | + merged text not null default '', | |
| 31 | + unique(repo, number) | |
| 32 | +); | |
| 33 | +create table if not exists comments ( | |
| 34 | + id integer primary key, | |
| 35 | + pull integer not null references pulls(id) on delete cascade, | |
| 36 | + author integer not null references users(id) on delete cascade, | |
| 37 | + body text not null, | |
| 38 | + created integer not null | |
| 39 | +); | |
| 40 | +` | |
| 41 | + | |
| 42 | +type Pull struct { | |
| 43 | + ID int64 | |
| 44 | + Number int64 | |
| 45 | + Repo Repo | |
| 46 | + Author User | |
| 47 | + Title string | |
| 48 | + Body string | |
| 49 | + SourceRepo Repo | |
| 50 | + SourceBranch string | |
| 51 | + TargetBranch string | |
| 52 | + State string | |
| 53 | + Created time.Time | |
| 54 | + Updated time.Time | |
| 55 | + Merged string | |
| 56 | + Comments int | |
| 57 | +} | |
| 58 | + | |
| 59 | +type Comment struct { | |
| 60 | + ID int64 | |
| 61 | + Author User | |
| 62 | + Body string | |
| 63 | + Created time.Time | |
| 64 | +} | |
| 65 | + | |
| 66 | +func (p Pull) Ref() string { | |
| 67 | + return fmt.Sprintf("refs/pull/%d/head", p.Number) | |
| 68 | +} | |
| 69 | + | |
| 70 | +func (p Pull) SameRepo() bool { | |
| 71 | + return p.SourceRepo.ID == p.Repo.ID | |
| 72 | +} | |
| 73 | + | |
| 74 | +func (s *Store) CreatePull(repo Repo, author User, title, body string, source Repo, sourceBranch, targetBranch string) (Pull, error) { | |
| 75 | + title = strings.TrimSpace(title) | |
| 76 | + if title == "" || len(title) > 200 { | |
| 77 | + return Pull{}, errors.New("Title is required and at most 200 characters") | |
| 78 | + } | |
| 79 | + now := time.Now().Unix() | |
| 80 | + var number int64 | |
| 81 | + tx, err := s.db.Begin() | |
| 82 | + if err != nil { | |
| 83 | + return Pull{}, err | |
| 84 | + } | |
| 85 | + defer tx.Rollback() | |
| 86 | + tx.QueryRow(`select coalesce(max(number), 0) + 1 from pulls where repo = ?`, repo.ID).Scan(&number) | |
| 87 | + res, err := tx.Exec(`insert into pulls(repo, number, author, title, body, source_repo, source_branch, target_branch, state, created, updated) values (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, | |
| 88 | + repo.ID, number, author.ID, title, body, source.ID, sourceBranch, targetBranch, now, now) | |
| 89 | + if err != nil { | |
| 90 | + return Pull{}, err | |
| 91 | + } | |
| 92 | + if err := tx.Commit(); err != nil { | |
| 93 | + return Pull{}, err | |
| 94 | + } | |
| 95 | + id, _ := res.LastInsertId() | |
| 96 | + return Pull{ID: id, Number: number, Repo: repo, Author: author, Title: title, Body: body, SourceRepo: source, SourceBranch: sourceBranch, TargetBranch: targetBranch, State: "open"}, nil | |
| 97 | +} | |
| 98 | + | |
| 99 | +func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) { | |
| 100 | + defer rows.Close() | |
| 101 | + var pulls []Pull | |
| 102 | + for rows.Next() { | |
| 103 | + var p Pull | |
| 104 | + var created, updated, aCreated int64 | |
| 105 | + var sourceID int64 | |
| 106 | + 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 { | |
| 107 | + return nil, err | |
| 108 | + } | |
| 109 | + p.Created, p.Updated, p.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(aCreated, 0) | |
| 110 | + p.SourceRepo.ID = sourceID | |
| 111 | + pulls = append(pulls, p) | |
| 112 | + } | |
| 113 | + return pulls, rows.Err() | |
| 114 | +} | |
| 115 | + | |
| 116 | +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, | |
| 117 | + (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` | |
| 118 | + | |
| 119 | +func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) { | |
| 120 | + rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and (? = '' or p.state = ?) order by p.number desc`, repo.ID, state, state) | |
| 121 | + if err != nil { | |
| 122 | + return nil, err | |
| 123 | + } | |
| 124 | + pulls, err := s.scanPulls(rows) | |
| 125 | + for i := range pulls { | |
| 126 | + pulls[i].Repo = repo | |
| 127 | + s.fillSource(&pulls[i]) | |
| 128 | + } | |
| 129 | + return pulls, err | |
| 130 | +} | |
| 131 | + | |
| 132 | +func (s *Store) Pull(repo Repo, number int64) (Pull, error) { | |
| 133 | + rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and p.number = ?`, repo.ID, number) | |
| 134 | + if err != nil { | |
| 135 | + return Pull{}, err | |
| 136 | + } | |
| 137 | + pulls, err := s.scanPulls(rows) | |
| 138 | + if err != nil || len(pulls) == 0 { | |
| 139 | + return Pull{}, ErrNotFound | |
| 140 | + } | |
| 141 | + p := pulls[0] | |
| 142 | + p.Repo = repo | |
| 143 | + s.fillSource(&p) | |
| 144 | + return p, nil | |
| 145 | +} | |
| 146 | + | |
| 147 | +func (s *Store) fillSource(p *Pull) { | |
| 148 | + if p.SourceRepo.ID == p.Repo.ID { | |
| 149 | + p.SourceRepo = p.Repo | |
| 150 | + return | |
| 151 | + } | |
| 152 | + rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, p.SourceRepo.ID) | |
| 153 | + if err != nil { | |
| 154 | + return | |
| 155 | + } | |
| 156 | + if repos, _ := scanRepos(rows); len(repos) == 1 { | |
| 157 | + p.SourceRepo = repos[0] | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +func (s *Store) SetPullState(id int64, state, merged string) error { | |
| 162 | + _, err := s.db.Exec(`update pulls set state = ?, merged = ?, updated = ? where id = ?`, state, merged, time.Now().Unix(), id) | |
| 163 | + return err | |
| 164 | +} | |
| 165 | + | |
| 166 | +func (s *Store) TouchPull(id int64) { | |
| 167 | + s.db.Exec(`update pulls set updated = ? where id = ?`, time.Now().Unix(), id) | |
| 168 | +} | |
| 169 | + | |
| 170 | +func (s *Store) AddComment(pull int64, author User, body string) error { | |
| 171 | + body = strings.TrimSpace(body) | |
| 172 | + if body == "" || len(body) > 20000 { | |
| 173 | + return errors.New("Comment is empty or too long") | |
| 174 | + } | |
| 175 | + _, err := s.db.Exec(`insert into comments(pull, author, body, created) values (?, ?, ?, ?)`, pull, author.ID, body, time.Now().Unix()) | |
| 176 | + s.TouchPull(pull) | |
| 177 | + return err | |
| 178 | +} | |
| 179 | + | |
| 180 | +func (s *Store) Comments(pull int64) ([]Comment, error) { | |
| 181 | + rows, err := s.db.Query(`select c.id, c.body, c.created, u.id, u.name, u.created from comments c join users u on u.id = c.author where c.pull = ? order by c.id`, pull) | |
| 182 | + if err != nil { | |
| 183 | + return nil, err | |
| 184 | + } | |
| 185 | + defer rows.Close() | |
| 186 | + var out []Comment | |
| 187 | + for rows.Next() { | |
| 188 | + var c Comment | |
| 189 | + var created, uCreated int64 | |
| 190 | + if err := rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &uCreated); err != nil { | |
| 191 | + return nil, err | |
| 192 | + } | |
| 193 | + c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0) | |
| 194 | + out = append(out, c) | |
| 195 | + } | |
| 196 | + return out, rows.Err() | |
| 197 | +} | |
| 198 | + | |
| 199 | +func (s *Store) OpenPullCount(repo int64) int { | |
| 200 | + var n int | |
| 201 | + s.db.QueryRow(`select count(*) from pulls where repo = ? and state = 'open'`, repo).Scan(&n) | |
| 202 | + return n | |
| 203 | +} | |
| 204 | + | |
| 205 | +func (s *Store) Forks(parent int64, viewer int64) ([]Repo, error) { | |
| 206 | + rows, err := s.db.Query(`select `+repoColumns+` where r.parent = ? and (r.private = 0 or r.owner = ?) order by u.name`, parent, viewer) | |
| 207 | + if err != nil { | |
| 208 | + return nil, err | |
| 209 | + } | |
| 210 | + return scanRepos(rows) | |
| 211 | +} | |
| 212 | + | |
| 213 | +func (s *Store) ForkRepo(owner User, parent Repo) (Repo, error) { | |
| 214 | + name := parent.Name | |
| 215 | + repo, err := s.CreateRepo(owner, name, parent.Description, Public, strings.Join(parent.Tags, ",")) | |
| 216 | + if err != nil { | |
| 217 | + return Repo{}, err | |
| 218 | + } | |
| 219 | + s.db.Exec(`update repos set parent = ? where id = ?`, parent.ID, repo.ID) | |
| 220 | + repo.Parent = parent.ID | |
| 221 | + return repo, nil | |
| 222 | +} | |
| 223 | + | |
| 224 | +func (s *Server) pullAccess(w http.ResponseWriter, r *http.Request, repo Repo) (repoPage, Git, *User, bool) { | |
| 225 | + rp, g, err := s.repoPage(r, repo, "") | |
| 226 | + if err != nil { | |
| 227 | + s.fail(w, r, 404, "No such ref") | |
| 228 | + return rp, g, nil, false | |
| 229 | + } | |
| 230 | + rp.Tab = "pulls" | |
| 231 | + return rp, g, s.current(r), true | |
| 232 | +} | |
| 233 | + | |
| 234 | +func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 235 | + rp, _, _, ok := s.pullAccess(w, r, repo) | |
| 236 | + if !ok { | |
| 237 | + return | |
| 238 | + } | |
| 239 | + state := r.URL.Query().Get("state") | |
| 240 | + if state == "" { | |
| 241 | + state = "open" | |
| 242 | + } | |
| 243 | + if state == "all" { | |
| 244 | + state = "" | |
| 245 | + } | |
| 246 | + pulls, err := s.store.Pulls(repo, state) | |
| 247 | + if err != nil { | |
| 248 | + s.fail(w, r, 500, err.Error()) | |
| 249 | + return | |
| 250 | + } | |
| 251 | + 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")}}) | |
| 252 | +} | |
| 253 | + | |
| 254 | +type sourceOption struct { | |
| 255 | + Value string | |
| 256 | + Label string | |
| 257 | +} | |
| 258 | + | |
| 259 | +func (s *Server) pullSources(repo Repo, user *User) []sourceOption { | |
| 260 | + var opts []sourceOption | |
| 261 | + add := func(src Repo) { | |
| 262 | + g := Git{Dir: s.repoDir(src)} | |
| 263 | + branches, _ := g.Refs("refs/heads") | |
| 264 | + for _, b := range branches { | |
| 265 | + label := b.Name | |
| 266 | + if src.ID != repo.ID { | |
| 267 | + label = src.Path() + ":" + b.Name | |
| 268 | + } | |
| 269 | + opts = append(opts, sourceOption{Value: src.Path() + ":" + b.Name, Label: label}) | |
| 270 | + } | |
| 271 | + } | |
| 272 | + add(repo) | |
| 273 | + if user != nil { | |
| 274 | + forks, _ := s.store.Forks(repo.ID, user.ID) | |
| 275 | + for _, f := range forks { | |
| 276 | + if f.Owner.ID == user.ID { | |
| 277 | + add(f) | |
| 278 | + } | |
| 279 | + } | |
| 280 | + } | |
| 281 | + return opts | |
| 282 | +} | |
| 283 | + | |
| 284 | +func (s *Server) newPull(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 285 | + rp, g, user, ok := s.pullAccess(w, r, repo) | |
| 286 | + if !ok { | |
| 287 | + return | |
| 288 | + } | |
| 289 | + if user == nil { | |
| 290 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 291 | + return | |
| 292 | + } | |
| 293 | + branches, _ := g.Refs("refs/heads") | |
| 294 | + s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: r.URL.Query().Get("error"), | |
| 295 | + Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.URL.Query().Get("source"), "Target": r.URL.Query().Get("target")}}) | |
| 296 | +} | |
| 297 | + | |
| 298 | +func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, string, error) { | |
| 299 | + path, branch, ok := strings.Cut(value, ":") | |
| 300 | + if !ok || branch == "" { | |
| 301 | + return Repo{}, "", errors.New("Choose a source branch") | |
| 302 | + } | |
| 303 | + owner, name, ok := strings.Cut(strings.TrimPrefix(path, "~"), "/") | |
| 304 | + if !ok { | |
| 305 | + return Repo{}, "", errors.New("Choose a source branch") | |
| 306 | + } | |
| 307 | + src, err := s.store.RepoByPath(owner, name) | |
| 308 | + if err != nil { | |
| 309 | + return Repo{}, "", errors.New("No such source repository") | |
| 310 | + } | |
| 311 | + if src.ID != repo.ID && (src.Parent != repo.ID || user == nil || src.Owner.ID != user.ID) { | |
| 312 | + return Repo{}, "", errors.New("Source must be this repository or your own fork of it") | |
| 313 | + } | |
| 314 | + if _, err := (Git{Dir: s.repoDir(src)}).Resolve("refs/heads/" + branch); err != nil { | |
| 315 | + return Repo{}, "", errors.New("No such source branch") | |
| 316 | + } | |
| 317 | + return src, branch, nil | |
| 318 | +} | |
| 319 | + | |
| 320 | +func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 321 | + rp, g, user, ok := s.pullAccess(w, r, repo) | |
| 322 | + if !ok { | |
| 323 | + return | |
| 324 | + } | |
| 325 | + if user == nil { | |
| 326 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 327 | + return | |
| 328 | + } | |
| 329 | + retry := func(msg string) { | |
| 330 | + branches, _ := g.Refs("refs/heads") | |
| 331 | + w.WriteHeader(400) | |
| 332 | + s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg, | |
| 333 | + Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.FormValue("source"), "Target": r.FormValue("target"), "PullTitle": r.FormValue("title"), "Body": r.FormValue("body")}}) | |
| 334 | + } | |
| 335 | + src, branch, err := s.resolveSource(repo, user, r.FormValue("source")) | |
| 336 | + if err != nil { | |
| 337 | + retry(err.Error()) | |
| 338 | + return | |
| 339 | + } | |
| 340 | + target := r.FormValue("target") | |
| 341 | + if _, err := g.Resolve("refs/heads/" + target); err != nil { | |
| 342 | + retry("No such target branch") | |
| 343 | + return | |
| 344 | + } | |
| 345 | + if src.ID == repo.ID && branch == target { | |
| 346 | + retry("Source and target are the same branch") | |
| 347 | + return | |
| 348 | + } | |
| 349 | + pull, err := s.store.CreatePull(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")), src, branch, target) | |
| 350 | + if err != nil { | |
| 351 | + retry(err.Error()) | |
| 352 | + return | |
| 353 | + } | |
| 354 | + if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil { | |
| 355 | + retry("Could not fetch the source branch: " + err.Error()) | |
| 356 | + return | |
| 357 | + } | |
| 358 | + http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther) | |
| 359 | +} | |
| 360 | + | |
| 361 | +func (s *Server) loadPull(w http.ResponseWriter, r *http.Request, repo Repo, rest string) (repoPage, Git, *User, Pull, string, bool) { | |
| 362 | + rp, g, user, ok := s.pullAccess(w, r, repo) | |
| 363 | + if !ok { | |
| 364 | + return rp, g, user, Pull{}, "", false | |
| 365 | + } | |
| 366 | + numStr, action, _ := strings.Cut(rest, "/") | |
| 367 | + number, err := strconv.ParseInt(numStr, 10, 64) | |
| 368 | + if err != nil { | |
| 369 | + s.fail(w, r, 404, "No such pull request") | |
| 370 | + return rp, g, user, Pull{}, "", false | |
| 371 | + } | |
| 372 | + pull, err := s.store.Pull(repo, number) | |
| 373 | + if err != nil { | |
| 374 | + s.fail(w, r, 404, "No such pull request") | |
| 375 | + return rp, g, user, Pull{}, "", false | |
| 376 | + } | |
| 377 | + return rp, g, user, pull, action, true | |
| 378 | +} | |
| 379 | + | |
| 380 | +func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest string) { | |
| 381 | + rp, g, user, pull, action, ok := s.loadPull(w, r, repo, rest) | |
| 382 | + if !ok { | |
| 383 | + return | |
| 384 | + } | |
| 385 | + isOwner := user != nil && user.ID == repo.Owner.ID | |
| 386 | + isAuthor := user != nil && user.ID == pull.Author.ID | |
| 387 | + if r.Method == "POST" { | |
| 388 | + if user == nil { | |
| 389 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 390 | + return | |
| 391 | + } | |
| 392 | + back := fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number) | |
| 393 | + switch action { | |
| 394 | + case "comment": | |
| 395 | + if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil { | |
| 396 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 397 | + return | |
| 398 | + } | |
| 399 | + case "close": | |
| 400 | + if (isOwner || isAuthor) && pull.State == "open" { | |
| 401 | + s.store.SetPullState(pull.ID, "closed", "") | |
| 402 | + } | |
| 403 | + case "reopen": | |
| 404 | + if (isOwner || isAuthor) && pull.State == "closed" { | |
| 405 | + s.store.SetPullState(pull.ID, "open", "") | |
| 406 | + } | |
| 407 | + case "update": | |
| 408 | + if (isOwner || isAuthor) && pull.State == "open" { | |
| 409 | + if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil { | |
| 410 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 411 | + return | |
| 412 | + } | |
| 413 | + s.store.TouchPull(pull.ID) | |
| 414 | + } | |
| 415 | + case "merge": | |
| 416 | + if !isOwner || pull.State != "open" { | |
| 417 | + s.fail(w, r, 403, "Only the repository owner can merge") | |
| 418 | + return | |
| 419 | + } | |
| 420 | + hash, err := g.Merge(pull.TargetBranch, pull.Ref(), fmt.Sprintf("Merge pull request #%d from %s\n\n%s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch, pull.Title), user.Name, user.Name+"@"+s.host) | |
| 421 | + if err != nil { | |
| 422 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 423 | + return | |
| 424 | + } | |
| 425 | + s.store.SetPullState(pull.ID, "merged", hash) | |
| 426 | + default: | |
| 427 | + s.fail(w, r, 404, "Page not found") | |
| 428 | + return | |
| 429 | + } | |
| 430 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 431 | + return | |
| 432 | + } | |
| 433 | + head, err := g.Resolve(pull.Ref()) | |
| 434 | + if err != nil { | |
| 435 | + s.fail(w, r, 500, "Pull request head is missing") | |
| 436 | + return | |
| 437 | + } | |
| 438 | + data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor, | |
| 439 | + "Body": s.renderMarkdown([]byte(pull.Body))} | |
| 440 | + base := pull.TargetBranch | |
| 441 | + if pull.State == "merged" && pull.Merged != "" { | |
| 442 | + base = pull.Merged + "^1" | |
| 443 | + } | |
| 444 | + if mb, err := g.MergeBase(base, head); err == nil { | |
| 445 | + data["Base"] = mb | |
| 446 | + commits, _ := g.LogRange(mb, head, 200) | |
| 447 | + data["Commits"] = commits | |
| 448 | + files, _ := g.DiffRange(mb, head) | |
| 449 | + data["Files"] = files | |
| 450 | + added, removed := 0, 0 | |
| 451 | + for _, f := range files { | |
| 452 | + added += f.Added | |
| 453 | + removed += f.Removed | |
| 454 | + } | |
| 455 | + data["Added"], data["Removed"] = added, removed | |
| 456 | + if pull.State == "open" { | |
| 457 | + data["Mergeable"] = g.Mergeable(pull.TargetBranch, head) | |
| 458 | + if cur, err := g.Resolve("refs/heads/" + pull.TargetBranch); err == nil && cur == head { | |
| 459 | + data["Mergeable"] = false | |
| 460 | + } | |
| 461 | + } | |
| 462 | + } else { | |
| 463 | + data["Unrelated"] = true | |
| 464 | + } | |
| 465 | + comments, _ := s.store.Comments(pull.ID) | |
| 466 | + rendered := make([]map[string]any, len(comments)) | |
| 467 | + for i, c := range comments { | |
| 468 | + rendered[i] = map[string]any{"Comment": c, "Body": s.renderMarkdown([]byte(c.Body))} | |
| 469 | + } | |
| 470 | + data["Comments"] = rendered | |
| 471 | + s.render(w, r, "pull", page{Title: fmt.Sprintf("#%d %s", pull.Number, pull.Title), Error: r.URL.Query().Get("error"), Math: hasMath(data["Body"]), Data: data}) | |
| 472 | +} | |
| 473 | + | |
| 474 | +func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 475 | + user := s.require(w, r) | |
| 476 | + if user == nil { | |
| 477 | + return | |
| 478 | + } | |
| 479 | + if user.ID == repo.Owner.ID { | |
| 480 | + http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 481 | + return | |
| 482 | + } | |
| 483 | + fork, err := s.store.ForkRepo(*user, repo) | |
| 484 | + if err != nil { | |
| 485 | + s.fail(w, r, 400, err.Error()) | |
| 486 | + return | |
| 487 | + } | |
| 488 | + dir := s.repoDir(fork) | |
| 489 | + if err := os.MkdirAll(filepath.Dir(dir), 0o755); err == nil { | |
| 490 | + err = cloneRepo(s.repoDir(repo), dir) | |
| 491 | + } | |
| 492 | + if err != nil { | |
| 493 | + s.store.DeleteRepo(fork.ID) | |
| 494 | + s.fail(w, r, 500, err.Error()) | |
| 495 | + return | |
| 496 | + } | |
| 497 | + http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) | |
| 498 | +} | |
static/style.css modified
| @@ -171,3 +171,36 @@ input[type=file]{border:0;padding:0} | ||
| 171 | 171 | .cropper canvas{display:block;max-width:100%;border:1px solid var(--line);cursor:move;touch-action:none} |
| 172 | 172 | .cropper input[type=range]{width:100%;padding:0} |
| 173 | 173 | @media(max-width:800px){.cards,.cards.commits,.columns,.columns.user,.pair,.avatarform{grid-template-columns:1fr}.profile .avatar{width:120px;height:120px}} |
| 174 | +.twocol{display:grid;grid-template-columns:1fr 1fr;gap:2rem;margin-bottom:2rem} | |
| 175 | +.twocol label{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.8rem} | |
| 176 | +.twocol .legend{display:block;margin-bottom:.4rem} | |
| 177 | +.twocol label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem} | |
| 178 | +.twocol label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem} | |
| 179 | +h3.plain{border:0;font-size:1.25rem} | |
| 180 | +.columns.browse{grid-template-columns:minmax(0,2fr) 1fr} | |
| 181 | +.search input[name=search]{width:100%} | |
| 182 | +.search .sort{display:flex;justify-content:flex-end;align-items:center;gap:1rem;margin:.5rem 0 1rem} | |
| 183 | +.search .sort label{display:flex;align-items:center;gap:.3rem} | |
| 184 | +.event .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.9rem} | |
| 185 | +.event p.tags{margin-top:.15rem} | |
| 186 | +.btn.block{display:block;text-align:center} | |
| 187 | +.titlerow{display:flex;align-items:center;gap:1rem} | |
| 188 | +.titlerow form{margin:0} | |
| 189 | +.count{background:#e9ecef;border-radius:.6rem;padding:0 .4rem;font-size:.8rem;color:var(--fg)} | |
| 190 | +.pullhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem} | |
| 191 | +nav.filters a{margin-right:1rem;color:var(--muted)} | |
| 192 | +nav.filters a.active{color:var(--fg);font-weight:600} | |
| 193 | +.state{display:inline-block;font-size:.75rem;padding:0 .4rem;border-radius:3px;color:#fff;vertical-align:middle;text-transform:lowercase} | |
| 194 | +.state.open{background:#1a7f37} | |
| 195 | +.state.merged{background:#6f42c1} | |
| 196 | +.state.closed{background:#c0392b} | |
| 197 | +.pulltitle{margin-bottom:1rem} | |
| 198 | +.pulltitle p{margin:.2rem 0} | |
| 199 | +.columns.pullbody{grid-template-columns:minmax(0,1fr) 20rem;margin-bottom:1.5rem} | |
| 200 | +.comment{border:1px solid var(--line);margin-bottom:.8rem} | |
| 201 | +.comment .who{background:var(--soft);padding:.3rem .6rem;border-bottom:1px solid var(--line)} | |
| 202 | +.comment .readme{padding:.4rem .6rem} | |
| 203 | +.comment .readme p{margin:.3rem 0} | |
| 204 | +.btn.block+form{margin-top:.4rem} | |
| 205 | +.btn:disabled{opacity:.5;cursor:default} | |
| 206 | +select{font:inherit;padding:.35rem .5rem;border:1px solid #ced4da;background:#fff} | |
store.go modified
| @@ -65,8 +65,16 @@ var migrations = []string{ | ||
| 65 | 65 | `alter table users add column website text not null default ''`, |
| 66 | 66 | `alter table users add column avatar integer not null default 0`, |
| 67 | 67 | `alter table users add column display text not null default ''`, |
| 68 | + `alter table repos add column tags text not null default ''`, | |
| 69 | + `alter table repos add column parent integer not null default 0`, | |
| 68 | 70 | } |
| 69 | 71 | |
| 72 | +const ( | |
| 73 | + Public = 0 | |
| 74 | + Private = 1 | |
| 75 | + Unlisted = 2 | |
| 76 | +) | |
| 77 | + | |
| 70 | 78 | type Profile struct { |
| 71 | 79 | Display string |
| 72 | 80 | Bio string |
| @@ -92,7 +100,38 @@ type Repo struct { | ||
| 92 | 100 | Name string |
| 93 | 101 | Description string |
| 94 | 102 | Private bool |
| 103 | + Unlisted bool | |
| 104 | + Tags []string | |
| 105 | + Parent int64 | |
| 95 | 106 | Created time.Time |
| 107 | + Updated time.Time | |
| 108 | +} | |
| 109 | + | |
| 110 | +var tagRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{0,23}$`) | |
| 111 | + | |
| 112 | +func parseTags(raw string) ([]string, error) { | |
| 113 | + var tags []string | |
| 114 | + for _, t := range strings.Split(raw, ",") { | |
| 115 | + t = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t), "#"))) | |
| 116 | + if t == "" { | |
| 117 | + continue | |
| 118 | + } | |
| 119 | + if !tagRe.MatchString(t) { | |
| 120 | + return nil, errors.New("Tags are lowercase letters, digits, dot, plus or dash, at most 24 characters") | |
| 121 | + } | |
| 122 | + tags = append(tags, t) | |
| 123 | + } | |
| 124 | + if len(tags) > 3 { | |
| 125 | + return nil, errors.New("Up to three tags") | |
| 126 | + } | |
| 127 | + return tags, nil | |
| 128 | +} | |
| 129 | + | |
| 130 | +func splitTags(raw string) []string { | |
| 131 | + if raw == "" { | |
| 132 | + return nil | |
| 133 | + } | |
| 134 | + return strings.Split(raw, ",") | |
| 96 | 135 | } |
| 97 | 136 | |
| 98 | 137 | type Key struct { |
| @@ -131,6 +170,9 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 131 | 170 | return nil, err |
| 132 | 171 | } |
| 133 | 172 | } |
| 173 | + if _, err := db.Exec(pullSchema); err != nil { | |
| 174 | + return nil, err | |
| 175 | + } | |
| 134 | 176 | } |
| 135 | 177 | return &Store{db}, nil |
| 136 | 178 | } |
| @@ -355,13 +397,17 @@ func (s *Store) UserByToken(name, token string) (User, bool) { | ||
| 355 | 397 | return u, err == nil |
| 356 | 398 | } |
| 357 | 399 | |
| 358 | -func (s *Store) CreateRepo(owner User, name, description string, private bool) (Repo, error) { | |
| 400 | +func (s *Store) CreateRepo(owner User, name, description string, visibility int, rawTags string) (Repo, error) { | |
| 359 | 401 | if !validName(name) || name == "avatar" { |
| 360 | 402 | return Repo{}, errors.New("Repository names are lowercase letters, digits, dot, dash or underscore") |
| 361 | 403 | } |
| 404 | + tags, err := parseTags(rawTags) | |
| 405 | + if err != nil { | |
| 406 | + return Repo{}, err | |
| 407 | + } | |
| 362 | 408 | now := time.Now() |
| 363 | - res, err := s.db.Exec(`insert into repos(owner, name, description, private, created) values (?, ?, ?, ?, ?)`, | |
| 364 | - owner.ID, name, description, private, now.Unix()) | |
| 409 | + res, err := s.db.Exec(`insert into repos(owner, name, description, private, tags, created) values (?, ?, ?, ?, ?, ?)`, | |
| 410 | + owner.ID, name, description, visibility, strings.Join(tags, ","), now.Unix()) | |
| 365 | 411 | if err != nil { |
| 366 | 412 | if strings.Contains(err.Error(), "UNIQUE") { |
| 367 | 413 | return Repo{}, errors.New("You already have a repository with that name") |
| @@ -369,7 +415,7 @@ func (s *Store) CreateRepo(owner User, name, description string, private bool) ( | ||
| 369 | 415 | return Repo{}, err |
| 370 | 416 | } |
| 371 | 417 | id, _ := res.LastInsertId() |
| 372 | - return Repo{ID: id, Owner: owner, Name: name, Description: description, Private: private, Created: now}, nil | |
| 418 | + return Repo{ID: id, Owner: owner, Name: name, Description: description, Private: visibility == Private, Unlisted: visibility == Unlisted, Tags: tags, Created: now}, nil | |
| 373 | 419 | } |
| 374 | 420 | |
| 375 | 421 | func scanRepos(rows *sql.Rows) ([]Repo, error) { |
| @@ -378,11 +424,14 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) { | ||
| 378 | 424 | for rows.Next() { |
| 379 | 425 | var r Repo |
| 380 | 426 | var private int |
| 427 | + var tags string | |
| 381 | 428 | var created, ucreated int64 |
| 382 | - if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil { | |
| 429 | + if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &tags, &r.Parent, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil { | |
| 383 | 430 | return nil, err |
| 384 | 431 | } |
| 385 | - r.Private = private != 0 | |
| 432 | + r.Private = private == Private | |
| 433 | + r.Unlisted = private == Unlisted | |
| 434 | + r.Tags = splitTags(tags) | |
| 386 | 435 | r.Created = time.Unix(created, 0) |
| 387 | 436 | r.Owner.Created = time.Unix(ucreated, 0) |
| 388 | 437 | repos = append(repos, r) |
| @@ -390,7 +439,19 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) { | ||
| 390 | 439 | return repos, rows.Err() |
| 391 | 440 | } |
| 392 | 441 | |
| 393 | -const repoColumns = `r.id, r.name, r.description, r.private, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner` | |
| 442 | +const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner` | |
| 443 | + | |
| 444 | +func (s *Store) RepoByID(id int64) (Repo, error) { | |
| 445 | + rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, id) | |
| 446 | + if err != nil { | |
| 447 | + return Repo{}, err | |
| 448 | + } | |
| 449 | + repos, err := scanRepos(rows) | |
| 450 | + if err != nil || len(repos) == 0 { | |
| 451 | + return Repo{}, ErrNotFound | |
| 452 | + } | |
| 453 | + return repos[0], nil | |
| 454 | +} | |
| 394 | 455 | |
| 395 | 456 | func (s *Store) RepoByPath(owner, name string) (Repo, error) { |
| 396 | 457 | rows, err := s.db.Query(`select `+repoColumns+` where u.name = ? and r.name = ?`, owner, name) |
| @@ -423,11 +484,25 @@ func (s *Store) RecentRepos(n int) ([]Repo, error) { | ||
| 423 | 484 | return scanRepos(rows) |
| 424 | 485 | } |
| 425 | 486 | |
| 426 | -func (s *Store) UpdateRepo(id int64, description string, private bool) error { | |
| 427 | - _, err := s.db.Exec(`update repos set description = ?, private = ? where id = ?`, description, private, id) | |
| 487 | +func (s *Store) UpdateRepo(id int64, description string, visibility int, rawTags string) error { | |
| 488 | + tags, err := parseTags(rawTags) | |
| 489 | + if err != nil { | |
| 490 | + return err | |
| 491 | + } | |
| 492 | + _, err = s.db.Exec(`update repos set description = ?, private = ?, tags = ? where id = ?`, description, visibility, strings.Join(tags, ","), id) | |
| 428 | 493 | return err |
| 429 | 494 | } |
| 430 | 495 | |
| 496 | +func (s *Store) PublicRepos(query, tag string) ([]Repo, error) { | |
| 497 | + like := "%" + strings.ReplaceAll(strings.ReplaceAll(query, "%", ""), "_", "") + "%" | |
| 498 | + rows, err := s.db.Query(`select `+repoColumns+` where r.private = 0 and (? = '' or r.name like ? or r.description like ? or u.name like ? or r.tags like ?) and (? = '' or ',' || r.tags || ',' like ?) order by r.created desc limit 500`, | |
| 499 | + query, like, like, like, like, tag, "%,"+tag+",%") | |
| 500 | + if err != nil { | |
| 501 | + return nil, err | |
| 502 | + } | |
| 503 | + return scanRepos(rows) | |
| 504 | +} | |
| 505 | + | |
| 431 | 506 | func (s *Store) DeleteRepo(id int64) error { |
| 432 | 507 | _, err := s.db.Exec(`delete from repos where id = ?`, id) |
| 433 | 508 | return err |
templates/create.html modified
| @@ -1,9 +1,17 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | -<div class="panel narrow"><h3>Create a new repository</h3> | |
| 3 | -<form method="post" action="/create" class="stack"> | |
| 4 | -<label>Name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label> | |
| 5 | -<label>Description <input name="description" maxlength="200"></label> | |
| 6 | -<label class="check"><input type="checkbox" name="private"> This repository is private</label> | |
| 7 | -<div class="actions"><button class="btn primary">Create ▸</button></div> | |
| 8 | -</form></div> | |
| 2 | +<h3>Create new repository</h3> | |
| 3 | +<form method="post" action="/create" class="twocol"> | |
| 4 | +<div> | |
| 5 | +<label>Repository name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}" placeholder="my-cool-project" value="{{.Data.Name}}"></label> | |
| 6 | +<label>Description <input name="description" maxlength="200" placeholder="Short description, shown in lists" value="{{.Data.Description}}"></label> | |
| 7 | +<label>Tags <input name="tags" placeholder="Up to three comma-separated topics" value="{{.Data.Tags}}"></label> | |
| 8 | +<div class="actions"><button class="btn primary">Create repository ▸</button></div> | |
| 9 | +</div> | |
| 10 | +<div> | |
| 11 | +<span class="legend">Visibility</span> | |
| 12 | +<label class="radio"><input type="radio" name="visibility" value="public" checked> Public<small>Shown on your profile and listed in the public index</small></label> | |
| 13 | +<label class="radio"><input type="radio" name="visibility" value="unlisted"> Unlisted<small>Visible to anyone who knows the URL, but not shown on your profile</small></label> | |
| 14 | +<label class="radio"><input type="radio" name="visibility" value="private"> Private<small>Only visible to you</small></label> | |
| 15 | +</div> | |
| 16 | +</form> | |
| 9 | 17 | {{end}} |
templates/index.html modified
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | </div> |
| 15 | 15 | <div class="cards"> |
| 16 | 16 | <section class="panel"><h3>Host a repository</h3><p>Registration is by invitation. Once you have an account, push any repository and it gets a page with its history, refs and README.</p><a class="btn primary" href="/register">Register ▸</a></section> |
| 17 | -<section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="#recent">Recent repositories ▸</a></section> | |
| 17 | +<section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="/projects">Browse public projects ▸</a></section> | |
| 18 | 18 | <section class="panel"><h3>Already a member?</h3><p>Log in to see your repositories, manage SSH keys and tokens, and edit your profile.</p><a class="btn" href="/login">Log in ▸</a></section> |
| 19 | 19 | </div> |
| 20 | 20 | {{end}} |
| @@ -25,5 +25,5 @@ | ||
| 25 | 25 | </section> |
| 26 | 26 | {{end}} |
| 27 | 27 | {{define "repolist"}} |
| 28 | -{{if .}}<div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="muted">Nothing here yet.</p>{{end}} | |
| 28 | +{{if .}}<div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="muted">Nothing here yet.</p>{{end}} | |
| 29 | 29 | {{end}} |
templates/layout.html modified
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | <header> |
| 16 | 16 | <nav class="site"> |
| 17 | 17 | <a class="brand" href="/">{{.Host}}</a> |
| 18 | +<a href="/projects">projects</a> | |
| 18 | 19 | <span class="spacer"></span> |
| 19 | 20 | {{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<a href="/login">Log in</a> — <a href="/register">Register</a>{{end}} |
| 20 | 21 | </nav> |
| @@ -29,10 +30,10 @@ | ||
| 29 | 30 | </header> |
| 30 | 31 | {{with .Data}}{{with index . "Page"}} |
| 31 | 32 | <div class="repohead"> |
| 32 | -<div class="inner"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div> | |
| 33 | +<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn">Fork ▸</button></form>{{end}}</div>{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div> | |
| 33 | 34 | <div class="tabbar"><nav class="tabs inner"> |
| 34 | 35 | <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a> |
| 35 | -{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a>{{end}} | |
| 36 | +{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}} | |
| 36 | 37 | {{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}} |
| 37 | 38 | </nav></div> |
| 38 | 39 | </div> |
templates/newpull.html added
| @@ -0,0 +1,15 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +<h3>New pull request</h3> | |
| 4 | +<form method="post" action="/{{$p.Repo.Path}}/pulls/new" class="twocol"> | |
| 5 | +<div> | |
| 6 | +<label>Title <input name="title" required autofocus maxlength="200" value="{{.Data.PullTitle}}"></label> | |
| 7 | +<label>Description <textarea name="body" rows="8" placeholder="Markdown supported">{{.Data.Body}}</textarea></label> | |
| 8 | +<div class="actions"><button class="btn primary">Create pull request ▸</button></div> | |
| 9 | +</div> | |
| 10 | +<div> | |
| 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>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 | +</div> | |
| 14 | +</form> | |
| 15 | +{{end}} | |
templates/projects.html added
| @@ -0,0 +1,24 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="columns browse"> | |
| 3 | +<div class="main"> | |
| 4 | +<h3 class="plain">Browse public projects</h3> | |
| 5 | +<form method="get" action="/projects" class="search"> | |
| 6 | +<input name="search" value="{{.Data.Query}}" placeholder="Search all public projects"> | |
| 7 | +<div class="sort"> | |
| 8 | +<span>Sort results by</span> | |
| 9 | +<label><input type="radio" name="sort" value="updated"{{if ne .Data.Sort "created"}} checked{{end}}> Recently updated</label> | |
| 10 | +<label><input type="radio" name="sort" value="created"{{if eq .Data.Sort "created"}} checked{{end}}> Recently created</label> | |
| 11 | +{{if .Data.Tag}}<input type="hidden" name="tag" value="{{.Data.Tag}}">{{end}} | |
| 12 | +<button class="btn">Apply ▸</button> | |
| 13 | +</div> | |
| 14 | +</form> | |
| 15 | +{{if .Data.Tag}}<p class="muted">Showing projects tagged <a href="/projects?tag={{.Data.Tag}}">#{{.Data.Tag}}</a> · <a href="/projects">clear</a></p>{{end}} | |
| 16 | +{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">No projects match.</p>{{end}} | |
| 17 | +</div> | |
| 18 | +<aside> | |
| 19 | +<h3 class="plain">About</h3> | |
| 20 | +<p>{{.Data.Public}} public projects from {{.Data.Users}} users on {{.Host}}.</p> | |
| 21 | +{{if .User}}<a class="btn block" href="/create">Create a new repository ▸</a>{{else}}<a class="btn block" href="/register">Register ▸</a>{{end}} | |
| 22 | +</aside> | |
| 23 | +</div> | |
| 24 | +{{end}} | |
templates/pull.html added
| @@ -0,0 +1,50 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +{{$pr := .Data.Pull}} | |
| 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> | |
| 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 | +</div> | |
| 8 | +<div class="columns pullbody"> | |
| 9 | +<div class="main"> | |
| 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 | +{{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 | +{{if .User}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/comment" class="stack wide"> | |
| 13 | +<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label> | |
| 14 | +<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 | +</div> | |
| 17 | +<aside> | |
| 18 | +<div class="panel"> | |
| 19 | +{{if eq $pr.State "open"}} | |
| 20 | +{{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}} | |
| 22 | +{{if .Data.Owner}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/merge"><button class="btn primary block"{{if not .Data.Mergeable}} disabled{{end}}>Merge pull request ▸</button></form>{{end}} | |
| 23 | +{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/update"><button class="btn block">Update from source ▸</button></form> | |
| 24 | +<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/close"><button class="btn danger block">Close ▸</button></form>{{end}} | |
| 25 | +{{else if eq $pr.State "closed"}}<p>Closed without merging.</p>{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}} | |
| 26 | +{{else}}<p>Merged into <code>{{$pr.TargetBranch}}</code>.</p>{{end}} | |
| 27 | +<p class="muted small">Fetch locally with<br><code>git fetch https://{{.Host}}/{{$p.Repo.Path}} {{$pr.Ref}}</code></p> | |
| 28 | +</div> | |
| 29 | +</aside> | |
| 30 | +</div> | |
| 31 | +{{if .Data.Commits}} | |
| 32 | +<h3>Commits</h3> | |
| 33 | +<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div> | |
| 34 | +{{end}} | |
| 35 | +{{if .Data.Files}} | |
| 36 | +<h3>Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></span></h3> | |
| 37 | +{{range .Data.Files}} | |
| 38 | +<section class="diff" id="{{.New}}"> | |
| 39 | +<h4><a href="/{{$p.Repo.Path}}/tree/{{$.Data.Head}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h4> | |
| 40 | +{{if .Binary}}<p class="muted">Binary file.</p>{{end}} | |
| 41 | +{{range .Hunks}} | |
| 42 | +<table class="hunk"> | |
| 43 | +<tr class="head"><td colspan="3">{{.Header}}</td></tr> | |
| 44 | +{{range .Lines}}<tr class="{{.Kind}}"><td class="n">{{if .Old}}{{.Old}}{{end}}</td><td class="n">{{if .New}}{{.New}}{{end}}</td><td class="l">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else if eq .Kind "ctx"}} {{end}}{{.Text}}</td></tr>{{end}} | |
| 45 | +</table> | |
| 46 | +{{end}} | |
| 47 | +</section> | |
| 48 | +{{end}} | |
| 49 | +{{end}} | |
| 50 | +{{end}} | |
templates/pulls.html added
| @@ -0,0 +1,8 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +<div class="pullhead"> | |
| 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 | +{{if $p.User}}<a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}} | |
| 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}} | |
| 8 | +{{end}} | |
templates/reposettings.html modified
| @@ -1,15 +1,23 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | -<div class="panel"><h3>Repository settings</h3> | |
| 4 | -<form method="post" action="/{{$p.Repo.Path}}/settings" class="stack"> | |
| 3 | +<h3>Repository settings</h3> | |
| 4 | +<form method="post" action="/{{$p.Repo.Path}}/settings" class="twocol"> | |
| 5 | +<div> | |
| 5 | 6 | <label>Description <input name="description" value="{{$p.Repo.Description}}" maxlength="200"></label> |
| 6 | -<label class="check"><input type="checkbox" name="private"{{if $p.Repo.Private}} checked{{end}}> This repository is private</label> | |
| 7 | -<div class="actions left"><button class="btn primary">Save ▸</button></div> | |
| 8 | -</form></div> | |
| 9 | -<div class="panel"><h3>Delete repository</h3> | |
| 10 | -<p>This permanently removes the repository and its history from the server.</p> | |
| 7 | +<label>Tags <input name="tags" value="{{range $i, $t := $p.Repo.Tags}}{{if $i}}, {{end}}{{$t}}{{end}}" placeholder="Up to three comma-separated topics"></label> | |
| 8 | +<div class="actions"><button class="btn primary">Save ▸</button></div> | |
| 9 | +</div> | |
| 10 | +<div> | |
| 11 | +<span class="legend">Visibility</span> | |
| 12 | +<label class="radio"><input type="radio" name="visibility" value="public"{{if not (or $p.Repo.Private $p.Repo.Unlisted)}} checked{{end}}> Public<small>Shown on your profile and listed in the public index</small></label> | |
| 13 | +<label class="radio"><input type="radio" name="visibility" value="unlisted"{{if $p.Repo.Unlisted}} checked{{end}}> Unlisted<small>Visible to anyone who knows the URL, but not shown on your profile</small></label> | |
| 14 | +<label class="radio"><input type="radio" name="visibility" value="private"{{if $p.Repo.Private}} checked{{end}}> Private<small>Only visible to you</small></label> | |
| 15 | +</div> | |
| 16 | +</form> | |
| 17 | +<h3>Delete repository</h3> | |
| 11 | 18 | <form method="post" action="/{{$p.Repo.Path}}/delete" class="stack"> |
| 19 | +<p>This permanently removes the repository and its history from the server.</p> | |
| 12 | 20 | <label>Type <code>{{$p.Repo.Name}}</code> to confirm <input name="confirm" required></label> |
| 13 | 21 | <div class="actions left"><button class="btn danger">Delete repository ▸</button></div> |
| 14 | -</form></div> | |
| 22 | +</form> | |
| 15 | 23 | {{end}} |
templates/user.html modified
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | {{if $own}}<a class="btn primary block" href="/create">Create a new repository ▸</a><a class="btn block" href="/settings">Edit your profile ▸</a>{{end}} |
| 17 | 17 | </aside> |
| 18 | 18 | <div class="main"> |
| 19 | -{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}} | |
| 19 | +{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}} | |
| 20 | 20 | </div> |
| 21 | 21 | </div> |
| 22 | 22 | {{end}} |
web.go modified
| @@ -13,6 +13,7 @@ import ( | ||
| 13 | 13 | "os" |
| 14 | 14 | "path/filepath" |
| 15 | 15 | "regexp" |
| 16 | + "slices" | |
| 16 | 17 | "strconv" |
| 17 | 18 | "strings" |
| 18 | 19 | "time" |
| @@ -135,6 +136,7 @@ func (s *Server) handler() http.Handler { | ||
| 135 | 136 | w.Write(css) |
| 136 | 137 | }) |
| 137 | 138 | mux.HandleFunc("GET /{$}", s.index) |
| 139 | + mux.HandleFunc("GET /projects", s.projects) | |
| 138 | 140 | mux.HandleFunc("GET /login", s.loginForm) |
| 139 | 141 | mux.HandleFunc("POST /login", s.login) |
| 140 | 142 | mux.HandleFunc("POST /logout", s.logout) |
| @@ -267,7 +269,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 267 | 269 | return |
| 268 | 270 | } |
| 269 | 271 | name := strings.TrimSpace(r.FormValue("name")) |
| 270 | - repo, err := s.store.CreateRepo(*u, name, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on") | |
| 272 | + repo, err := s.store.CreateRepo(*u, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")) | |
| 271 | 273 | if err != nil { |
| 272 | 274 | w.WriteHeader(400) |
| 273 | 275 | s.render(w, r, "create", page{Title: "New repository", User: u, Error: err.Error()}) |
| @@ -285,6 +287,55 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 285 | 287 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 286 | 288 | } |
| 287 | 289 | |
| 290 | +func visibility(v string) int { | |
| 291 | + switch v { | |
| 292 | + case "private": | |
| 293 | + return Private | |
| 294 | + case "unlisted": | |
| 295 | + return Unlisted | |
| 296 | + } | |
| 297 | + return Public | |
| 298 | +} | |
| 299 | + | |
| 300 | +func (s *Server) projects(w http.ResponseWriter, r *http.Request) { | |
| 301 | + query := strings.TrimSpace(r.URL.Query().Get("search")) | |
| 302 | + tag := strings.TrimSpace(r.URL.Query().Get("tag")) | |
| 303 | + sort := r.URL.Query().Get("sort") | |
| 304 | + repos, err := s.store.PublicRepos(query, tag) | |
| 305 | + if err != nil { | |
| 306 | + s.fail(w, r, 500, err.Error()) | |
| 307 | + return | |
| 308 | + } | |
| 309 | + for i := range repos { | |
| 310 | + repos[i].Updated = repoUpdated(s.repoDir(repos[i])) | |
| 311 | + } | |
| 312 | + if sort != "created" { | |
| 313 | + slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) }) | |
| 314 | + } | |
| 315 | + users, public := s.store.Stats() | |
| 316 | + s.render(w, r, "projects", page{Title: "Browse public projects", Data: map[string]any{"Repos": repos, "Query": query, "Tag": tag, "Sort": sort, "Users": users, "Public": public}}) | |
| 317 | +} | |
| 318 | + | |
| 319 | +func repoUpdated(dir string) time.Time { | |
| 320 | + var latest time.Time | |
| 321 | + consider := func(p string) { | |
| 322 | + if info, err := os.Stat(p); err == nil && info.ModTime().After(latest) { | |
| 323 | + latest = info.ModTime() | |
| 324 | + } | |
| 325 | + } | |
| 326 | + consider(filepath.Join(dir, "packed-refs")) | |
| 327 | + for _, sub := range []string{"heads", "tags"} { | |
| 328 | + entries, _ := os.ReadDir(filepath.Join(dir, "refs", sub)) | |
| 329 | + for _, e := range entries { | |
| 330 | + consider(filepath.Join(dir, "refs", sub, e.Name())) | |
| 331 | + } | |
| 332 | + } | |
| 333 | + if latest.IsZero() { | |
| 334 | + consider(dir) | |
| 335 | + } | |
| 336 | + return latest | |
| 337 | +} | |
| 338 | + | |
| 288 | 339 | func (s *Server) repoDir(repo Repo) string { |
| 289 | 340 | return filepath.Join(s.root, "repos", repo.Owner.Name, repo.Name+".git") |
| 290 | 341 | } |
| @@ -547,6 +598,16 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 547 | 598 | s.log(w, r, repo, "") |
| 548 | 599 | case strings.HasPrefix(rest, "commit/") && r.Method == "GET": |
| 549 | 600 | s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/")) |
| 601 | + case rest == "pulls" && r.Method == "GET": | |
| 602 | + s.pulls(w, r, repo) | |
| 603 | + case rest == "pulls/new" && r.Method == "GET": | |
| 604 | + s.newPull(w, r, repo) | |
| 605 | + case rest == "pulls/new" && r.Method == "POST": | |
| 606 | + s.createPull(w, r, repo) | |
| 607 | + case strings.HasPrefix(rest, "pulls/"): | |
| 608 | + s.pull(w, r, repo, strings.TrimPrefix(rest, "pulls/")) | |
| 609 | + case rest == "fork" && r.Method == "POST": | |
| 610 | + s.fork(w, r, repo) | |
| 550 | 611 | default: |
| 551 | 612 | s.fail(w, r, 404, "Page not found") |
| 552 | 613 | } |
| @@ -559,12 +620,20 @@ type repoPage struct { | ||
| 559 | 620 | Owner bool |
| 560 | 621 | Empty bool |
| 561 | 622 | Tab string |
| 623 | + Open int | |
| 624 | + Parent *Repo | |
| 625 | + User *User | |
| 562 | 626 | } |
| 563 | 627 | |
| 564 | 628 | func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) { |
| 565 | 629 | g := Git{Dir: s.repoDir(repo)} |
| 566 | 630 | user := s.current(r) |
| 567 | - rp := repoPage{Repo: repo, Owner: user != nil && user.ID == repo.Owner.ID} | |
| 631 | + rp := repoPage{Repo: repo, Owner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user} | |
| 632 | + if repo.Parent != 0 { | |
| 633 | + if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private { | |
| 634 | + rp.Parent = &parent | |
| 635 | + } | |
| 636 | + } | |
| 568 | 637 | if g.Empty() { |
| 569 | 638 | rp.Empty = true |
| 570 | 639 | rp.Ref = g.Head() |
| @@ -837,8 +906,10 @@ func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 837 | 906 | s.fail(w, r, 403, "Not your repository") |
| 838 | 907 | return |
| 839 | 908 | } |
| 840 | - if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on"); err != nil { | |
| 841 | - s.fail(w, r, 500, err.Error()) | |
| 909 | + if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")); err != nil { | |
| 910 | + rp.Tab = "settings" | |
| 911 | + w.WriteHeader(400) | |
| 912 | + s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: err.Error(), Data: map[string]any{"Page": rp}}) | |
| 842 | 913 | return |
| 843 | 914 | } |
| 844 | 915 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |