api: move gitea api to subpackage
This commit is contained in:
parent
45ed36b327
commit
cb454938cc
372
api.go
372
api.go
@ -1,372 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GiteaCreated time.Time `json:"created_at"`
|
||||
GiteaUpdated time.Time `json:"updated_at"`
|
||||
|
||||
newestCommit time.Time
|
||||
topics []string
|
||||
}
|
||||
|
||||
func (this *Application) populateCommitInfo(ctx context.Context, rr *Repo) {
|
||||
|
||||
// The most recent commit will be the head of one of the branches (easy to find)
|
||||
|
||||
brs, err := this.branches(ctx, rr.Name)
|
||||
if err != nil {
|
||||
log.Printf("loading branches for '%s': %s", rr.Name, err)
|
||||
rr.newestCommit = rr.GiteaUpdated // best guess
|
||||
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type ContentsResponse struct {
|
||||
Content []byte `json:"content"` // Assume base64 "encoding" parameter in Gitea response, and use Go's auto decode
|
||||
}
|
||||
|
||||
type TopicsResponse struct {
|
||||
Topics []string `json:"topics"`
|
||||
}
|
||||
|
||||
type ReaddirEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int `json:"size"`
|
||||
RawURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
func (rde ReaddirEntry) isImage() bool {
|
||||
return strings.HasSuffix(rde.Name, `.png`) || strings.HasSuffix(rde.Name, `.jpg`) || strings.HasSuffix(rde.Name, `.jpeg`)
|
||||
}
|
||||
|
||||
type MarkdownRequest struct {
|
||||
Context string
|
||||
Mode string
|
||||
Text string
|
||||
Wiki bool
|
||||
}
|
||||
|
||||
type BranchCommit struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
Commit BranchCommit `json:"commit"`
|
||||
}
|
||||
|
||||
type AuthorInfo struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
type CommitListEntryCommit struct {
|
||||
Message string `json:"message"`
|
||||
Author AuthorInfo `json:"author"`
|
||||
Committer AuthorInfo `json:"committer"`
|
||||
}
|
||||
|
||||
type CommitListEntry struct {
|
||||
ID string `json:"sha"`
|
||||
Commit CommitListEntryCommit `json:"commit"`
|
||||
}
|
||||
|
||||
func (this *Application) apiRequest(ctx context.Context, endpoint string, target interface{}) error {
|
||||
err := this.apiSem.Acquire(ctx, 1)
|
||||
if err != nil {
|
||||
return err // e.g. ctx closed
|
||||
}
|
||||
defer this.apiSem.Release(1)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, this.cfg.Gitea.URL+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 (this *Application) branches(ctx context.Context, repo string) ([]Branch, error) {
|
||||
var branches []Branch
|
||||
|
||||
err := this.apiRequest(ctx, `api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+url.PathEscape(repo)+`/branches`, &branches)
|
||||
if err != nil {
|
||||
return nil, err // e.g. ctx closed
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func (this *Application) commitsPage(ctx context.Context, repo, ref string, page, limit int) ([]CommitListEntry, error) {
|
||||
var ret []CommitListEntry
|
||||
|
||||
err := this.apiRequest(ctx, fmt.Sprintf(`api/v1/repos/%s/%s/commits?page=%d&limit=%d`, url.PathEscape(this.cfg.Gitea.Org), url.PathEscape(repo), page, limit), &ret)
|
||||
if err != nil {
|
||||
return nil, err // e.g. ctx closed
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (this *Application) commits(ctx context.Context, repo, ref string) ([]CommitListEntry, error) {
|
||||
var ret []CommitListEntry
|
||||
|
||||
nextPage := 1 // Counting starts at 1
|
||||
for {
|
||||
page, err := this.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 (this *Application) reposPage(ctx context.Context, page, limit int) ([]Repo, error) {
|
||||
var repos []Repo
|
||||
|
||||
err := this.apiRequest(ctx, `api/v1/orgs/`+url.PathEscape(this.cfg.Gitea.Org)+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 (this *Application) 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 := this.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 (this *Application) repoFile(ctx context.Context, repo, filename string) ([]byte, error) {
|
||||
var cr ContentsResponse
|
||||
|
||||
err := this.apiRequest(ctx, `api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+url.PathEscape(repo)+`/contents/`+url.PathEscape(filename), &cr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cr.Content, nil
|
||||
}
|
||||
|
||||
func (this *Application) filesInDirectory(ctx context.Context, repo, dir string) ([]ReaddirEntry, error) {
|
||||
err := this.apiSem.Acquire(ctx, 1)
|
||||
if err != nil {
|
||||
return nil, err // e.g. ctx closed
|
||||
}
|
||||
defer this.apiSem.Release(1)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, this.cfg.Gitea.URL+`api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+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 (this *Application) imageFilesForRepo(ctx context.Context, repo string) ([]ReaddirEntry, error) {
|
||||
|
||||
ret := []ReaddirEntry{}
|
||||
|
||||
for _, dirName := range []string{``, `dist`, `doc`} {
|
||||
|
||||
files, err := this.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 (this *Application) topicsForRepo(ctx context.Context, repo string) ([]string, error) {
|
||||
var tr TopicsResponse
|
||||
|
||||
err := this.apiRequest(ctx, `api/v1/repos/`+url.PathEscape(this.cfg.Gitea.Org)+`/`+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 (this *Application) renderMarkdown(ctx context.Context, repoName string, body string) ([]byte, error) {
|
||||
err := this.apiSem.Acquire(ctx, 1)
|
||||
if err != nil {
|
||||
return nil, err // e.g. ctx closed
|
||||
}
|
||||
defer this.apiSem.Release(1)
|
||||
|
||||
jb, err := json.Marshal(MarkdownRequest{
|
||||
Context: this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + 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, this.cfg.Gitea.URL+`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 (this *Application) renderMarkdownRaw(ctx context.Context, body []byte) ([]byte, error) {
|
||||
err := this.apiSem.Acquire(ctx, 1)
|
||||
if err != nil {
|
||||
return nil, err // e.g. ctx closed
|
||||
}
|
||||
defer this.apiSem.Release(1)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, this.cfg.Gitea.URL+`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)
|
||||
}
|
348
gitea/apiclient.go
Normal file
348
gitea/apiclient.go
Normal file
@ -0,0 +1,348 @@
|
||||
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)
|
||||
}
|
72
gitea/types.go
Normal file
72
gitea/types.go
Normal file
@ -0,0 +1,72 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GiteaCreated time.Time `json:"created_at"`
|
||||
GiteaUpdated time.Time `json:"updated_at"`
|
||||
|
||||
// NewestCommit is populated via PopulateCommitInfo().
|
||||
NewestCommit time.Time
|
||||
// Topics is populated via topicsForRepo().
|
||||
Topics []string
|
||||
}
|
||||
|
||||
type ContentsResponse struct {
|
||||
Content []byte `json:"content"` // Assume base64 "encoding" parameter in Gitea response, and use Go's auto decode
|
||||
}
|
||||
|
||||
type TopicsResponse struct {
|
||||
Topics []string `json:"topics"`
|
||||
}
|
||||
|
||||
type ReaddirEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int `json:"size"`
|
||||
RawURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
func (rde ReaddirEntry) isImage() bool {
|
||||
return strings.HasSuffix(rde.Name, `.png`) || strings.HasSuffix(rde.Name, `.jpg`) || strings.HasSuffix(rde.Name, `.jpeg`)
|
||||
}
|
||||
|
||||
type MarkdownRequest struct {
|
||||
Context string
|
||||
Mode string
|
||||
Text string
|
||||
Wiki bool
|
||||
}
|
||||
|
||||
type BranchCommit struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
Commit BranchCommit `json:"commit"`
|
||||
}
|
||||
|
||||
type AuthorInfo struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
type CommitListEntryCommit struct {
|
||||
Message string `json:"message"`
|
||||
Author AuthorInfo `json:"author"`
|
||||
Committer AuthorInfo `json:"committer"`
|
||||
}
|
||||
|
||||
type CommitListEntry struct {
|
||||
ID string `json:"sha"`
|
||||
Commit CommitListEntryCommit `json:"commit"`
|
||||
}
|
21
main.go
21
main.go
@ -6,11 +6,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"teafolio/gitea"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@ -31,10 +30,11 @@ type Application struct {
|
||||
cfg Config
|
||||
|
||||
rxRepoPage, rxRepoImage *regexp.Regexp
|
||||
apiSem *semaphore.Weighted
|
||||
|
||||
gitea *gitea.APIClient
|
||||
|
||||
reposMut sync.RWMutex
|
||||
reposCache []Repo // Sorted by recently-created-first
|
||||
reposCache []gitea.Repo // Sorted by recently-created-first
|
||||
reposAlphabeticalOrder map[string]int
|
||||
}
|
||||
|
||||
@ -52,17 +52,8 @@ func main() {
|
||||
log.Fatalf("toml.DecodeFile: %s", err.Error())
|
||||
}
|
||||
|
||||
// Assert Gitea URL always has trailing slash
|
||||
if !strings.HasSuffix(app.cfg.Gitea.URL, `/`) {
|
||||
app.cfg.Gitea.URL += `/`
|
||||
}
|
||||
|
||||
// Create semaphore
|
||||
if app.cfg.Gitea.MaxConnections == 0 { // unlimited
|
||||
app.apiSem = semaphore.NewWeighted(99999)
|
||||
} else {
|
||||
app.apiSem = semaphore.NewWeighted(app.cfg.Gitea.MaxConnections)
|
||||
}
|
||||
// Create Gitea API client
|
||||
app.gitea = gitea.NewAPIClient(app.cfg.Gitea.URL, app.cfg.Gitea.Org, app.cfg.Gitea.MaxConnections)
|
||||
|
||||
// Sync worker
|
||||
go app.syncWorker(context.Background())
|
||||
|
@ -53,7 +53,7 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rowClass := ""
|
||||
for _, topic := range repo.topics {
|
||||
for _, topic := range repo.Topics {
|
||||
rowClass += `taggedWith-` + topic + ` `
|
||||
}
|
||||
|
||||
@ -61,9 +61,9 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
|
||||
<tr
|
||||
class="`+html.EscapeString(rowClass)+`"
|
||||
data-sort-al="`+fmt.Sprintf("-%d", this.reposAlphabeticalOrder[repo.Name])+`"
|
||||
data-sort-ls="`+fmt.Sprintf("%.0f", repo.newestCommit.Sub(repo.GiteaCreated).Seconds())+`"
|
||||
data-sort-ls="`+fmt.Sprintf("%.0f", repo.NewestCommit.Sub(repo.GiteaCreated).Seconds())+`"
|
||||
data-sort-ct="`+fmt.Sprintf("%d", repo.GiteaCreated.Unix())+`"
|
||||
data-sort-mt="`+fmt.Sprintf("%d", repo.newestCommit.Unix())+`"
|
||||
data-sort-mt="`+fmt.Sprintf("%d", repo.NewestCommit.Unix())+`"
|
||||
>
|
||||
<td>
|
||||
<a href="`+pageHref+`"><img class="homeimage" loading="lazy" src="`+html.EscapeString(`/:banner/`+url.PathEscape(repo.Name))+`"></div></a>
|
||||
@ -74,7 +74,7 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
|
||||
<br>
|
||||
<small>
|
||||
`)
|
||||
for _, topic := range repo.topics {
|
||||
for _, topic := range repo.Topics {
|
||||
fmt.Fprint(w, `<a class="tag tag-link" data-tag="`+html.EscapeString(topic)+`">`+html.EscapeString(topic)+`</a> `)
|
||||
}
|
||||
fmt.Fprint(w, `
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) {
|
||||
ctx := r.Context()
|
||||
|
||||
images, err := this.imageFilesForRepo(ctx, repoName)
|
||||
images, err := this.gitea.ImageFilesForRepo(ctx, repoName)
|
||||
if err != nil {
|
||||
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
||||
return
|
||||
|
@ -14,7 +14,7 @@ func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoNa
|
||||
repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName)
|
||||
extraHead := ""
|
||||
|
||||
readme, err := this.repoFile(ctx, repoName, `README.md`)
|
||||
readme, err := this.gitea.RepoFile(ctx, repoName, `README.md`)
|
||||
if err != nil {
|
||||
this.internalError(w, r, fmt.Errorf("loading README.md: %w", err))
|
||||
return
|
||||
@ -29,7 +29,7 @@ func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoNa
|
||||
if rr.Name != repoName {
|
||||
continue
|
||||
}
|
||||
for _, topic := range rr.topics {
|
||||
for _, topic := range rr.Topics {
|
||||
if topic == "article" {
|
||||
hasArticleTag = true
|
||||
break
|
||||
@ -53,7 +53,7 @@ func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoNa
|
||||
lines = append([]string{lines[0], lines[1], extraBadgesMd, ""}, lines[2:]...)
|
||||
}
|
||||
|
||||
readmeHtml, err := this.renderMarkdown(ctx, repoName, strings.Join(lines, "\n"))
|
||||
readmeHtml, err := this.gitea.RenderMarkdown(ctx, repoName, strings.Join(lines, "\n"))
|
||||
if err != nil {
|
||||
this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err))
|
||||
return
|
||||
@ -61,14 +61,14 @@ func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoNa
|
||||
|
||||
readmeHtml = []byte(strings.Replace(string(readmeHtml), `%%REPLACEME__BADGE%%`, `<img src="/static/build_success_brightgreen.svg" style="width:90px;height:20px;">`, 1))
|
||||
|
||||
images, err := this.imageFilesForRepo(ctx, repoName)
|
||||
images, err := this.gitea.ImageFilesForRepo(ctx, repoName)
|
||||
if err != nil {
|
||||
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// If the Git repository contains a top-level go.mod file, allow vanity imports
|
||||
if goMod, err := this.repoFile(ctx, repoName, `go.mod`); err == nil {
|
||||
if goMod, err := this.gitea.RepoFile(ctx, repoName, `go.mod`); err == nil {
|
||||
|
||||
// Check the first line should be `module MODULENAME\n`
|
||||
firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0]
|
||||
|
14
sync.go
14
sync.go
@ -4,13 +4,14 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"sort"
|
||||
"teafolio/gitea"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (this *Application) sync(ctx context.Context) (bool, error) {
|
||||
|
||||
// List repositories on Gitea
|
||||
repos, err := this.repos(ctx)
|
||||
repos, err := this.gitea.Repos(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@ -39,11 +40,16 @@ func (this *Application) sync(ctx context.Context) (bool, error) {
|
||||
anyChanges = true
|
||||
|
||||
// Refresh timestamps
|
||||
this.populateCommitInfo(ctx, &rr)
|
||||
err := this.gitea.PopulateCommitInfo(ctx, &rr)
|
||||
if err != nil {
|
||||
log.Printf("loading branches for '%s': %s", rr.Name, err)
|
||||
rr.NewestCommit = rr.GiteaUpdated // best guess
|
||||
}
|
||||
|
||||
// Refresh topics
|
||||
if t, err := this.topicsForRepo(ctx, rr.Name); err == nil {
|
||||
rr.topics = t
|
||||
err = this.gitea.PopulateTopics(ctx, &rr)
|
||||
if err != nil {
|
||||
log.Printf("loading topics for '%s': %s", rr.Name, err)
|
||||
}
|
||||
|
||||
// Save
|
||||
|
Loading…
Reference in New Issue
Block a user