package gitea import ( "bytes" "context" "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" "strings" "time" "golang.org/x/sync/semaphore" ) type APIClient struct { urlBase string orgName string apiSem *semaphore.Weighted } // 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, maxConnections int64) *APIClient { if !strings.HasSuffix(urlBase, `/`) { urlBase += `/` } ret := &APIClient{ urlBase: urlBase, orgName: orgName, } 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) 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 } 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 } // renderMarkdown calls the remote Gitea server's own markdown renderer. func (ac *APIClient) RenderMarkdown(ctx context.Context, repoName string, body string) ([]byte, error) { 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 { 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) { 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 { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } return ioutil.ReadAll(resp.Body) }