package forge import ( "fmt" "slices" "strings" ) type issueView struct { Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Author string `json:"author"` Labels []string `json:"labels"` Assignees []string `json:"assignees"` Comments int `json:"comments"` Opened *string `json:"opened"` Updated *string `json:"updated"` URL string `json:"url"` API string `json:"api"` } type branchSide struct { Repo string `json:"repo"` Branch string `json:"branch"` SHA string `json:"sha,omitempty"` } type pullView struct { issueView Draft bool `json:"draft"` Head branchSide `json:"head"` Base branchSide `json:"base"` Topic string `json:"topic,omitempty"` Merged *string `json:"merged_as"` Mergeable *bool `json:"mergeable,omitempty"` Verdicts *verdictSum `json:"verdicts,omitempty"` } type verdictSum struct { Approvals int `json:"approvals"` Changes int `json:"changes_requested"` Needed int `json:"approvals_needed"` } type commentView struct { ID int64 `json:"id"` Author string `json:"author"` Body string `json:"body"` At *string `json:"at"` } type reviewView struct { ID int64 `json:"id"` Author string `json:"author"` Verdict string `json:"verdict"` Body string `json:"body"` Head string `json:"head"` At *string `json:"at"` } type noteView struct { ID int64 `json:"id"` Author string `json:"author"` Path string `json:"path"` Side string `json:"side"` Line int `json:"line"` Body string `json:"body"` Resolved bool `json:"resolved"` At *string `json:"at"` } func labelNames(labels []Label) []string { return mapped(labels, func(l Label) string { return l.Name }) } func (s *Server) issueView(repo Repo, i Issue) issueView { link := fmt.Sprintf("%s/issues/%d", repo.Path(), i.Number) return issueView{Number: i.Number, Title: i.Title, Body: i.Body, State: i.State, Author: handle(i.Author.Name), Labels: labelNames(s.store.ItemLabels("issue", i.ID)), Assignees: handles(s.store.Assignees("issue", i.ID)), Comments: i.Comments, Opened: iso(i.Created), Updated: iso(i.Updated), URL: "https://" + s.host + "/" + link, API: "https://" + s.host + "/api/" + link} } func (s *Server) pullView(p Pull, deep bool) pullView { link := fmt.Sprintf("%s/pulls/%d", p.Repo.Path(), p.Number) v := pullView{issueView: issueView{Number: p.Number, Title: p.Title, Body: p.Body, State: p.State, Author: handle(p.Author.Name), Labels: labelNames(s.store.ItemLabels("pull", p.ID)), Assignees: handles(s.store.Assignees("pull", p.ID)), Comments: p.Comments, Opened: iso(p.Created), Updated: iso(p.Updated), URL: "https://" + s.host + "/" + link, API: "https://" + s.host + "/api/" + link}, Draft: p.Draft, Topic: p.Agit, Base: branchSide{Repo: p.Repo.Path(), Branch: p.TargetBranch}} if p.Agit == "" { v.Head = branchSide{Repo: p.SourceRepo.Path(), Branch: p.SourceBranch} } else { v.Head = branchSide{Repo: p.Repo.Path(), Branch: p.Source()} } if p.Merged != "" { v.Merged = &p.Merged } g := Git{Dir: s.repoDir(p.Repo)} head, err := g.Resolve(p.Ref()) if err == nil { v.Head.SHA = head } if base, err := g.Resolve("refs/heads/" + p.TargetBranch); err == nil { v.Base.SHA = base } if !deep || err != nil { return v } approvals, changes := latestVerdicts(s.store.PRReviews(p.ID), head) sum := verdictSum{Approvals: approvals, Changes: changes} if prot, ok := s.store.Protection(p.Repo.ID, p.TargetBranch); ok { sum.Needed = prot.Approvals } v.Verdicts = &sum if p.State == "open" { ok := !p.Draft && g.Mergeable(p.TargetBranch, head) && v.Base.SHA != head && (sum.Needed == 0 || (approvals >= sum.Needed && changes == 0)) v.Mergeable = &ok } return v } func commentViews(comments []Comment) []commentView { return mapped(comments, func(c Comment) commentView { return commentView{c.ID, handle(c.Author.Name), c.Body, iso(c.Created)} }) } func (s *Server) threadRoutes() []apiRoute { return []apiRoute{ {"GET", "/~:owner/:repo/issues", needNone, "issues, ?state=open|closed|all and ?label=name", s.apiIssues, nil}, {"POST", "/~:owner/:repo/issues", needWrite, "open an issue from {title, body}, collaborators may add labels and assignees", s.apiOpenIssue, nil}, {"GET", "/~:owner/:repo/issues/:number", needNone, "an issue", s.apiIssue, nil}, {"PATCH", "/~:owner/:repo/issues/:number", needWrite, "change state, labels or assignees", s.apiPatchIssue, nil}, {"GET", "/~:owner/:repo/issues/:number/comments", needNone, "comments, oldest first", s.apiIssueComments, nil}, {"POST", "/~:owner/:repo/issues/:number/comments", needWrite, "comment with {body}", s.apiCommentIssue, nil}, {"GET", "/~:owner/:repo/pulls", needNone, "pull requests, ?state=open|closed|merged|all and ?label=name", s.apiPulls, nil}, {"POST", "/~:owner/:repo/pulls", needWrite, "open a pull request from {title, body, head, base, draft}, head is a branch or ~you/fork:branch", s.apiOpenPull, nil}, {"GET", "/~:owner/:repo/pulls/:number", needNone, "a pull request with its head, mergeability and verdicts", s.apiPull, nil}, {"PATCH", "/~:owner/:repo/pulls/:number", needWrite, "change state, draft, labels or assignees", s.apiPatchPull, nil}, {"GET", "/~:owner/:repo/pulls/:number/commits", needNone, "commits the pull request adds", s.apiPullCommits, nil}, {"GET", "/~:owner/:repo/pulls/:number/files", needNone, "files the pull request changes, ?patch=1 adds the hunks", s.apiPullFiles, nil}, {"GET", "/~:owner/:repo/pulls/:number/revisions", needNone, "every head the pull request has had, oldest first", s.apiPullRevisions, nil}, {"GET", "/~:owner/:repo/pulls/:number/comments", needNone, "conversation comments", s.apiPullComments, nil}, {"POST", "/~:owner/:repo/pulls/:number/comments", needWrite, "comment with {body}", s.apiCommentPull, nil}, {"GET", "/~:owner/:repo/pulls/:number/reviews", needNone, "verdicts, each tied to the head it judged", s.apiReviews, nil}, {"POST", "/~:owner/:repo/pulls/:number/reviews", needWrite, "give a verdict with {verdict: approved|changes|comment, body}", s.apiReview, nil}, {"GET", "/~:owner/:repo/pulls/:number/notes", needNone, "line notes on the diff", s.apiNotes, nil}, {"POST", "/~:owner/:repo/pulls/:number/notes", needWrite, "note a line with {path, side: n|o, line, body}", s.apiAddNote, nil}, {"PATCH", "/~:owner/:repo/pulls/:number/notes/:id", needWrite, "resolve or reopen a note with {resolved}", s.apiPatchNote, nil}, {"DELETE", "/~:owner/:repo/pulls/:number/notes/:id", needWrite, "delete a note", s.apiDeleteNote, nil}, {"POST", "/~:owner/:repo/pulls/:number/refresh", needWrite, "fetch the source branch again", s.apiRefreshPull, nil}, {"POST", "/~:owner/:repo/pulls/:number/merge", needWrite, "merge with {strategy: merge|squash|rebase|ff, title, body}", s.apiMerge, nil}, } } func (c *call) issue() (Repo, Issue, bool) { repo, ok := c.repo() if !ok { return repo, Issue{}, false } n, ok := c.number() if !ok { return repo, Issue{}, false } issue, err := c.s.store.Issue(repo.ID, n) if err != nil { c.fail(404, "No such issue") return repo, Issue{}, false } return repo, issue, true } func (c *call) pull() (Pull, bool) { repo, ok := c.repo() if !ok { return Pull{}, false } n, ok := c.number() if !ok { return Pull{}, false } pull, err := c.s.store.Pull(repo, n) if err != nil { c.fail(404, "No such pull request") return Pull{}, false } return pull, true } func (s *Server) labelIDs(repo Repo, names []string) ([]int64, error) { labels, _ := s.store.Labels(repo.ID) var ids []int64 for _, n := range names { i := slices.IndexFunc(labels, func(l Label) bool { return strings.EqualFold(l.Name, n) }) if i < 0 { return nil, refuse(400, "No label called "+n) } ids = append(ids, labels[i].ID) } return ids, nil } func (s *Server) userIDs(names []string) ([]int64, error) { var ids []int64 for _, n := range names { u, err := s.store.UserByName(strings.TrimPrefix(n, "~")) if err != nil { return nil, refuse(400, "No account called "+n) } ids = append(ids, u.ID) } return ids, nil } type threadPatch struct { State *string `json:"state"` Draft *bool `json:"draft"` Labels *[]string `json:"labels"` Assignees *[]string `json:"assignees"` } func (s *Server) applyMeta(u User, repo Repo, kind string, item int64, in threadPatch) error { if in.Labels != nil { ids, err := s.labelIDs(repo, *in.Labels) if err != nil { return err } if err := s.setLabels(u, repo, kind, item, ids); err != nil { return err } } if in.Assignees != nil { ids, err := s.userIDs(*in.Assignees) if err != nil { return err } if err := s.setAssignees(u, repo, kind, item, ids); err != nil { return err } } return nil } func listState(c *call, fallback string) (string, bool) { state := c.query("state") switch state { case "": return fallback, true case "all": return "", true case "open", "closed", "merged": return state, true } c.fail(400, "State must be open, closed, merged or all") return "", false } func (s *Server) labelFilter(c *call, repo Repo, kind string) (map[int64]bool, bool) { name := c.query("label") if name == "" { return nil, true } ids, err := s.labelIDs(repo, []string{name}) if err != nil { c.err(err) return nil, false } return s.store.ItemsWithLabel(kind, ids[0]), true } func (s *Server) apiIssues(c *call) { repo, ok := c.repo() if !ok { return } state, ok := listState(c, "open") if !ok { return } issues, err := s.store.Issues(repo.ID, state) if err != nil { c.err(err) return } keep, ok := s.labelFilter(c, repo, "issue") if !ok { return } if keep != nil { issues = slices.DeleteFunc(issues, func(i Issue) bool { return !keep[i.ID] }) } paged(c, mapped(issues, func(i Issue) issueView { return s.issueView(repo, i) })) } func (s *Server) apiOpenIssue(c *call) { repo, ok := c.repo() if !ok { return } var in struct { Title string `json:"title"` Body string `json:"body"` Labels []string `json:"labels"` Assignees []string `json:"assignees"` } if !c.decode(&in) { return } issue, err := s.openIssue(*c.user, repo, in.Title, in.Body) if err != nil { c.err(err) return } if (in.Labels != nil || in.Assignees != nil) && s.store.CanWrite(repo, c.user) { s.applyMeta(*c.user, repo, "issue", issue.ID, threadPatch{Labels: &in.Labels, Assignees: &in.Assignees}) } issue, _ = s.store.Issue(repo.ID, issue.Number) v := s.issueView(repo, issue) c.w.Header().Set("Location", v.API) c.reply(201, v) } func (s *Server) apiIssue(c *call) { if repo, issue, ok := c.issue(); ok { c.reply(200, s.issueView(repo, issue)) } } func (s *Server) apiPatchIssue(c *call) { repo, issue, ok := c.issue() if !ok { return } var in threadPatch if !c.decode(&in) { return } if in.Draft != nil { c.fail(400, "Issues have no draft state") return } if in.State != nil { if *in.State != "open" && *in.State != "closed" { c.fail(400, "State must be open or closed") return } if err := s.setIssueState(*c.user, repo, issue, *in.State == "open"); err != nil { c.err(err) return } } if err := s.applyMeta(*c.user, repo, "issue", issue.ID, in); err != nil { c.err(err) return } issue, _ = s.store.Issue(repo.ID, issue.Number) c.reply(200, s.issueView(repo, issue)) } func (s *Server) apiIssueComments(c *call) { _, issue, ok := c.issue() if !ok { return } comments, err := s.store.IssueComments(issue.ID) if err != nil { c.err(err) return } paged(c, commentViews(comments)) } func (s *Server) apiCommentIssue(c *call) { repo, issue, ok := c.issue() if !ok { return } var in struct { Body string `json:"body"` } if !c.decode(&in) { return } if err := s.commentIssue(*c.user, repo, issue, in.Body); err != nil { c.err(err) return } comments, _ := s.store.IssueComments(issue.ID) c.reply(201, commentViews(comments[len(comments)-1:])[0]) } func (s *Server) apiPulls(c *call) { repo, ok := c.repo() if !ok { return } state, ok := listState(c, "open") if !ok { return } pulls, err := s.store.Pulls(repo, state) if err != nil { c.err(err) return } keep, ok := s.labelFilter(c, repo, "pull") if !ok { return } if keep != nil { pulls = slices.DeleteFunc(pulls, func(p Pull) bool { return !keep[p.ID] }) } paged(c, mapped(pulls, func(p Pull) pullView { return s.pullView(p, false) })) } func (s *Server) apiOpenPull(c *call) { repo, ok := c.repo() if !ok { return } var in struct { Title string `json:"title"` Body string `json:"body"` Head string `json:"head"` Base string `json:"base"` Draft bool `json:"draft"` } if !c.decode(&in) { return } if in.Base == "" { in.Base = Git{Dir: s.repoDir(repo)}.Head() } source := in.Head if !strings.Contains(source, ":") { source = repo.Path() + ":" + source } pull, err := s.openPull(*c.user, repo, source, in.Base, in.Title, in.Body, in.Draft) if err != nil { c.err(err) return } pull, _ = s.store.Pull(repo, pull.Number) v := s.pullView(pull, true) c.w.Header().Set("Location", v.API) c.reply(201, v) } func (s *Server) apiPull(c *call) { if pull, ok := c.pull(); ok { c.reply(200, s.pullView(pull, true)) } } func (s *Server) apiPatchPull(c *call) { pull, ok := c.pull() if !ok { return } var in threadPatch if !c.decode(&in) { return } if in.State != nil && *in.State != pull.State { if *in.State != "open" && *in.State != "closed" { c.fail(400, "State must be open or closed, merge through /merge") return } if err := s.setPullState(*c.user, pull, *in.State == "open"); err != nil { c.err(err) return } } if in.Draft != nil { if err := s.setPullDraft(*c.user, pull, *in.Draft); err != nil { c.err(err) return } } if err := s.applyMeta(*c.user, pull.Repo, "pull", pull.ID, in); err != nil { c.err(err) return } pull, _ = s.store.Pull(pull.Repo, pull.Number) c.reply(200, s.pullView(pull, true)) } func (s *Server) pullRange(c *call, pull Pull) (Git, string, string, bool) { g := Git{Dir: s.repoDir(pull.Repo)} head, err := g.Resolve(pull.Ref()) if err != nil { c.fail(410, "The head of this pull request is gone") return g, "", "", false } base := pull.TargetBranch if pull.State == "merged" && pull.Merged != "" { base = g.mergedBase(pull) } mb, err := g.MergeBase(base, head) if err != nil { c.fail(409, "The branches share no history") return g, "", "", false } return g, mb, head, true } func (s *Server) apiPullCommits(c *call) { pull, ok := c.pull() if !ok { return } g, mb, head, ok := s.pullRange(c, pull) if !ok { return } commits, err := g.LogRange(mb, head, 250) if err != nil { c.err(err) return } paged(c, mapped(commits, s.commitView)) } func (s *Server) apiPullFiles(c *call) { pull, ok := c.pull() if !ok { return } g, mb, head, ok := s.pullRange(c, pull) if !ok { return } files, err := g.DiffRange(mb, head) if err != nil { c.err(err) return } paged(c, fileViews(files, c.query("patch") == "1")) } func (s *Server) apiPullRevisions(c *call) { pull, ok := c.pull() if !ok { return } revs := Git{Dir: s.repoDir(pull.Repo)}.RecordRevision(pull) type row struct { Revision int `json:"revision"` SHA string `json:"sha"` } out := []row{} for i, h := range revs { out = append(out, row{i + 1, h}) } paged(c, out) } func (s *Server) apiPullComments(c *call) { pull, ok := c.pull() if !ok { return } comments, err := s.store.Comments(pull.ID) if err != nil { c.err(err) return } paged(c, commentViews(comments)) } func (s *Server) apiCommentPull(c *call) { pull, ok := c.pull() if !ok { return } var in struct { Body string `json:"body"` } if !c.decode(&in) { return } if err := s.commentPull(*c.user, pull, in.Body); err != nil { c.err(err) return } comments, _ := s.store.Comments(pull.ID) c.reply(201, commentViews(comments[len(comments)-1:])[0]) } func reviewViews(reviews []PRReview) []reviewView { return mapped(reviews, func(r PRReview) reviewView { return reviewView{r.ID, handle(r.Author.Name), r.State, r.Body, r.Head, iso(r.Created)} }) } func (s *Server) apiReviews(c *call) { if pull, ok := c.pull(); ok { paged(c, reviewViews(s.store.PRReviews(pull.ID))) } } func (s *Server) apiReview(c *call) { pull, ok := c.pull() if !ok { return } var in struct { Verdict string `json:"verdict"` Body string `json:"body"` } if !c.decode(&in) { return } if err := s.judgePull(*c.user, pull, in.Verdict, in.Body); err != nil { c.err(err) return } reviews := s.store.PRReviews(pull.ID) c.reply(201, reviewViews(reviews[len(reviews)-1:])[0]) } func noteViews(reviews []Review) []noteView { return mapped(reviews, func(r Review) noteView { return noteView{r.ID, handle(r.Author.Name), r.Path, r.Side, r.Line, r.Body, r.Resolved, iso(r.Created)} }) } func (s *Server) apiNotes(c *call) { pull, ok := c.pull() if !ok { return } notes, err := s.store.Reviews(pull.ID) if err != nil { c.err(err) return } paged(c, noteViews(notes)) } func (s *Server) apiAddNote(c *call) { pull, ok := c.pull() if !ok { return } var in struct { Path string `json:"path"` Side string `json:"side"` Line int `json:"line"` Body string `json:"body"` } if !c.decode(&in) { return } if err := s.notePull(*c.user, pull, in.Path, in.Side, in.Line, in.Body); err != nil { c.err(err) return } notes, _ := s.store.Reviews(pull.ID) newest := slices.MaxFunc(notes, func(a, b Review) int { return int(a.ID - b.ID) }) c.reply(201, noteViews([]Review{newest})[0]) } func (s *Server) apiPatchNote(c *call) { pull, ok := c.pull() if !ok { return } id, ok := c.id("id") if !ok { return } var in struct { Resolved bool `json:"resolved"` } if !c.decode(&in) { return } action := "unresolve" if in.Resolved { action = "resolve" } if err := s.touchNote(*c.user, pull, id, action); err != nil { c.err(err) return } c.done() } func (s *Server) apiDeleteNote(c *call) { pull, ok := c.pull() if !ok { return } id, ok := c.id("id") if !ok { return } if err := s.touchNote(*c.user, pull, id, "delete"); err != nil { c.err(err) return } c.done() } func (s *Server) apiRefreshPull(c *call) { pull, ok := c.pull() if !ok { return } if err := s.refreshPull(*c.user, pull); err != nil { c.err(err) return } pull, _ = s.store.Pull(pull.Repo, pull.Number) c.reply(200, s.pullView(pull, true)) } func (s *Server) apiMerge(c *call) { pull, ok := c.pull() if !ok { return } var in struct { Strategy string `json:"strategy"` Title string `json:"title"` Body string `json:"body"` } if !c.decode(&in) { return } if _, err := s.mergePull(*c.user, pull, in.Strategy, in.Title, in.Body); err != nil { c.err(err) return } pull, _ = s.store.Pull(pull.Repo, pull.Number) c.reply(200, s.pullView(pull, true)) }