Files

414 lines
12 KiB
Go

package editor
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
)
// What the editor reads about a repository from its platform, as the
// signed-in person. Gitea, Forgejo and GitHub share one API shape. GitLab and
// Bitbucket sign people in already; reading their branches and reviews comes
// with a later milestone.
var errNotYet = errors.New("this platform's branches and reviews aren't read by the editor yet")
// Access is what the person may do in a repository.
type Access struct {
Read, Write, Admin bool
}
// Branch is one branch and its tip.
type Branch struct {
Name string `json:"name"`
Commit string `json:"commit"`
}
// Review is an open pull request.
type Review struct {
Number int `json:"number"`
Title string `json:"title"`
Branch string `json:"branch"`
Author string `json:"author"`
URL string `json:"url"`
}
func (s *Server) apiBase(f *ForgeConfig) string {
switch f.Kind {
case forge.GitHub:
if strings.EqualFold(f.Host, "github.com") {
return "https://api.github.com"
}
return f.base() + "/api/v3"
case forge.Gitea, forge.Forgejo:
return f.base() + "/api/v1"
}
return ""
}
func repoPath(site *SiteConfig) (string, error) {
r, err := forge.Parse(site.Repo, "")
if err != nil {
return "", err
}
if r.Path == "" {
u, _ := url.Parse(site.Repo)
return strings.TrimSuffix(strings.Trim(u.Path, "/"), ".git"), nil
}
return r.Path, nil
}
func (s *Server) access(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig) (Access, error) {
p, err := repoPath(site)
if err != nil {
return Access{}, err
}
switch f.Kind {
case forge.GitLab:
return s.gitlabAccess(ctx, f, token, p)
case forge.Bitbucket:
return s.bitbucketAccess(ctx, token, p)
}
base := s.apiBase(f)
if base == "" {
return Access{}, nil // a platform the editor can't ask: no access
}
var d struct {
Permissions struct{ Admin, Push, Pull bool } `json:"permissions"`
}
if err := s.api(ctx, f, token, base+"/repos/"+p, &d); err != nil {
if errors.Is(err, errSignedOut) {
return Access{}, err
}
return Access{}, nil // not found or not allowed: no access
}
return Access{Read: d.Permissions.Pull, Write: d.Permissions.Push, Admin: d.Permissions.Admin}, nil
}
// gitlabAccess reads the person's role on a GitLab project: reporters can
// read, developers write, maintainers and owners maintain.
func (s *Server) gitlabAccess(ctx context.Context, f *ForgeConfig, token, p string) (Access, error) {
var d struct {
Permissions struct {
Project *struct {
Level int `json:"access_level"`
} `json:"project_access"`
Group *struct {
Level int `json:"access_level"`
} `json:"group_access"`
} `json:"permissions"`
}
if err := s.api(ctx, f, token, f.base()+"/api/v4/projects/"+url.PathEscape(p), &d); err != nil {
if errors.Is(err, errSignedOut) {
return Access{}, err
}
return Access{}, nil
}
level := 0
if d.Permissions.Project != nil {
level = d.Permissions.Project.Level
}
if d.Permissions.Group != nil && d.Permissions.Group.Level > level {
level = d.Permissions.Group.Level
}
return Access{Read: level >= 20, Write: level >= 30, Admin: level >= 40}, nil
}
// bitbucketAccess reads the person's permission on a Bitbucket Cloud
// repository: read, write or admin.
func (s *Server) bitbucketAccess(ctx context.Context, token, p string) (Access, error) {
var d struct {
Values []struct {
Permission string `json:"permission"`
} `json:"values"`
}
q := url.Values{"q": {`repository.full_name="` + p + `"`}}
if err := s.api(ctx, &ForgeConfig{Kind: forge.Bitbucket}, token, bitbucketAPI+"/user/permissions/repositories?"+q.Encode(), &d); err != nil {
if errors.Is(err, errSignedOut) {
return Access{}, err
}
return Access{}, nil
}
if len(d.Values) == 0 {
return Access{}, nil
}
switch d.Values[0].Permission {
case "admin":
return Access{Read: true, Write: true, Admin: true}, nil
case "write":
return Access{Read: true, Write: true}, nil
case "read":
return Access{Read: true}, nil
}
return Access{}, nil
}
// bitbucketAPI is Bitbucket Cloud's API; a variable so tests can stand in.
var bitbucketAPI = "https://api.bitbucket.org/2.0"
// accessCached is access, remembered for a minute per token and repository:
// with several accounts, one page load would otherwise ask the platform the
// same question many times. Failures aren't remembered.
func (s *Server) accessCached(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig) (Access, error) {
sum := sha256.Sum256([]byte(token + "\x00" + site.Repo))
key := hex.EncodeToString(sum[:])
s.accessMu.Lock()
if hit, ok := s.accessSeen[key]; ok && time.Since(hit.at) < accessTTL {
s.accessMu.Unlock()
return hit.acc, nil
}
s.accessMu.Unlock()
acc, err := s.access(ctx, f, token, site)
if err != nil {
return acc, err
}
s.accessMu.Lock()
if s.accessSeen == nil || len(s.accessSeen) > 4096 {
s.accessSeen = map[string]accessHit{}
}
s.accessSeen[key] = accessHit{acc: acc, at: time.Now()}
s.accessMu.Unlock()
return acc, nil
}
// rememberAccess records an answer already known, such as a repository
// listing's permissions.
func (s *Server) rememberAccess(token string, site *SiteConfig, acc Access) {
sum := sha256.Sum256([]byte(token + "\x00" + site.Repo))
s.accessMu.Lock()
if s.accessSeen == nil {
s.accessSeen = map[string]accessHit{}
}
s.accessSeen[hex.EncodeToString(sum[:])] = accessHit{acc: acc, at: time.Now()}
s.accessMu.Unlock()
}
const accessTTL = time.Minute
type accessHit struct {
acc Access
at time.Time
}
func (s *Server) branches(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig) ([]Branch, error) {
base := s.apiBase(f)
if base == "" {
return nil, errNotYet
}
p, err := repoPath(site)
if err != nil {
return nil, err
}
var out []Branch
for page := 1; page <= 10; page++ {
var d []struct {
Name string `json:"name"`
Commit struct {
ID string `json:"id"`
SHA string `json:"sha"`
} `json:"commit"`
}
if err := s.api(ctx, f, token, fmt.Sprintf("%s/repos/%s/branches?limit=50&per_page=50&page=%d", base, p, page), &d); err != nil {
return nil, err
}
for _, b := range d {
out = append(out, Branch{Name: b.Name, Commit: firstNonEmpty(b.Commit.ID, b.Commit.SHA)})
}
if len(d) < 50 {
break
}
}
return out, nil
}
func (s *Server) reviews(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig) ([]Review, error) {
base := s.apiBase(f)
if base == "" {
return nil, errNotYet
}
p, err := repoPath(site)
if err != nil {
return nil, err
}
var d []struct {
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
Head struct {
Ref string `json:"ref"`
} `json:"head"`
User struct {
Login string `json:"login"`
} `json:"user"`
}
if err := s.api(ctx, f, token, fmt.Sprintf("%s/repos/%s/pulls?state=open&limit=50&per_page=50", base, p), &d); err != nil {
return nil, err
}
out := make([]Review, 0, len(d))
for _, pr := range d {
out = append(out, Review{Number: pr.Number, Title: pr.Title, Branch: pr.Head.Ref, Author: pr.User.Login, URL: pr.HTMLURL})
}
return out, nil
}
// send makes a write call to the platform API as the signed-in person.
func (s *Server) send(ctx context.Context, f *ForgeConfig, token, method, u string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hotdog-cms-editor")
res, err := s.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
return errSignedOut
}
if res.StatusCode >= 300 {
var e struct {
Message string `json:"message"`
}
_ = json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&e)
if e.Message == "" {
e.Message = res.Status
}
return &platformError{Status: res.StatusCode, Message: e.Message}
}
if out != nil {
return json.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(out)
}
return nil
}
type platformError struct {
Status int
Message string
}
func (e *platformError) Error() string { return fmt.Sprintf("the platform said: %s", e.Message) }
// openReview opens a pull request from branch into the site's publishing branch.
func (s *Server) openReview(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig, branch, title, body string) (Review, error) {
base := s.apiBase(f)
if base == "" {
return Review{}, errNotYet
}
p, err := repoPath(site)
if err != nil {
return Review{}, err
}
var d struct {
Number int `json:"number"`
HTMLURL string `json:"html_url"`
}
if err := s.send(ctx, f, token, http.MethodPost, base+"/repos/"+p+"/pulls", map[string]string{"title": title, "head": branch, "base": site.Branch, "body": body}, &d); err != nil {
return Review{}, err
}
return Review{Number: d.Number, Title: title, Branch: branch, URL: d.HTMLURL}, nil
}
// merge merges a pull request. The platform decides whether this person may.
func (s *Server) merge(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig, number int) error {
base := s.apiBase(f)
if base == "" {
return errNotYet
}
p, err := repoPath(site)
if err != nil {
return err
}
u := fmt.Sprintf("%s/repos/%s/pulls/%d/merge", base, p, number)
if f.Kind == forge.GitHub {
if err := s.send(ctx, f, token, http.MethodPut, u, map[string]string{"merge_method": "merge"}, nil); err != nil {
return err
}
// GitHub leaves the branch; remove a merged draft as Gitea does. A
// failure here (a protected branch, say) doesn't undo the publish.
var pr struct {
Head struct {
Ref string `json:"ref"`
Repo struct {
FullName string `json:"full_name"`
} `json:"repo"`
} `json:"head"`
}
if err := s.api(ctx, f, token, fmt.Sprintf("%s/repos/%s/pulls/%d", base, p, number), &pr); err == nil &&
strings.EqualFold(pr.Head.Repo.FullName, p) && strings.HasPrefix(pr.Head.Ref, "draft/") {
_ = s.send(ctx, f, token, http.MethodDelete, base+"/repos/"+p+"/git/refs/heads/"+pr.Head.Ref, nil, nil)
}
return nil
}
return s.send(ctx, f, token, http.MethodPost, u, map[string]any{"Do": "merge", "delete_branch_after_merge": true}, nil)
}
// Person is someone on the site's platform.
type Person struct {
Login string `json:"login"`
Name string `json:"name,omitempty"`
}
// reviewers lists who can be asked to review: Gitea and Forgejo say so
// directly; on GitHub, the repository's collaborators.
func (s *Server) reviewers(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig) ([]Person, error) {
base := s.apiBase(f)
if base == "" {
return nil, errNotYet
}
p, err := repoPath(site)
if err != nil {
return nil, err
}
var raw []struct {
Login string `json:"login"`
FullName string `json:"full_name"`
Name string `json:"name"`
}
u := base + "/repos/" + p + "/reviewers"
if f.Kind == forge.GitHub {
u = base + "/repos/" + p + "/collaborators?per_page=100"
}
if err := s.api(ctx, f, token, u, &raw); err != nil {
return nil, err
}
out := []Person{}
for _, r := range raw {
out = append(out, Person{Login: r.Login, Name: firstNonEmpty(r.FullName, r.Name)})
}
return out, nil
}
// requestReviewers asks people to review a pull request; the platform
// notifies them, by its own notifications and email.
func (s *Server) requestReviewers(ctx context.Context, f *ForgeConfig, token string, site *SiteConfig, number int, logins []string) error {
base := s.apiBase(f)
p, err := repoPath(site)
if err != nil {
return err
}
return s.send(ctx, f, token, http.MethodPost, fmt.Sprintf("%s/repos/%s/pulls/%d/requested_reviewers", base, p, number), map[string][]string{"reviewers": logins}, nil)
}