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"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
"teafolio/gitea"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
"golang.org/x/sync/semaphore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -31,10 +30,11 @@ type Application struct {
|
|||||||
cfg Config
|
cfg Config
|
||||||
|
|
||||||
rxRepoPage, rxRepoImage *regexp.Regexp
|
rxRepoPage, rxRepoImage *regexp.Regexp
|
||||||
apiSem *semaphore.Weighted
|
|
||||||
|
gitea *gitea.APIClient
|
||||||
|
|
||||||
reposMut sync.RWMutex
|
reposMut sync.RWMutex
|
||||||
reposCache []Repo // Sorted by recently-created-first
|
reposCache []gitea.Repo // Sorted by recently-created-first
|
||||||
reposAlphabeticalOrder map[string]int
|
reposAlphabeticalOrder map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,17 +52,8 @@ func main() {
|
|||||||
log.Fatalf("toml.DecodeFile: %s", err.Error())
|
log.Fatalf("toml.DecodeFile: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assert Gitea URL always has trailing slash
|
// Create Gitea API client
|
||||||
if !strings.HasSuffix(app.cfg.Gitea.URL, `/`) {
|
app.gitea = gitea.NewAPIClient(app.cfg.Gitea.URL, app.cfg.Gitea.Org, app.cfg.Gitea.MaxConnections)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync worker
|
// Sync worker
|
||||||
go app.syncWorker(context.Background())
|
go app.syncWorker(context.Background())
|
||||||
|
@ -53,7 +53,7 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rowClass := ""
|
rowClass := ""
|
||||||
for _, topic := range repo.topics {
|
for _, topic := range repo.Topics {
|
||||||
rowClass += `taggedWith-` + topic + ` `
|
rowClass += `taggedWith-` + topic + ` `
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,9 +61,9 @@ func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
|
|||||||
<tr
|
<tr
|
||||||
class="`+html.EscapeString(rowClass)+`"
|
class="`+html.EscapeString(rowClass)+`"
|
||||||
data-sort-al="`+fmt.Sprintf("-%d", this.reposAlphabeticalOrder[repo.Name])+`"
|
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-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>
|
<td>
|
||||||
<a href="`+pageHref+`"><img class="homeimage" loading="lazy" src="`+html.EscapeString(`/:banner/`+url.PathEscape(repo.Name))+`"></div></a>
|
<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>
|
<br>
|
||||||
<small>
|
<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, `<a class="tag tag-link" data-tag="`+html.EscapeString(topic)+`">`+html.EscapeString(topic)+`</a> `)
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `
|
fmt.Fprint(w, `
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) {
|
func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
images, err := this.imageFilesForRepo(ctx, repoName)
|
images, err := this.gitea.ImageFilesForRepo(ctx, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
||||||
return
|
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)
|
repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName)
|
||||||
extraHead := ""
|
extraHead := ""
|
||||||
|
|
||||||
readme, err := this.repoFile(ctx, repoName, `README.md`)
|
readme, err := this.gitea.RepoFile(ctx, repoName, `README.md`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
this.internalError(w, r, fmt.Errorf("loading README.md: %w", err))
|
this.internalError(w, r, fmt.Errorf("loading README.md: %w", err))
|
||||||
return
|
return
|
||||||
@ -29,7 +29,7 @@ func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoNa
|
|||||||
if rr.Name != repoName {
|
if rr.Name != repoName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, topic := range rr.topics {
|
for _, topic := range rr.Topics {
|
||||||
if topic == "article" {
|
if topic == "article" {
|
||||||
hasArticleTag = true
|
hasArticleTag = true
|
||||||
break
|
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:]...)
|
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 {
|
if err != nil {
|
||||||
this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err))
|
this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err))
|
||||||
return
|
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))
|
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 {
|
if err != nil {
|
||||||
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the Git repository contains a top-level go.mod file, allow vanity imports
|
// 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`
|
// Check the first line should be `module MODULENAME\n`
|
||||||
firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0]
|
firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0]
|
||||||
|
14
sync.go
14
sync.go
@ -4,13 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"teafolio/gitea"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (this *Application) sync(ctx context.Context) (bool, error) {
|
func (this *Application) sync(ctx context.Context) (bool, error) {
|
||||||
|
|
||||||
// List repositories on Gitea
|
// List repositories on Gitea
|
||||||
repos, err := this.repos(ctx)
|
repos, err := this.gitea.Repos(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@ -39,11 +40,16 @@ func (this *Application) sync(ctx context.Context) (bool, error) {
|
|||||||
anyChanges = true
|
anyChanges = true
|
||||||
|
|
||||||
// Refresh timestamps
|
// 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
|
// Refresh topics
|
||||||
if t, err := this.topicsForRepo(ctx, rr.Name); err == nil {
|
err = this.gitea.PopulateTopics(ctx, &rr)
|
||||||
rr.topics = t
|
if err != nil {
|
||||||
|
log.Printf("loading topics for '%s': %s", rr.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
|
Loading…
Reference in New Issue
Block a user