Single-binary Go server that fetches markdown from Gitea (mc org), renders to HTML with goldmark (GFM, chroma syntax highlighting, heading anchors), and serves a navigable read-only documentation site. Features: - Boot fetch with retry, webhook refresh, 15-minute poll fallback - In-memory cache with atomic per-repo swap - chi router with htmx partial responses for SPA-like navigation - HMAC-SHA256 webhook validation - Responsive CSS, TOC generation, priority doc ordering - $PORT env var support for MCP agent port assignment 33 tests across config, cache, render, and server packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
162 lines
4.2 KiB
Go
162 lines
4.2 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Repo represents a Gitea repository.
|
|
type Repo struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
|
|
// TreeEntry represents a file in a Gitea repo tree.
|
|
type TreeEntry struct {
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // "blob" or "tree"
|
|
}
|
|
|
|
// TreeResponse is the Gitea API response for a recursive tree listing.
|
|
type TreeResponse struct {
|
|
Tree []TreeEntry `json:"tree"`
|
|
}
|
|
|
|
// CommitMeta holds minimal commit info for a file.
|
|
type CommitMeta struct {
|
|
SHA string `json:"sha"`
|
|
Date time.Time `json:"-"`
|
|
}
|
|
|
|
// RepoCommit is used to extract the latest commit SHA for a repo.
|
|
type RepoCommit struct {
|
|
SHA string `json:"sha"`
|
|
Commit repoCommitInfo `json:"commit"`
|
|
}
|
|
|
|
type repoCommitInfo struct {
|
|
Committer commitPerson `json:"committer"`
|
|
}
|
|
|
|
type commitPerson struct {
|
|
Date time.Time `json:"date"`
|
|
}
|
|
|
|
// Client fetches content from a Gitea instance.
|
|
type Client struct {
|
|
baseURL string
|
|
org string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a Gitea API client.
|
|
func NewClient(baseURL, org string, timeout time.Duration) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
org: org,
|
|
httpClient: &http.Client{
|
|
Timeout: timeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ListRepos returns all repositories in the configured organization.
|
|
func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) {
|
|
var allRepos []Repo
|
|
page := 1
|
|
|
|
for {
|
|
url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?page=%d&limit=50", c.baseURL, c.org, page)
|
|
var repos []Repo
|
|
if err := c.getJSON(ctx, url, &repos); err != nil {
|
|
return nil, fmt.Errorf("list repos page %d: %w", page, err)
|
|
}
|
|
if len(repos) == 0 {
|
|
break
|
|
}
|
|
allRepos = append(allRepos, repos...)
|
|
page++
|
|
}
|
|
|
|
return allRepos, nil
|
|
}
|
|
|
|
// ListMarkdownFiles returns paths to all .md files in a repo's default branch.
|
|
func (c *Client) ListMarkdownFiles(ctx context.Context, repo, branch string) ([]string, error) {
|
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
|
|
c.baseURL, c.org, repo, branch)
|
|
|
|
var tree TreeResponse
|
|
if err := c.getJSON(ctx, url, &tree); err != nil {
|
|
return nil, fmt.Errorf("list tree for %s: %w", repo, err)
|
|
}
|
|
|
|
var files []string
|
|
for _, entry := range tree.Tree {
|
|
if entry.Type == "blob" && strings.HasSuffix(strings.ToLower(entry.Path), ".md") {
|
|
files = append(files, entry.Path)
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// FetchFileContent returns the raw content of a file in a repo.
|
|
func (c *Client) FetchFileContent(ctx context.Context, repo, branch, filepath string) ([]byte, error) {
|
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
|
|
c.baseURL, c.org, repo, filepath, branch)
|
|
return c.getRaw(ctx, url)
|
|
}
|
|
|
|
// LatestCommitSHA returns the SHA of the latest commit on a branch.
|
|
func (c *Client) LatestCommitSHA(ctx context.Context, repo, branch string) (string, time.Time, error) {
|
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?sha=%s&limit=1",
|
|
c.baseURL, c.org, repo, branch)
|
|
|
|
var commits []RepoCommit
|
|
if err := c.getJSON(ctx, url, &commits); err != nil {
|
|
return "", time.Time{}, fmt.Errorf("latest commit for %s: %w", repo, err)
|
|
}
|
|
if len(commits) == 0 {
|
|
return "", time.Time{}, fmt.Errorf("no commits found for %s/%s", repo, branch)
|
|
}
|
|
|
|
return commits[0].SHA, commits[0].Commit.Committer.Date, nil
|
|
}
|
|
|
|
func (c *Client) getJSON(ctx context.Context, url string, target interface{}) error {
|
|
body, err := c.getRaw(ctx, url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(body, target)
|
|
}
|
|
|
|
func (c *Client) getRaw(ctx context.Context, url string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch %s: %w", url, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("fetch %s: status %d", url, resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response from %s: %w", url, err)
|
|
}
|
|
return body, nil
|
|
}
|