teafolio/gitea/apiclient.go

390 lines
9.3 KiB
Go

package gitea
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
"golang.org/x/sync/semaphore"
)
const MarkdownFailureCooldownSeconds = 3600 // 1 hour
type APIClient struct {
urlBase string
orgName string
token string
apiSem *semaphore.Weighted
lastMarkdownFailure atomic.Int64
}
// NewAPIClient creates a new Gitea API client for a single Gitea organization.
// Set maxConnections to 0 for unlimited concurrent API calls.
func NewAPIClient(urlBase string, orgName string, token string, maxConnections int64) *APIClient {
if !strings.HasSuffix(urlBase, `/`) {
urlBase += `/`
}
ret := &APIClient{
urlBase: urlBase,
orgName: orgName,
token: token,
}
if maxConnections == 0 { // unlimited
ret.apiSem = semaphore.NewWeighted(99999)
} else {
ret.apiSem = semaphore.NewWeighted(maxConnections)
}
return ret
}
func (ac *APIClient) PopulateCommitInfo(ctx context.Context, rr *Repo) error {
// The most recent commit will be the head of one of the branches (easy to find)
brs, err := ac.Branches(ctx, rr.Name)
if err != nil {
return err
} else {
NewestCommit := time.Unix(0, 0) // sentinel
for _, br := range brs {
if br.Commit.Timestamp.After(NewestCommit) {
NewestCommit = br.Commit.Timestamp
}
}
if !NewestCommit.Equal(time.Unix(0, 0)) {
rr.NewestCommit = NewestCommit // replace it
}
}
return nil
}
func (ac *APIClient) PopulateTopics(ctx context.Context, rr *Repo) error {
t, err := ac.topicsForRepo(ctx, rr.Name)
if err != nil {
return err
}
rr.Topics = t
return nil
}
func (ac *APIClient) PopulateImages(ctx context.Context, rr *Repo) error {
img, err := ac.ImageFilesForRepo(ctx, rr.Name)
if err != nil {
return err
}
rr.Images = img
return nil
}
func (ac *APIClient) apiRequest(ctx context.Context, endpoint string, target interface{}) error {
err := ac.apiSem.Acquire(ctx, 1)
if err != nil {
return err // e.g. ctx closed
}
defer ac.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ac.urlBase+endpoint, nil)
if err != nil {
return err
}
if ac.token != "" {
req.Header.Set(`Authorization`, `token `+ac.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(target)
if err != nil {
return err
}
return nil
}
func (ac *APIClient) Branches(ctx context.Context, repo string) ([]Branch, error) {
var branches []Branch
err := ac.apiRequest(ctx, `api/v1/repos/`+url.PathEscape(ac.orgName)+`/`+url.PathEscape(repo)+`/branches`, &branches)
if err != nil {
return nil, err // e.g. ctx closed
}
return branches, nil
}
func (ac *APIClient) commitsPage(ctx context.Context, repo, ref string, page, limit int) ([]CommitListEntry, error) {
var ret []CommitListEntry
err := ac.apiRequest(ctx, fmt.Sprintf(`api/v1/repos/%s/%s/commits?page=%d&limit=%d`, url.PathEscape(ac.orgName), url.PathEscape(repo), page, limit), &ret)
if err != nil {
return nil, err // e.g. ctx closed
}
return ret, nil
}
func (ac *APIClient) Commits(ctx context.Context, repo, ref string) ([]CommitListEntry, error) {
var ret []CommitListEntry
nextPage := 1 // Counting starts at 1
for {
page, err := ac.commitsPage(ctx, repo, ref, nextPage, 300)
if err != nil {
return nil, err
}
if len(page) == 0 && len(ret) > 0 {
break // Found enough already
}
ret = append(ret, page...)
nextPage += 1
}
if len(ret) == 0 {
return nil, fmt.Errorf("no commits found")
}
return ret, nil
}
// reposPage gets a single page of the list of Git repositories in this organisation.
func (ac *APIClient) reposPage(ctx context.Context, page, limit int) ([]Repo, error) {
var repos []Repo
err := ac.apiRequest(ctx, `api/v1/orgs/`+url.PathEscape(ac.orgName)+fmt.Sprintf(`/repos?page=%d&limit=%d`, page, limit), &repos)
if err != nil {
return nil, err
}
return repos, nil
}
// repos gets a list of Git repositories in this organisation. It may have to
// make multiple network requests.
func (ac *APIClient) Repos(ctx context.Context) ([]Repo, error) {
// Seems like gitea-1.13.0-rc1 returns 30 results by default, and supports up to a limit of 100
// Make a much larger request
ret := make([]Repo, 0)
nextPage := 1 // Counting starts at 1
for {
page, err := ac.reposPage(ctx, nextPage, 300)
if err != nil {
return nil, err
}
if len(page) == 0 && len(ret) > 0 {
break // Found enough already
}
ret = append(ret, page...)
nextPage += 1
}
return ret, nil
}
// repoFile gets a single file from the default branch of the git repository
// Usually the default branch is `master`.
func (ac *APIClient) RepoFile(ctx context.Context, repo, filename string) ([]byte, error) {
var cr ContentsResponse
err := ac.apiRequest(ctx, `api/v1/repos/`+url.PathEscape(ac.orgName)+`/`+url.PathEscape(repo)+`/contents/`+url.PathEscape(filename), &cr)
if err != nil {
return nil, err
}
return cr.Content, nil
}
func (ac *APIClient) filesInDirectory(ctx context.Context, repo, dir string) ([]ReaddirEntry, error) {
err := ac.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer ac.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ac.urlBase+`api/v1/repos/`+url.PathEscape(ac.orgName)+`/`+url.PathEscape(repo)+`/contents/`+dir, nil) // n.b. $dir param not escaped
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
// "No files found" happens with a HTTP 500/404 error depending on Gitea version. Catch this special case
if resp.StatusCode == 500 || resp.StatusCode == 404 {
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if strings.Contains(string(b), `does not exist`) {
return []ReaddirEntry{}, nil // no files found
}
}
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var ret []ReaddirEntry
err = json.NewDecoder(resp.Body).Decode(&ret)
if err != nil {
return nil, err
}
return ret, nil
}
// ImageFilesForRepo finds documentation images for the repository.
// It searches the top-level directory and the dist/ and doc/ subdirectories.
func (ac *APIClient) ImageFilesForRepo(ctx context.Context, repo string) ([]ReaddirEntry, error) {
ret := []ReaddirEntry{}
for _, dirName := range []string{``, `dist`, `doc`} {
files, err := ac.filesInDirectory(ctx, repo, dirName)
if err != nil {
return nil, fmt.Errorf("readdir(%s): %w", dirName, err)
}
for _, f := range files {
if f.isImage() {
ret = append(ret, f)
}
}
}
return ret, nil
}
func (ac *APIClient) topicsForRepo(ctx context.Context, repo string) ([]string, error) {
var tr TopicsResponse
err := ac.apiRequest(ctx, `api/v1/repos/`+url.PathEscape(ac.orgName)+`/`+url.PathEscape(repo)+`/topics`, &tr)
if err != nil {
return nil, err
}
return tr.Topics, nil
}
var ErrMarkdownCooldown = errors.New("Markdown API was recently not functional")
func (ac *APIClient) checkMarkdownCooldown() error {
if ac.lastMarkdownFailure.Load()+MarkdownFailureCooldownSeconds > time.Now().Unix() {
return ErrMarkdownCooldown
}
return nil
}
// renderMarkdown calls the remote Gitea server's own markdown renderer.
func (ac *APIClient) RenderMarkdown(ctx context.Context, repoName string, body string) ([]byte, error) {
if err := ac.checkMarkdownCooldown(); err != nil {
return nil, err
}
err := ac.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer ac.apiSem.Release(1)
jb, err := json.Marshal(MarkdownRequest{
Context: ac.urlBase + url.PathEscape(ac.orgName) + `/` + url.PathEscape(repoName) + `/src/branch/master`,
Mode: "gfm", // magic constant - Github Flavoured Markdown
Text: body,
})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ac.urlBase+`api/v1/markdown/`, bytes.NewReader(jb))
if err != nil {
return nil, err
}
req.Header.Set(`Content-Type`, `application/json`)
req.Header.Set(`Content-Length`, fmt.Sprintf("%d", len(jb)))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
ac.lastMarkdownFailure.Store(time.Now().Unix())
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
}
// renderMarkdownRaw calls the remote Gitea server's own markdown renderer.
func (ac *APIClient) renderMarkdownRaw(ctx context.Context, body []byte) ([]byte, error) {
if err := ac.checkMarkdownCooldown(); err != nil {
return nil, err
}
err := ac.apiSem.Acquire(ctx, 1)
if err != nil {
return nil, err // e.g. ctx closed
}
defer ac.apiSem.Release(1)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ac.urlBase+`api/v1/markdown/raw`, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set(`Content-Type`, `text/plain`)
req.Header.Set(`Content-Length`, fmt.Sprintf("%d", len(body)))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
ac.lastMarkdownFailure.Store(time.Now().Unix())
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
}