414 lines
12 KiB
Go
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)
|
|
}
|