package limatmpl
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
)
const defaultFilename = ".lima.yaml"
func transformGitHubURL(ctx context.Context, input string) (string, error) {
input, origBranch, _ := strings.Cut(input, "@")
parts := strings.Split(input, "/")
for len(parts) < 2 {
parts = append(parts, "")
}
org := parts[0]
if org == "" {
return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input)
}
repo := cmp.Or(parts[1], org)
filePath := strings.Join(parts[2:], "/")
if filePath == "" {
filePath = defaultFilename
} else {
if strings.HasSuffix(filePath, "/") {
filePath += defaultFilename
}
filename := path.Base(filePath)
if !strings.Contains(filename[1:], ".") {
filePath += ".yaml"
}
}
branch := origBranch
if branch == "" {
var err error
branch, err = getGitHubDefaultBranch(ctx, org, repo)
if err != nil {
return "", fmt.Errorf("failed to get default branch for %s/%s: %w", org, repo, err)
}
}
ext := strings.ToLower(path.Ext(filePath))
if ext == ".yaml" || ext == ".yml" {
return resolveGitHubSymlink(ctx, org, repo, branch, filePath, origBranch)
}
return githubUserContentURL(org, repo, branch, filePath), nil
}
func githubUserContentURL(org, repo, branch, filePath string) string {
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
}
func getGitHubUserContent(ctx context.Context, org, repo, branch, filePath string) (*http.Response, error) {
url := githubUserContentURL(org, repo, branch, filePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "lima")
return http.DefaultClient.Do(req)
}
func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", org, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "lima")
req.Header.Set("Accept", "application/vnd.github.v3+json")
token := cmp.Or(os.Getenv("GH_TOKEN"), os.Getenv("GITHUB_TOKEN"))
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to query GitHub API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read GitHub API response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
}
var repoData struct {
DefaultBranch string `json:"default_branch"`
}
if err := json.Unmarshal(body, &repoData); err != nil {
return "", fmt.Errorf("failed to parse GitHub API response: %w", err)
}
if repoData.DefaultBranch == "" {
return "", fmt.Errorf("repository %s/%s has no default branch", org, repo)
}
return repoData.DefaultBranch, nil
}
func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath, origBranch string) (string, error) {
resp, err := getGitHubUserContent(ctx, org, repo, branch, filePath)
if err != nil {
return "", fmt.Errorf("failed to fetch file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound && repo == org {
defaultBranch, err := getGitHubDefaultBranch(ctx, org, repo)
if err == nil {
return resolveGitHubRedirect(ctx, org, repo, defaultBranch, filePath, branch)
}
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
}
buf := make([]byte, 1024)
n, err := resp.Body.Read(buf)
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
}
content := string(buf[:n])
if repo == org && strings.HasPrefix(content, "github:") {
return validateGitHubRedirect(content, org, origBranch, resp.Request.URL.String())
}
if !(content == "" || strings.ContainsAny(content, "\n :")) {
filePath = path.Join(path.Dir(filePath), content)
}
return githubUserContentURL(org, repo, branch, filePath), nil
}
func resolveGitHubRedirect(ctx context.Context, org, repo, defaultBranch, filePath, origBranch string) (string, error) {
resp, err := getGitHubUserContent(ctx, org, repo, defaultBranch, filePath)
if err != nil {
return "", fmt.Errorf("failed to fetch file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
}
return validateGitHubRedirect(string(body), org, origBranch, resp.Request.URL.String())
}
func validateGitHubRedirect(body, org, origBranch, url string) (string, error) {
redirect, _, _ := strings.Cut(body, "\n")
redirect = strings.TrimSpace(redirect)
if !strings.HasPrefix(redirect, "github:"+org+"/") {
return "", fmt.Errorf(`redirect %q is not a "github:%s" URL (from %q)`, redirect, org, url)
}
if strings.ContainsRune(redirect, '@') {
return "", fmt.Errorf("redirect %q must not include a branch/tag/sha (from %q)", redirect, url)
}
if origBranch != "" {
redirect += "@" + origBranch
}
return redirect, nil
}