package main import ( "database/sql" "errors" "fmt" "log" "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 reviews ( id integer primary key, pull integer not null references pulls(id) on delete cascade, author integer not null references users(id) on delete cascade, path text not null, side text not null, line integer not null, body text not null, resolved integer not null default 0, created integer not null ); 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 Draft bool } type PRReview struct { ID int64 Author User State string Body string Head string Created time.Time HTML template.HTML } func (s *Store) AddPRReview(pull int64, author User, state, body, head string) error { switch state { case "approved", "changes", "comment": default: return errors.New("Pick a review outcome") } body = strings.TrimSpace(body) if state == "comment" && body == "" { return errors.New("A comment review needs some text") } if len(body) > 20000 { return errors.New("Review is too long") } _, err := s.db.Exec(`insert into prreviews(pull, author, state, body, head, created) values (?, ?, ?, ?, ?, ?)`, pull, author.ID, state, body, head, time.Now().Unix()) s.TouchPull(pull) return err } func (s *Store) PRReviews(pull int64) []PRReview { 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) if err != nil { return nil } defer rows.Close() var out []PRReview for rows.Next() { var r PRReview var created int64 rows.Scan(&r.ID, &r.State, &r.Body, &r.Head, &created, &r.Author.ID, &r.Author.Name) r.Created = time.Unix(created, 0) out = append(out, r) } return out } func latestVerdicts(reviews []PRReview, head string) (approvals int, changes int) { latest := map[int64]string{} for _, r := range reviews { if r.State == "comment" || r.Head != head { continue } latest[r.Author.ID] = r.State } for _, st := range latest { if st == "approved" { approvals++ } else { changes++ } } return } func (s *Store) SetDraft(id int64, draft bool) { s.db.Exec(`update pulls set draft = ?, updated = ? where id = ?`, draft, time.Now().Unix(), id) } type Review struct { ID int64 Author User Path string Side string Line int Body string Resolved bool Created time.Time HTML template.HTML URL string CanEdit bool } func (r Review) Key() string { return fmt.Sprintf("%s:%s:%d", r.Path, r.Side, r.Line) } 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 var draft int 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 { return nil, err } p.Draft = draft != 0 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), p.draft, 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) AddReview(pull int64, author User, path, side string, line int, body string) error { body = strings.TrimSpace(body) if body == "" || len(body) > 20000 { return errors.New("Comment is empty or too long") } if side != "n" && side != "o" || line <= 0 || path == "" { return errors.New("Bad line reference") } _, err := s.db.Exec(`insert into reviews(pull, author, path, side, line, body, created) values (?, ?, ?, ?, ?, ?, ?)`, pull, author.ID, path, side, line, body, time.Now().Unix()) s.TouchPull(pull) return err } func (s *Store) Reviews(pull int64) ([]Review, error) { rows, err := s.db.Query(`select r.id, r.path, r.side, r.line, r.body, r.resolved, r.created, u.id, u.name, u.created from reviews r join users u on u.id = r.author where r.pull = ? order by r.id`, pull) if err != nil { return nil, err } defer rows.Close() var out []Review for rows.Next() { var r Review var resolved int var created, uCreated int64 if err := rows.Scan(&r.ID, &r.Path, &r.Side, &r.Line, &r.Body, &resolved, &created, &r.Author.ID, &r.Author.Name, &uCreated); err != nil { return nil, err } r.Resolved = resolved != 0 r.Created, r.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0) out = append(out, r) } return out, rows.Err() } func (s *Store) ReviewAuthor(id int64) int64 { var author int64 s.db.QueryRow(`select author from reviews where id = ?`, id).Scan(&author) return author } func (s *Store) ResolveReview(id int64, resolved bool) { s.db.Exec(`update reviews set resolved = ? where id = ?`, resolved, id) } func (s *Store) DeleteReview(id int64) { s.db.Exec(`delete from reviews where id = ?`, id) } 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, g, _, 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 } labels, _ := s.store.Labels(repo.ID) filter, _ := strconv.ParseInt(r.URL.Query().Get("label"), 10, 64) if filter != 0 { keep := s.store.ItemsWithLabel("pull", filter) var filtered []Pull for _, p := range pulls { if keep[p.ID] { filtered = append(filtered, p) } } pulls = filtered } itemLabels := map[int64][]Label{} for _, p := range pulls { itemLabels[p.ID] = s.store.ItemLabels("pull", p.ID) } s.render(w, r, "pulls", page{Title: repo.Path() + " pull requests", Data: map[string]any{"Page": rp, "Pulls": pulls, "Pushed": s.recentPushes(repo, g, rp), "State": r.URL.Query().Get("state"), "Labels": labels, "Filter": filter, "ItemLabels": itemLabels}}) } 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 s.store.CanWrite(f, user) { 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 || !validBranch(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 || !s.store.CanWrite(src, user)) { 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")}}) } if repo.Archived { retry("This repository is archived") return } 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 || !validBranch(target) { 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 r.FormValue("draft") == "on" { s.store.SetDraft(pull.ID, true) } if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil { retry("Could not fetch the source branch: " + err.Error()) return } g.RecordRevision(pull) url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number) if repo.Owner.ID != user.ID { s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title), fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\n%s\n", user.Name, repo.Path(), pull.Body, url)) } 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 := s.store.CanWrite(repo, user) 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 } s.notifyPull(pull, *user, "commented on", r.FormValue("body")) 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 "review": line, _ := strconv.Atoi(r.FormValue("line")) if err := s.store.AddReview(pull.ID, *user, r.FormValue("path"), r.FormValue("side"), line, r.FormValue("body")); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } back += "#r-" + r.FormValue("path") + ":" + r.FormValue("side") + ":" + r.FormValue("line") s.notifyPull(pull, *user, "reviewed "+r.FormValue("path")+" line "+r.FormValue("line")+" on", r.FormValue("body")) case "verdict": if user.ID == pull.Author.ID && r.FormValue("state") != "comment" { http.Redirect(w, r, back+"?error=You+cannot+approve+your+own+pull+request", http.StatusSeeOther) return } head, _ := g.Resolve(pull.Ref()) if err := s.store.AddPRReview(pull.ID, *user, r.FormValue("state"), r.FormValue("body"), head); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } verb := map[string]string{"approved": "approved", "changes": "requested changes on", "comment": "reviewed"}[r.FormValue("state")] s.notifyPull(pull, *user, verb, r.FormValue("body")) back += "#reviews" case "ready": if isOwner || isAuthor { s.store.SetDraft(pull.ID, false) } case "draft": if isOwner || isAuthor { s.store.SetDraft(pull.ID, true) } case "meta": s.setMeta(w, r, repo, "pull", pull.ID, back) return case "resolve", "unresolve", "delete": id, _ := strconv.ParseInt(r.FormValue("review"), 10, 64) owner := s.store.ReviewAuthor(id) if owner == 0 || (owner != user.ID && !isOwner) { s.fail(w, r, 403, "Not your comment") return } switch action { case "resolve": s.store.ResolveReview(id, true) case "unresolve": s.store.ResolveReview(id, false) case "delete": s.store.DeleteReview(id) } back += "#diff" 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 } g.RecordRevision(pull) s.store.TouchPull(pull.ID) } case "merge": if !isOwner || pull.State != "open" { s.fail(w, r, 403, "Only the repository owner can merge") return } if pull.Draft { http.Redirect(w, r, back+"?error=Draft+pull+requests+cannot+be+merged", http.StatusSeeOther) return } if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok && prot.Approvals > 0 { head, _ := g.Resolve(pull.Ref()) approvals, changes := latestVerdicts(s.store.PRReviews(pull.ID), head) if approvals < prot.Approvals || changes > 0 { 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) return } } title := strings.TrimSpace(r.FormValue("title")) if title == "" { title = mergeTitle(pull) } message := title if body := strings.TrimSpace(r.FormValue("body")); body != "" { message += "\n\n" + body } var hash string var err error switch r.FormValue("strategy") { case "squash": hash, err = g.Squash(pull.TargetBranch, pull.Ref(), message, pull.Author.Name, pull.Author.Name+"@"+s.host, user.Name, user.Name+"@"+s.host) case "rebase": hash, err = g.Rebase(pull.TargetBranch, pull.Ref(), user.Name, user.Name+"@"+s.host) case "ff": hash, err = g.FastForward(pull.TargetBranch, pull.Ref(), pull.baseRef()) default: hash, err = g.Merge(pull.TargetBranch, pull.Ref(), message, 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) s.notifyPull(pull, *user, "merged", "") 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 } revisions := g.RecordRevision(pull) if action == "interdiff" { s.interdiff(w, r, rp, g, pull, revisions) return } data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor, "Revisions": revisions, "Body": s.renderMarkdown([]byte(pull.Body)), "Labels": s.store.ItemLabels("pull", pull.ID), "AllLabels": nil, "Assignees": s.store.Assignees("pull", pull.ID)} if isOwner { data["AllLabels"], _ = s.store.Labels(repo.ID) data["Assignable"] = s.store.Assignable(repo) } reviews := s.store.PRReviews(pull.ID) for i := range reviews { reviews[i].HTML = s.renderMarkdown([]byte(reviews[i].Body)) } data["Reviews"] = reviews approvals, changes := latestVerdicts(reviews, head) data["Approvals"], data["Changes"] = approvals, changes if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok { data["Protection"] = prot } base := pull.TargetBranch if pull.State == "merged" && pull.Merged != "" { base = g.mergedBase(pull) } 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 reviews, _ := s.store.Reviews(pull.ID) present := map[string]bool{} for _, f := range files { for _, h := range f.Hunks { for _, l := range h.Lines { switch l.Kind { case "add", "ctx": present[fmt.Sprintf("%s:n:%d", f.New, l.New)] = true } if l.Kind == "del" || l.Kind == "ctx" { present[fmt.Sprintf("%s:o:%d", f.New, l.Old)] = true } } } } inline := map[string][]Review{} var outdated []Review for _, rv := range reviews { rv.HTML = s.renderMarkdown([]byte(rv.Body)) rv.URL = fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number) rv.CanEdit = user != nil && (user.ID == rv.Author.ID || isOwner) if present[rv.Key()] { inline[rv.Key()] = append(inline[rv.Key()], rv) } else { outdated = append(outdated, rv) } } data["Inline"] = inline data["Outdated"] = outdated data["ReviewCount"] = len(reviews) data["CommentAt"] = r.URL.Query().Get("comment") 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 } if pull.Draft { data["Mergeable"] = false } if prot, ok := data["Protection"].(Protection); ok && prot.Approvals > 0 && (approvals < prot.Approvals || changes > 0) { data["Blocked"] = fmt.Sprintf("needs %d approving reviews of the current head and no change requests", prot.Approvals) data["Mergeable"] = false } data["FastForward"] = g.IsAncestor(pull.TargetBranch, head) if isOwner && data["Mergeable"] == true && r.URL.Query().Get("merge") == "1" { data["Confirm"] = true data["MergeTitle"] = mergeTitle(pull) var subjects []string for _, c := range data["Commits"].([]Commit) { subjects = append(subjects, "* "+c.Subject) } data["SquashBody"] = strings.Join(subjects, "\n") } } } 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) notifyPull(pull Pull, actor User, verb, body string) { url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, pull.Repo.Path(), pull.Number) subject := fmt.Sprintf("[%s] pull request #%d: %s", pull.Repo.Path(), pull.Number, pull.Title) text := fmt.Sprintf("~%s %s pull request #%d.\n\n%s\n\n%s\n", actor.Name, verb, pull.Number, body, url) seen := map[int64]bool{actor.ID: true} for _, id := range []int64{pull.Author.ID, pull.Repo.Owner.ID} { if !seen[id] { seen[id] = true s.notify(s.store.VerifiedEmail(id), subject, text) } } } func mergeTitle(pull Pull) string { return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch) } 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) err = os.MkdirAll(filepath.Dir(dir), 0o755) if err == nil { err = cloneRepo(s.repoDir(repo), dir) } if err != nil { log.Printf("create %s: %v", fork.Path(), err) os.RemoveAll(dir) s.store.DeleteRepo(fork.ID) s.fail(w, r, 500, "Could not create the repository") return } installHook(s.root, dir) http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) } func (p Pull) revisionRef(n int) string { return fmt.Sprintf("refs/pull/%d/v%d", p.Number, n) } func (g Git) RecordRevision(p Pull) []string { var revs []string for n := 1; ; n++ { h, err := g.Resolve(p.revisionRef(n)) if err != nil { break } revs = append(revs, h) } head, err := g.Resolve(p.Ref()) if err != nil || (len(revs) > 0 && revs[len(revs)-1] == head) { return revs } if _, err := g.run(nil, "update-ref", p.revisionRef(len(revs)+1), head, ""); err != nil { return revs } return append(revs, head) } type rangeLine struct { Class string Text string } func (s *Server) interdiff(w http.ResponseWriter, r *http.Request, rp repoPage, g Git, pull Pull, revs []string) { from, _ := strconv.Atoi(r.URL.Query().Get("from")) to, _ := strconv.Atoi(r.URL.Query().Get("to")) if to == 0 { to = len(revs) } if from == 0 { from = to - 1 } if from < 1 || to > len(revs) || from >= to { s.fail(w, r, 404, "No such pair of revisions") return } target := pull.TargetBranch if pull.State == "merged" && pull.Merged != "" { target = g.mergedBase(pull) } baseA, errA := g.MergeBase(target, revs[from-1]) baseB, errB := g.MergeBase(target, revs[to-1]) if errA != nil || errB != nil { s.fail(w, r, 500, "These revisions share no history with the target") return } out, err := g.run(nil, "range-diff", "--no-color", "--creation-factor=80", baseA+".."+revs[from-1], baseB+".."+revs[to-1]) if err != nil { s.fail(w, r, 500, "range-diff failed") return } var lines []rangeLine for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { class := "" if body, ok := strings.CutPrefix(l, " "); ok { switch { case strings.HasPrefix(body, "@@"): class = "hunk" case strings.HasPrefix(body, "+"): class = "add" case strings.HasPrefix(body, "-"): class = "del" } } else { fields := strings.Fields(l) if len(fields) >= 3 { switch fields[2] { case "=": class = "same" case "!": class = "changed" case ">": class = "new" case "<": class = "gone" } } } lines = append(lines, rangeLine{class, l}) } rp.Tab = "pulls" s.render(w, r, "interdiff", page{Title: fmt.Sprintf("%s #%d v%d to v%d", rp.Repo.Path(), pull.Number, from, to), Data: map[string]any{"Page": rp, "Pull": pull, "Revisions": revs, "From": from, "To": to, "Lines": lines}}) } func (p Pull) baseRef() string { return fmt.Sprintf("refs/pull/%d/base", p.Number) } func (g Git) mergedBase(p Pull) string { if base, err := g.Resolve(p.baseRef()); err == nil { return base } return p.Merged + "^1" } func (g Git) IsAncestor(a, b string) bool { _, err := g.run(nil, "merge-base", "--is-ancestor", "--end-of-options", a, b) return err == nil } func (g Git) FastForward(target, head, baseRef string) (string, error) { targetHash, err := g.Resolve("refs/heads/" + target) if err != nil { return "", errors.New("no such target branch") } headHash, err := g.Resolve(head) if err != nil { return "", err } if headHash == targetHash || !g.IsAncestor(targetHash, headHash) { return "", errors.New("the target has moved on, so this cannot fast forward") } if _, err := g.run(nil, "update-ref", baseRef, targetHash); err != nil { return "", err } if _, err := g.run(nil, "update-ref", "refs/heads/"+target, headHash, targetHash); err != nil { return "", errors.New("target branch moved, try again") } return headHash, nil } func (s *Store) HasOpenPull(repo int64, branch string) bool { var n int s.db.QueryRow(`select count(*) from pulls where repo = ? and source_repo = ? and source_branch = ? and state = 'open'`, repo, repo, branch).Scan(&n) return n > 0 }