Files
mcdoc/internal/gitea/client.go
Kyle Isom 28afaa2c56 Implement mcdoc v0.1.0: public documentation server
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>
2026-03-27 13:04:15 -07:00

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
}