Files
gh-action-readme/internal/template.go

333 lines
8.9 KiB
Go

package internal
import (
"bytes"
"path/filepath"
"strings"
"text/template"
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/validation"
"github.com/ivuorinen/gh-action-readme/templates_embed"
)
// TemplateOptions defines options for rendering templates.
type TemplateOptions struct {
TemplatePath string
HeaderPath string
FooterPath string
Format string // md or html
}
// TemplateData represents all data available to templates.
type TemplateData struct {
// Action Data
*ActionYML
// Git Repository Information
Git git.RepoInfo `json:"git"`
// Configuration
Config *AppConfig `json:"config"`
// Computed Values
UsesStatement string `json:"uses_statement"`
// Path information for subdirectory extraction
ActionPath string `json:"action_path,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
// Dependencies (populated by dependency analysis)
Dependencies []dependencies.Dependency `json:"dependencies,omitempty"`
}
// templateFuncs returns a map of custom template functions.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"lower": strings.ToLower,
"upper": strings.ToUpper,
"replace": strings.ReplaceAll,
"join": strings.Join,
"gitOrg": getGitOrg,
"gitRepo": getGitRepo,
"gitUsesString": getGitUsesString,
"actionVersion": getActionVersion,
}
}
// getGitOrg returns the Git organization from template data.
func getGitOrg(data any) string {
if td, ok := data.(*TemplateData); ok {
if td.Git.Organization != "" {
return td.Git.Organization
}
if td.Config.Organization != "" {
return td.Config.Organization
}
}
return appconstants.DefaultOrgPlaceholder
}
// getGitRepo returns the Git repository name from template data.
func getGitRepo(data any) string {
if td, ok := data.(*TemplateData); ok {
if td.Git.Repository != "" {
return td.Git.Repository
}
if td.Config.Repository != "" {
return td.Config.Repository
}
}
return appconstants.DefaultRepoPlaceholder
}
// getGitUsesString returns a complete uses string for the action.
func getGitUsesString(data any) string {
td, ok := data.(*TemplateData)
if !ok {
return appconstants.DefaultUsesPlaceholder
}
org := strings.TrimSpace(getGitOrg(data))
repo := strings.TrimSpace(getGitRepo(data))
if !isValidOrgRepo(org, repo) {
return appconstants.DefaultUsesPlaceholder
}
version := formatVersion(getActionVersion(data))
return buildUsesString(td, org, repo, version)
}
// isValidOrgRepo checks if org and repo are valid.
func isValidOrgRepo(org, repo string) bool {
return org != "" && repo != "" &&
org != appconstants.DefaultOrgPlaceholder &&
repo != appconstants.DefaultRepoPlaceholder
}
// formatVersion ensures version has proper @ prefix.
func formatVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" {
return "@v1"
}
if !strings.HasPrefix(version, "@") {
return "@" + version
}
return version
}
// buildUsesString constructs the uses string with optional subdirectory path.
func buildUsesString(td *TemplateData, org, repo, version string) string {
// Use the validation package's FormatUsesStatement for consistency
if org == "" || repo == "" {
return appconstants.DefaultUsesPlaceholder
}
// For monorepo actions in subdirectories, extract the actual directory path
subdir := extractActionSubdirectory(td.ActionPath, td.RepoRoot)
if subdir != "" {
// Action is in a subdirectory: org/repo/subdir@version
return validation.FormatUsesStatement(org, repo+"/"+subdir, version)
}
// Action is at repo root: org/repo@version
return validation.FormatUsesStatement(org, repo, version)
}
// extractActionSubdirectory extracts the subdirectory path for an action relative to repo root.
// For monorepo actions (e.g., org/repo/subdir/action.yml), returns "subdir".
// For repo-root actions (e.g., org/repo/action.yml), returns empty string.
// Returns empty string if paths cannot be determined.
func extractActionSubdirectory(actionPath, repoRoot string) string {
// Validate inputs
if actionPath == "" || repoRoot == "" {
return ""
}
// Get absolute paths for reliable comparison
absActionPath, err := filepath.Abs(actionPath)
if err != nil {
return ""
}
absRepoRoot, err := filepath.Abs(repoRoot)
if err != nil {
return ""
}
// Get the directory containing action.yml
actionDir := filepath.Dir(absActionPath)
// Calculate relative path from repo root to action directory
relPath, err := filepath.Rel(absRepoRoot, actionDir)
if err != nil {
return ""
}
// If relative path is "." or empty, action is at repo root
if relPath == "." || relPath == "" {
return ""
}
// If relative path starts with "..", action is outside repo (shouldn't happen)
if strings.HasPrefix(relPath, "..") {
return ""
}
// Return the subdirectory path (e.g., "actions/csharp-build")
return relPath
}
// getActionVersion returns the action version from template data.
// Priority: 1) Config.Version (explicit override), 2) Default branch (if enabled), 3) "v1" (fallback).
func getActionVersion(data any) string {
td, ok := data.(*TemplateData)
if !ok {
return "v1"
}
// Priority 1: Explicit version override
if td.Config.Version != "" {
return td.Config.Version
}
// Priority 2: Use default branch if enabled and available
if td.Config.UseDefaultBranch && td.Git.DefaultBranch != "" {
return td.Git.DefaultBranch
}
// Priority 3: Fallback
return "v1"
}
// BuildTemplateData constructs comprehensive template data from action and configuration.
func BuildTemplateData(action *ActionYML, config *AppConfig, repoRoot, actionPath string) *TemplateData {
data := &TemplateData{
ActionYML: action,
Config: config,
ActionPath: actionPath,
RepoRoot: repoRoot,
}
// Populate Git information
if repoRoot != "" {
if info, err := git.DetectRepository(repoRoot); err == nil {
data.Git = *info
}
}
// Override with configuration values if available
if config.Organization != "" {
data.Git.Organization = config.Organization
}
if config.Repository != "" {
data.Git.Repository = config.Repository
}
// Build uses statement
data.UsesStatement = getGitUsesString(data)
// Add dependency analysis if enabled
if config.AnalyzeDependencies && actionPath != "" {
data.Dependencies = analyzeDependencies(actionPath, config, data.Git)
}
return data
}
// analyzeDependencies performs dependency analysis on the action file.
func analyzeDependencies(actionPath string, config *AppConfig, gitInfo git.RepoInfo) []dependencies.Dependency {
// Create GitHub client if we have a token
var client *GitHubClient
if token := GetGitHubToken(config); token != "" {
var err error
client, err = NewGitHubClient(token)
if err != nil {
// Log error but continue with no client (graceful degradation)
client = nil
}
}
// Create high-performance cache
var depCache dependencies.DependencyCache
if cacheInstance, err := cache.NewCache(cache.DefaultConfig()); err == nil {
depCache = dependencies.NewCacheAdapter(cacheInstance)
} else {
// Fallback to no-op cache if cache creation fails
depCache = dependencies.NewNoOpCache()
}
// Create dependency analyzer
var githubClient *github.Client
if client != nil {
githubClient = client.Client
}
analyzer := dependencies.NewAnalyzer(githubClient, gitInfo, depCache)
// Analyze dependencies
deps, err := analyzer.AnalyzeActionFile(actionPath)
if err != nil {
// Log error but don't fail - return empty dependencies
return []dependencies.Dependency{}
}
return deps
}
// RenderReadme renders a README using a Go template and the parsed action.yml data.
func RenderReadme(action any, opts TemplateOptions) (string, error) {
tmplContent, err := templates_embed.ReadTemplate(opts.TemplatePath)
if err != nil {
return "", err
}
var tmpl *template.Template
if opts.Format == appconstants.OutputFormatHTML {
tmpl, err = template.New(appconstants.TemplateNameReadme).Funcs(templateFuncs()).Parse(string(tmplContent))
if err != nil {
return "", err
}
var head, foot string
if opts.HeaderPath != "" {
h, _ := templates_embed.ReadTemplate(opts.HeaderPath)
head = string(h)
}
if opts.FooterPath != "" {
f, _ := templates_embed.ReadTemplate(opts.FooterPath)
foot = string(f)
}
// Wrap template output in header/footer
buf := &bytes.Buffer{}
buf.WriteString(head)
if err := tmpl.Execute(buf, action); err != nil {
return "", err
}
buf.WriteString(foot)
return buf.String(), nil
}
tmpl, err = template.New(appconstants.TemplateNameReadme).Funcs(templateFuncs()).Parse(string(tmplContent))
if err != nil {
return "", err
}
buf := &bytes.Buffer{}
if err := tmpl.Execute(buf, action); err != nil {
return "", err
}
return buf.String(), nil
}