package main import ( "database/sql" "errors" "fmt" "html/template" "net/http" "os" "path/filepath" "strconv" "strings" "time" ) const pullSchema = ` create table if not exists pulls ( id integer primary key, repo integer not null references repos(id) on delete cascade, number integer not null, author integer not null references users(id) on delete cascade, title text not null, body text not null default '', source_repo integer not null, source_branch text not null, target_branch text not null, state text not null default 'open', created integer not null, updated integer not null, merged text not null default '', unique(repo, number) ); create table if not exists comments ( id integer primary key, pull integer not null references pulls(id) on delete cascade, author integer not null references users(id) on delete cascade, body text not null, created integer not null ); ` type Pull struct { ID int64 Number int64 Repo Repo Author User Title string Body string SourceRepo Repo SourceBranch string TargetBranch string State string Created time.Time Updated time.Time Merged string Comments int } type Comment struct { ID int64 Author User Body string Created time.Time } func (p Pull) Ref() string { return fmt.Sprintf("refs/pull/%d/head", p.Number) } func (p Pull) SameRepo() bool { return p.SourceRepo.ID == p.Repo.ID } func (s *Store) CreatePull(repo Repo, author User, title, body string, source Repo, sourceBranch, targetBranch string) (Pull, error) { title = strings.TrimSpace(title) if title == "" || len(title) > 200 { return Pull{}, errors.New("Title is required and at most 200 characters") } now := time.Now().Unix() var number int64 tx, err := s.db.Begin() if err != nil { return Pull{}, err } defer tx.Rollback() tx.QueryRow(`select coalesce(max(number), 0) + 1 from pulls where repo = ?`, repo.ID).Scan(&number) res, err := tx.Exec(`insert into pulls(repo, number, author, title, body, source_repo, source_branch, target_branch, state, created, updated) values (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, repo.ID, number, author.ID, title, body, source.ID, sourceBranch, targetBranch, now, now) if err != nil { return Pull{}, err } if err := tx.Commit(); err != nil { return Pull{}, err } id, _ := res.LastInsertId() return Pull{ID: id, Number: number, Repo: repo, Author: author, Title: title, Body: body, SourceRepo: source, SourceBranch: sourceBranch, TargetBranch: targetBranch, State: "open"}, nil } func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) { defer rows.Close() var pulls []Pull for rows.Next() { var p Pull var created, updated, aCreated int64 var sourceID int64 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 { return nil, err } p.Created, p.Updated, p.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(aCreated, 0) p.SourceRepo.ID = sourceID pulls = append(pulls, p) } return pulls, rows.Err() } 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, (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` func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) { rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and (? = '' or p.state = ?) order by p.number desc`, repo.ID, state, state) if err != nil { return nil, err } pulls, err := s.scanPulls(rows) for i := range pulls { pulls[i].Repo = repo s.fillSource(&pulls[i]) } return pulls, err } func (s *Store) Pull(repo Repo, number int64) (Pull, error) { rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and p.number = ?`, repo.ID, number) if err != nil { return Pull{}, err } pulls, err := s.scanPulls(rows) if err != nil || len(pulls) == 0 { return Pull{}, ErrNotFound } p := pulls[0] p.Repo = repo s.fillSource(&p) return p, nil } func (s *Store) fillSource(p *Pull) { if p.SourceRepo.ID == p.Repo.ID { p.SourceRepo = p.Repo return } rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, p.SourceRepo.ID) if err != nil { return } if repos, _ := scanRepos(rows); len(repos) == 1 { p.SourceRepo = repos[0] } } func (s *Store) SetPullState(id int64, state, merged string) error { _, err := s.db.Exec(`update pulls set state = ?, merged = ?, updated = ? where id = ?`, state, merged, time.Now().Unix(), id) return err } func (s *Store) TouchPull(id int64) { s.db.Exec(`update pulls set updated = ? where id = ?`, time.Now().Unix(), id) } func (s *Store) AddComment(pull int64, author User, body string) error { body = strings.TrimSpace(body) if body == "" || len(body) > 20000 { return errors.New("Comment is empty or too long") } _, err := s.db.Exec(`insert into comments(pull, author, body, created) values (?, ?, ?, ?)`, pull, author.ID, body, time.Now().Unix()) s.TouchPull(pull) return err } func (s *Store) Comments(pull int64) ([]Comment, error) { 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) if err != nil { return nil, err } defer rows.Close() var out []Comment for rows.Next() { var c Comment var created, uCreated int64 if err := rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &uCreated); err != nil { return nil, err } c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0) out = append(out, c) } return out, rows.Err() } func (s *Store) OpenPullCount(repo int64) int { var n int s.db.QueryRow(`select count(*) from pulls where repo = ? and state = 'open'`, repo).Scan(&n) return n } func (s *Store) Forks(parent int64, viewer int64) ([]Repo, error) { rows, err := s.db.Query(`select `+repoColumns+` where r.parent = ? and (r.private = 0 or r.owner = ?) order by u.name`, parent, viewer) if err != nil { return nil, err } return scanRepos(rows) } func (s *Store) ForkRepo(owner User, parent Repo) (Repo, error) { name := parent.Name repo, err := s.CreateRepo(owner, name, parent.Description, Public, strings.Join(parent.Tags, ",")) if err != nil { return Repo{}, err } s.db.Exec(`update repos set parent = ? where id = ?`, parent.ID, repo.ID) repo.Parent = parent.ID return repo, nil } func (s *Server) pullAccess(w http.ResponseWriter, r *http.Request, repo Repo) (repoPage, Git, *User, bool) { rp, g, err := s.repoPage(r, repo, "") if err != nil { s.fail(w, r, 404, "No such ref") return rp, g, nil, false } rp.Tab = "pulls" return rp, g, s.current(r), true } func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _, ok := s.pullAccess(w, r, repo) if !ok { return } state := r.URL.Query().Get("state") if state == "" { state = "open" } if state == "all" { state = "" } pulls, err := s.store.Pulls(repo, state) if err != nil { s.fail(w, r, 500, err.Error()) return } 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")}}) } type sourceOption struct { Value string Label string } func (s *Server) pullSources(repo Repo, user *User) []sourceOption { var opts []sourceOption add := func(src Repo) { g := Git{Dir: s.repoDir(src)} branches, _ := g.Refs("refs/heads") for _, b := range branches { label := b.Name if src.ID != repo.ID { label = src.Path() + ":" + b.Name } opts = append(opts, sourceOption{Value: src.Path() + ":" + b.Name, Label: label}) } } add(repo) if user != nil { forks, _ := s.store.Forks(repo.ID, user.ID) for _, f := range forks { if f.Owner.ID == user.ID { add(f) } } } return opts } func (s *Server) newPull(w http.ResponseWriter, r *http.Request, repo Repo) { rp, g, user, ok := s.pullAccess(w, r, repo) if !ok { return } if user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } branches, _ := g.Refs("refs/heads") s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: r.URL.Query().Get("error"), 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")}}) } func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, string, error) { path, branch, ok := strings.Cut(value, ":") if !ok || branch == "" { return Repo{}, "", errors.New("Choose a source branch") } owner, name, ok := strings.Cut(strings.TrimPrefix(path, "~"), "/") if !ok { return Repo{}, "", errors.New("Choose a source branch") } src, err := s.store.RepoByPath(owner, name) if err != nil { return Repo{}, "", errors.New("No such source repository") } if src.ID != repo.ID && (src.Parent != repo.ID || user == nil || src.Owner.ID != user.ID) { return Repo{}, "", errors.New("Source must be this repository or your own fork of it") } if _, err := (Git{Dir: s.repoDir(src)}).Resolve("refs/heads/" + branch); err != nil { return Repo{}, "", errors.New("No such source branch") } return src, branch, nil } func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { rp, g, user, ok := s.pullAccess(w, r, repo) if !ok { return } if user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } retry := func(msg string) { branches, _ := g.Refs("refs/heads") w.WriteHeader(400) s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg, 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")}}) } src, branch, err := s.resolveSource(repo, user, r.FormValue("source")) if err != nil { retry(err.Error()) return } target := r.FormValue("target") if _, err := g.Resolve("refs/heads/" + target); err != nil { retry("No such target branch") return } if src.ID == repo.ID && branch == target { retry("Source and target are the same branch") return } pull, err := s.store.CreatePull(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")), src, branch, target) if err != nil { retry(err.Error()) return } if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil { retry("Could not fetch the source branch: " + err.Error()) return } http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther) } func (s *Server) loadPull(w http.ResponseWriter, r *http.Request, repo Repo, rest string) (repoPage, Git, *User, Pull, string, bool) { rp, g, user, ok := s.pullAccess(w, r, repo) if !ok { return rp, g, user, Pull{}, "", false } numStr, action, _ := strings.Cut(rest, "/") number, err := strconv.ParseInt(numStr, 10, 64) if err != nil { s.fail(w, r, 404, "No such pull request") return rp, g, user, Pull{}, "", false } pull, err := s.store.Pull(repo, number) if err != nil { s.fail(w, r, 404, "No such pull request") return rp, g, user, Pull{}, "", false } return rp, g, user, pull, action, true } func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest string) { rp, g, user, pull, action, ok := s.loadPull(w, r, repo, rest) if !ok { return } isOwner := user != nil && user.ID == repo.Owner.ID isAuthor := user != nil && user.ID == pull.Author.ID if r.Method == "POST" { if user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } back := fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number) switch action { case "comment": if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } case "close": if (isOwner || isAuthor) && pull.State == "open" { s.store.SetPullState(pull.ID, "closed", "") } case "reopen": if (isOwner || isAuthor) && pull.State == "closed" { s.store.SetPullState(pull.ID, "open", "") } case "update": if (isOwner || isAuthor) && pull.State == "open" { if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } s.store.TouchPull(pull.ID) } case "merge": if !isOwner || pull.State != "open" { s.fail(w, r, 403, "Only the repository owner can merge") return } 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) if err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } s.store.SetPullState(pull.ID, "merged", hash) default: s.fail(w, r, 404, "Page not found") return } http.Redirect(w, r, back, http.StatusSeeOther) return } head, err := g.Resolve(pull.Ref()) if err != nil { s.fail(w, r, 500, "Pull request head is missing") return } data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor, "Body": s.renderMarkdown([]byte(pull.Body))} base := pull.TargetBranch if pull.State == "merged" && pull.Merged != "" { base = pull.Merged + "^1" } if mb, err := g.MergeBase(base, head); err == nil { data["Base"] = mb commits, _ := g.LogRange(mb, head, 200) data["Commits"] = commits files, _ := g.DiffRange(mb, head) data["Files"] = files added, removed := 0, 0 for _, f := range files { added += f.Added removed += f.Removed } data["Added"], data["Removed"] = added, removed if pull.State == "open" { data["Mergeable"] = g.Mergeable(pull.TargetBranch, head) if cur, err := g.Resolve("refs/heads/" + pull.TargetBranch); err == nil && cur == head { data["Mergeable"] = false } } } else { data["Unrelated"] = true } comments, _ := s.store.Comments(pull.ID) rendered := make([]map[string]any, len(comments)) for i, c := range comments { rendered[i] = map[string]any{"Comment": c, "Body": s.renderMarkdown([]byte(c.Body))} } data["Comments"] = rendered 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}) } func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { user := s.require(w, r) if user == nil { return } if user.ID == repo.Owner.ID { http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) return } fork, err := s.store.ForkRepo(*user, repo) if err != nil { s.fail(w, r, 400, err.Error()) return } dir := s.repoDir(fork) if err := os.MkdirAll(filepath.Dir(dir), 0o755); err == nil { err = cloneRepo(s.repoDir(repo), dir) } if err != nil { s.store.DeleteRepo(fork.ID) s.fail(w, r, 500, err.Error()) return } http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) }