package staticsite import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) // GitHubProvider implements GitProvider for GitHub repositories. type GitHubProvider struct { apiBase string // "https://api.github.com" for github.com token string httpClient *http.Client } // NewGitHubProvider creates a new GitHub provider. // baseURL should be "https://github.com" or a GitHub Enterprise URL. func NewGitHubProvider(baseURL, token string) *GitHubProvider { apiBase := "https://api.github.com" base := strings.TrimRight(baseURL, "/") if base != "https://github.com" && base != "http://github.com" { // GitHub Enterprise: API is at {base}/api/v3 apiBase = base + "/api/v3" } return &GitHubProvider{ apiBase: apiBase, token: token, httpClient: NewSafeHTTPClient(60 * time.Second), } } func (g *GitHubProvider) Name() string { return "github" } func (g *GitHubProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { var allRepos []RepoInfo if query != "" { // Use search API. url := fmt.Sprintf("%s/search/repositories?q=%s&per_page=50", g.apiBase, query) body, err := g.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("search repos: %w", err) } var result struct { Items []struct { Owner struct { Login string `json:"login"` } `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Private bool `json:"private"` HTMLURL string `json:"html_url"` } `json:"items"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("decode search: %w", err) } for _, r := range result.Items { allRepos = append(allRepos, RepoInfo{ Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName, Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL, }) } return allRepos, nil } // List authenticated user's repos. page := 1 for { url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=updated", g.apiBase, page) body, err := g.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("list repos: %w", err) } var repos []struct { Owner struct { Login string `json:"login"` } `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Private bool `json:"private"` HTMLURL string `json:"html_url"` } if err := json.Unmarshal(body, &repos); err != nil { return nil, fmt.Errorf("decode repos: %w", err) } for _, r := range repos { allRepos = append(allRepos, RepoInfo{ Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName, Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL, }) } if len(repos) < 100 { break } page++ } return allRepos, nil } func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string) error { url := fmt.Sprintf("%s/repos/%s/%s", g.apiBase, owner, repo) _, err := g.doGet(ctx, url) return err } // SetCommitStatus reports a deploy status on a commit via GitHub's commit- // status API (works for github.com and GitHub Enterprise — apiBase already // carries the /api/v3 suffix for GHE). The "context" field is fixed to // "tinyforge" so repeated deploys update one status row. func (g *GitHubProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { body, err := json.Marshal(map[string]string{ "state": githubState(status), "target_url": targetURL, "description": truncateDescription(description), "context": commitStatusContext, }) if err != nil { return fmt.Errorf("marshal status: %w", err) } apiURL := fmt.Sprintf("%s/repos/%s/%s/statuses/%s", g.apiBase, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) if err := postJSON(ctx, g.httpClient, apiURL, body, g.setAuth); err != nil { return fmt.Errorf("set commit status: %w", err) } return nil } // githubState maps a provider-agnostic CommitStatus onto GitHub's API // vocabulary. GitHub accepts the same four words Tinyforge uses. func githubState(status CommitStatus) string { switch status { case CommitStatusSuccess: return "success" case CommitStatusFailure: return "failure" case CommitStatusError: return "error" default: return "pending" } } func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { var allBranches []string page := 1 for { url := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", g.apiBase, owner, repo, page) body, err := g.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("list branches: %w", err) } var branches []struct { Name string `json:"name"` } if err := json.Unmarshal(body, &branches); err != nil { return nil, fmt.Errorf("decode branches: %w", err) } for _, b := range branches { allBranches = append(allBranches, b.Name) } if len(branches) < 100 { break } page++ } return allBranches, nil } func (g *GitHubProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { url := fmt.Sprintf("%s/repos/%s/%s/branches/%s", g.apiBase, owner, repo, branch) body, err := g.doGet(ctx, url) if err != nil { return "", fmt.Errorf("get branch: %w", err) } var result struct { Commit struct { SHA string `json:"sha"` } `json:"commit"` } if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("decode branch: %w", err) } return result.Commit.SHA, nil } func (g *GitHubProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { url := fmt.Sprintf("%s/repos/%s/%s/git/trees/%s?recursive=1", g.apiBase, owner, repo, branch) body, err := g.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("list tree: %w", err) } var tree struct { Tree []struct { Path string `json:"path"` Type string `json:"type"` // "blob" or "tree" } `json:"tree"` } if err := json.Unmarshal(body, &tree); err != nil { return nil, fmt.Errorf("decode tree: %w", err) } entries := make([]FolderEntry, 0, len(tree.Tree)) for _, e := range tree.Tree { entries = append(entries, FolderEntry{ Path: e.Path, IsDir: e.Type == "tree", }) } return entries, nil } func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { // Get tree to find files in folder. entries, err := g.ListTree(ctx, owner, repo, branch) if err != nil { return fmt.Errorf("list tree: %w", err) } folderPath = strings.TrimPrefix(folderPath, "/") folderPath = strings.TrimSuffix(folderPath, "/") prefix := folderPath + "/" for _, entry := range entries { if entry.IsDir { continue } if !strings.HasPrefix(entry.Path, prefix) { continue } relativePath := strings.TrimPrefix(entry.Path, prefix) localPath := filepath.Join(destDir, filepath.FromSlash(relativePath)) // Path-traversal defense: refuse tree entries whose resolved // path escapes destDir. A hostile/compromised GHE could // otherwise deliver `..`-laden entries. cleanDest := filepath.Clean(destDir) if cleanRel := filepath.Clean(localPath); cleanRel != cleanDest && !strings.HasPrefix(cleanRel, cleanDest+string(os.PathSeparator)) { return fmt.Errorf("rejecting tree entry outside dest: %s", relativePath) } // GitHub raw content URL. // For github.com: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} // For GHE: {baseURL}/{owner}/{repo}/raw/{branch}/{path} var fileURL string if g.apiBase == "https://api.github.com" { fileURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", owner, repo, branch, entry.Path) } else { // GHE: use API contents endpoint. fileURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", g.apiBase, owner, repo, entry.Path, branch) } if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil { return fmt.Errorf("download %s: %w", relativePath, err) } } return nil } // DownloadFile fetches a single file's raw bytes via the GitHub contents API // using the raw media type (works for both github.com and GHE), capped at // maxBytes. Returns ErrFileNotFound on a 404. func (g *GitHubProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) { p := strings.TrimPrefix(path, "/") fileURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", g.apiBase, owner, repo, p, ref) auth := func(r *http.Request) { g.setAuth(r) r.Header.Set("Accept", "application/vnd.github.raw+json") } return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, auth) } func (g *GitHubProvider) doGet(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) } g.setAuth(req) req.Header.Set("Accept", "application/vnd.github+json") resp, err := g.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) } return body, nil } func (g *GitHubProvider) setAuth(req *http.Request) { if g.token != "" { req.Header.Set("Authorization", "Bearer "+g.token) } }