Initial commit

This commit is contained in:
2025-07-30 19:12:53 +03:00
commit 74cbe1e469
83 changed files with 12567 additions and 0 deletions

View File

@@ -0,0 +1,539 @@
// Package dependencies provides GitHub Actions dependency analysis functionality.
package dependencies
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/google/go-github/v57/github"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
// VersionType represents the type of version specification used.
type VersionType string
const (
// SemanticVersion represents semantic versioning format (v1.2.3).
SemanticVersion VersionType = "semantic"
// CommitSHA represents a git commit SHA.
CommitSHA VersionType = "commit"
// BranchName represents a git branch reference.
BranchName VersionType = "branch"
// LocalPath represents a local file path reference.
LocalPath VersionType = "local"
// Common string constants.
compositeUsing = "composite"
updateTypeNone = "none"
updateTypeMajor = "major"
updateTypePatch = "patch"
defaultBranch = "main"
)
// Dependency represents a GitHub Action dependency with detailed information.
type Dependency struct {
Name string `json:"name"`
Uses string `json:"uses"` // Full uses statement
Version string `json:"version"` // Readable version
VersionType VersionType `json:"version_type"` // semantic, commit, branch
IsPinned bool `json:"is_pinned"` // Whether locked to specific version
Description string `json:"description"` // From GitHub API
Author string `json:"author"` // Action owner
MarketplaceURL string `json:"marketplace_url,omitempty"`
SourceURL string `json:"source_url"`
WithParams map[string]string `json:"with_params,omitempty"`
IsLocalAction bool `json:"is_local_action"` // Same repo dependency
IsShellScript bool `json:"is_shell_script"`
ScriptURL string `json:"script_url,omitempty"` // Link to script line
}
// OutdatedDependency represents a dependency that has newer versions available.
type OutdatedDependency struct {
Current Dependency `json:"current"`
LatestVersion string `json:"latest_version"`
LatestSHA string `json:"latest_sha"`
UpdateType string `json:"update_type"` // "major", "minor", "patch"
Changelog string `json:"changelog,omitempty"`
IsSecurityUpdate bool `json:"is_security_update"`
}
// PinnedUpdate represents an update that pins to a specific commit SHA.
type PinnedUpdate struct {
FilePath string `json:"file_path"`
OldUses string `json:"old_uses"` // "actions/checkout@v4"
NewUses string `json:"new_uses"` // "actions/checkout@8f4b7f84...# v4.1.1"
CommitSHA string `json:"commit_sha"`
Version string `json:"version"`
UpdateType string `json:"update_type"` // "major", "minor", "patch"
LineNumber int `json:"line_number"`
}
// Analyzer analyzes GitHub Action dependencies.
type Analyzer struct {
GitHubClient *github.Client
Cache DependencyCache // High-performance cache interface
RepoInfo git.RepoInfo
}
// DependencyCache defines the caching interface for dependency data.
type DependencyCache interface {
Get(key string) (any, bool)
Set(key string, value any) error
SetWithTTL(key string, value any, ttl time.Duration) error
}
// Note: Using git.RepoInfo instead of local GitInfo to avoid duplication
// NewAnalyzer creates a new dependency analyzer.
func NewAnalyzer(client *github.Client, repoInfo git.RepoInfo, cache DependencyCache) *Analyzer {
return &Analyzer{
GitHubClient: client,
Cache: cache,
RepoInfo: repoInfo,
}
}
// AnalyzeActionFile analyzes dependencies from an action.yml file.
func (a *Analyzer) AnalyzeActionFile(actionPath string) ([]Dependency, error) {
// Read and parse the action.yml file
action, err := a.parseCompositeAction(actionPath)
if err != nil {
return nil, fmt.Errorf("failed to parse action file: %w", err)
}
// Only analyze composite actions
if action.Runs.Using != compositeUsing {
return []Dependency{}, nil // No dependencies for non-composite actions
}
var dependencies []Dependency
// Analyze each step
for i, step := range action.Runs.Steps {
if step.Uses != "" {
// This is an action dependency
dep, err := a.analyzeActionDependency(step, i+1)
if err != nil {
// Log error but continue processing
continue
}
dependencies = append(dependencies, *dep)
} else if step.Run != "" {
// This is a shell script step
dep := a.analyzeShellScript(step, i+1)
dependencies = append(dependencies, *dep)
}
}
return dependencies, nil
}
// parseCompositeAction is implemented in parser.go
// analyzeActionDependency analyzes a single action dependency.
func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependency, error) {
// Parse the uses statement
owner, repo, version, versionType := a.parseUsesStatement(step.Uses)
if owner == "" || repo == "" {
return nil, fmt.Errorf("invalid uses statement: %s", step.Uses)
}
// Check if it's a local action (same repository)
isLocal := (owner == a.RepoInfo.Organization && repo == a.RepoInfo.Repository)
// Build dependency
dep := &Dependency{
Name: step.Name,
Uses: step.Uses,
Version: version,
VersionType: versionType,
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
Author: owner,
SourceURL: fmt.Sprintf("https://github.com/%s/%s", owner, repo),
IsLocalAction: isLocal,
IsShellScript: false,
WithParams: a.convertWithParams(step.With),
}
// Add marketplace URL for public actions
if !isLocal {
dep.MarketplaceURL = fmt.Sprintf("https://github.com/marketplace/actions/%s", repo)
}
// Fetch additional metadata from GitHub API if available
if a.GitHubClient != nil && !isLocal {
_ = a.enrichWithGitHubData(dep, owner, repo) // Ignore error - we have basic info
}
return dep, nil
}
// analyzeShellScript analyzes a shell script step.
func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Dependency {
// Create a shell script dependency
name := step.Name
if name == "" {
name = fmt.Sprintf("Shell Script #%d", stepNumber)
}
// Try to create a link to the script in the repository
scriptURL := ""
if a.RepoInfo.Organization != "" && a.RepoInfo.Repository != "" {
// This would ideally link to the specific line in the action.yml file
scriptURL = fmt.Sprintf("https://github.com/%s/%s/blob/%s/action.yml#L%d",
a.RepoInfo.Organization, a.RepoInfo.Repository, a.RepoInfo.DefaultBranch, stepNumber*10) // Rough estimate
}
return &Dependency{
Name: name,
Uses: "", // No uses for shell scripts
Version: "",
VersionType: LocalPath,
IsPinned: true, // Shell scripts are always "pinned"
Description: "Shell script execution",
Author: a.RepoInfo.Organization,
SourceURL: scriptURL,
WithParams: map[string]string{},
IsLocalAction: true,
IsShellScript: true,
ScriptURL: scriptURL,
}
}
// parseUsesStatement parses a GitHub Action uses statement.
func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string, versionType VersionType) {
// Handle different uses statement formats:
// - actions/checkout@v4
// - actions/checkout@main
// - actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e
// - ./local-action
// - docker://alpine:3.14
if strings.HasPrefix(uses, "./") || strings.HasPrefix(uses, "../") {
return "", "", uses, LocalPath
}
if strings.HasPrefix(uses, "docker://") {
return "", "", uses, LocalPath
}
// Standard GitHub action format: owner/repo@version
re := regexp.MustCompile(`^([^/]+)/([^@]+)@(.+)$`)
matches := re.FindStringSubmatch(uses)
if len(matches) != 4 {
return "", "", "", LocalPath
}
owner = matches[1]
repo = matches[2]
version = matches[3]
// Determine version type
switch {
case a.isCommitSHA(version):
versionType = CommitSHA
case a.isSemanticVersion(version):
versionType = SemanticVersion
default:
versionType = BranchName
}
return owner, repo, version, versionType
}
// isCommitSHA checks if a version string is a commit SHA.
func (a *Analyzer) isCommitSHA(version string) bool {
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
return len(version) >= 7 && re.MatchString(version)
}
// isSemanticVersion checks if a version string follows semantic versioning.
func (a *Analyzer) isSemanticVersion(version string) bool {
// Check for vX, vX.Y, vX.Y.Z format
re := regexp.MustCompile(`^v?\d+(\.\d+)*(\.\d+)?(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`)
return re.MatchString(version)
}
// isVersionPinned checks if a semantic version is pinned to a specific version.
func (a *Analyzer) isVersionPinned(version string) bool {
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
return re.MatchString(version)
}
// convertWithParams converts with parameters to string map.
func (a *Analyzer) convertWithParams(with map[string]any) map[string]string {
params := make(map[string]string)
for k, v := range with {
if str, ok := v.(string); ok {
params[k] = str
} else {
params[k] = fmt.Sprintf("%v", v)
}
}
return params
}
// CheckOutdated analyzes dependencies and finds those with newer versions available.
func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error) {
var outdated []OutdatedDependency
for _, dep := range deps {
if dep.IsShellScript || dep.IsLocalAction {
continue // Skip shell scripts and local actions
}
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
if owner == "" || repo == "" {
continue
}
latestVersion, latestSHA, err := a.getLatestVersion(owner, repo)
if err != nil {
continue // Skip on error, don't fail the whole operation
}
updateType := a.compareVersions(currentVersion, latestVersion)
if updateType != updateTypeNone {
outdated = append(outdated, OutdatedDependency{
Current: dep,
LatestVersion: latestVersion,
LatestSHA: latestSHA,
UpdateType: updateType,
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
})
}
}
return outdated, nil
}
// getLatestVersion fetches the latest release/tag for a repository.
func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) {
if a.GitHubClient == nil {
return "", "", fmt.Errorf("GitHub client not available")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Check cache first
cacheKey := fmt.Sprintf("latest:%s/%s", owner, repo)
if cached, exists := a.Cache.Get(cacheKey); exists {
if versionInfo, ok := cached.(map[string]string); ok {
return versionInfo["version"], versionInfo["sha"], nil
}
}
// Try to get latest release first
release, _, err := a.GitHubClient.Repositories.GetLatestRelease(ctx, owner, repo)
if err == nil && release.GetTagName() != "" {
// Get the commit SHA for this tag
tag, _, tagErr := a.GitHubClient.Git.GetRef(ctx, owner, repo, "tags/"+release.GetTagName())
sha := ""
if tagErr == nil && tag.GetObject() != nil {
sha = tag.GetObject().GetSHA()
}
version := release.GetTagName()
// Cache the result
versionInfo := map[string]string{"version": version, "sha": sha}
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, 1*time.Hour)
return version, sha, nil
}
// If no releases, try to get latest tags
tags, _, err := a.GitHubClient.Repositories.ListTags(ctx, owner, repo, &github.ListOptions{
PerPage: 10,
})
if err != nil || len(tags) == 0 {
return "", "", fmt.Errorf("no releases or tags found")
}
// Get the most recent tag
latestTag := tags[0]
version = latestTag.GetName()
sha = latestTag.GetCommit().GetSHA()
// Cache the result
versionInfo := map[string]string{"version": version, "sha": sha}
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, 1*time.Hour)
return version, sha, nil
}
// compareVersions compares two version strings and returns the update type.
func (a *Analyzer) compareVersions(current, latest string) string {
currentClean := strings.TrimPrefix(current, "v")
latestClean := strings.TrimPrefix(latest, "v")
if currentClean == latestClean {
return updateTypeNone
}
currentParts := a.parseVersionParts(currentClean)
latestParts := a.parseVersionParts(latestClean)
return a.determineUpdateType(currentParts, latestParts)
}
// parseVersionParts normalizes version string to 3-part semantic version.
func (a *Analyzer) parseVersionParts(version string) []string {
parts := strings.Split(version, ".")
for len(parts) < 3 {
parts = append(parts, "0")
}
return parts
}
// determineUpdateType compares version parts and returns update type.
func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) string {
if currentParts[0] != latestParts[0] {
return updateTypeMajor
}
if currentParts[1] != latestParts[1] {
return "minor"
}
if currentParts[2] != latestParts[2] {
return updateTypePatch
}
return updateTypePatch
}
// GeneratePinnedUpdate creates a pinned update for a dependency.
func (a *Analyzer) GeneratePinnedUpdate(
actionPath string,
dep Dependency,
latestVersion, latestSHA string,
) (*PinnedUpdate, error) {
if latestSHA == "" {
return nil, fmt.Errorf("no commit SHA available for %s", dep.Uses)
}
// Create the new pinned uses string: "owner/repo@sha # version"
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
newUses := fmt.Sprintf("%s/%s@%s # %s", owner, repo, latestSHA, latestVersion)
updateType := a.compareVersions(currentVersion, latestVersion)
return &PinnedUpdate{
FilePath: actionPath,
OldUses: dep.Uses,
NewUses: newUses,
CommitSHA: latestSHA,
Version: latestVersion,
UpdateType: updateType,
LineNumber: 0, // Will be determined during file update
}, nil
}
// ApplyPinnedUpdates applies pinned updates to action files.
func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error {
// Group updates by file path
updatesByFile := make(map[string][]PinnedUpdate)
for _, update := range updates {
updatesByFile[update.FilePath] = append(updatesByFile[update.FilePath], update)
}
// Apply updates to each file
for filePath, fileUpdates := range updatesByFile {
if err := a.updateActionFile(filePath, fileUpdates); err != nil {
return fmt.Errorf("failed to update %s: %w", filePath, err)
}
}
return nil
}
// updateActionFile applies updates to a single action file.
func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error {
// Read the file
content, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Create backup
backupPath := filePath + ".backup"
if err := os.WriteFile(backupPath, content, 0644); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
// Apply updates to content
lines := strings.Split(string(content), "\n")
for _, update := range updates {
// Find and replace the uses line
for i, line := range lines {
if strings.Contains(line, update.OldUses) {
// Replace the uses statement while preserving indentation
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
usesPrefix := "uses: "
lines[i] = indent + usesPrefix + update.NewUses
update.LineNumber = i + 1 // Store line number for reference
break
}
}
}
// Write updated content
updatedContent := strings.Join(lines, "\n")
if err := os.WriteFile(filePath, []byte(updatedContent), 0644); err != nil {
return fmt.Errorf("failed to write updated file: %w", err)
}
// Validate the updated file by trying to parse it
if err := a.validateActionFile(filePath); err != nil {
// Rollback on validation failure
if rollbackErr := os.Rename(backupPath, filePath); rollbackErr != nil {
return fmt.Errorf("validation failed and rollback failed: %v (original error: %w)", rollbackErr, err)
}
return fmt.Errorf("validation failed, rolled back changes: %w", err)
}
// Remove backup on success
_ = os.Remove(backupPath)
return nil
}
// validateActionFile validates that an action.yml file is still valid after updates.
func (a *Analyzer) validateActionFile(filePath string) error {
_, err := a.parseCompositeAction(filePath)
return err
}
// enrichWithGitHubData fetches additional information from GitHub API.
func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Check cache first
cacheKey := fmt.Sprintf("repo:%s/%s", owner, repo)
if cached, exists := a.Cache.Get(cacheKey); exists {
if repository, ok := cached.(*github.Repository); ok {
dep.Description = repository.GetDescription()
return nil
}
}
// Fetch from API
repository, _, err := a.GitHubClient.Repositories.Get(ctx, owner, repo)
if err != nil {
return fmt.Errorf("failed to fetch repository info: %w", err)
}
// Cache the result with 1 hour TTL
_ = a.Cache.SetWithTTL(cacheKey, repository, 1*time.Hour) // Ignore cache errors
// Enrich dependency with API data
dep.Description = repository.GetDescription()
return nil
}

View File

@@ -0,0 +1,547 @@
package dependencies
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/go-github/v57/github"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
tests := []struct {
name string
actionYML string
expectError bool
expectDeps bool
expectedLen int
expectedDeps []string
}{
{
name: "simple action - no dependencies",
actionYML: testutil.SimpleActionYML,
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "composite action with dependencies",
actionYML: testutil.CompositeActionYML,
expectError: false,
expectDeps: true,
expectedLen: 2,
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v3"},
},
{
name: "docker action - no step dependencies",
actionYML: testutil.DockerActionYML,
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "invalid action file",
actionYML: testutil.InvalidActionYML,
expectError: true,
},
{
name: "minimal action - no dependencies",
actionYML: testutil.MinimalActionYML,
expectError: false,
expectDeps: false,
expectedLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary action file
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, tt.actionYML)
// Create analyzer with mock GitHub client
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// Analyze the action file
deps, err := analyzer.AnalyzeActionFile(actionPath)
// Check error expectation
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
// Check dependencies
if tt.expectDeps {
if len(deps) != tt.expectedLen {
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
}
// Check specific dependencies if provided
if tt.expectedDeps != nil {
for i, expectedDep := range tt.expectedDeps {
if i >= len(deps) {
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
continue
}
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
}
}
}
} else if len(deps) != 0 {
t.Errorf("expected no dependencies, got %d", len(deps))
}
})
}
}
func TestAnalyzer_ParseUsesStatement(t *testing.T) {
tests := []struct {
name string
uses string
expectedOwner string
expectedRepo string
expectedVersion string
expectedType VersionType
}{
{
name: "semantic version",
uses: "actions/checkout@v4",
expectedOwner: "actions",
expectedRepo: "checkout",
expectedVersion: "v4",
expectedType: SemanticVersion,
},
{
name: "semantic version with patch",
uses: "actions/setup-node@v3.8.1",
expectedOwner: "actions",
expectedRepo: "setup-node",
expectedVersion: "v3.8.1",
expectedType: SemanticVersion,
},
{
name: "commit SHA",
uses: "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
expectedOwner: "actions",
expectedRepo: "checkout",
expectedVersion: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
expectedType: CommitSHA,
},
{
name: "branch reference",
uses: "octocat/hello-world@main",
expectedOwner: "octocat",
expectedRepo: "hello-world",
expectedVersion: "main",
expectedType: BranchName,
},
}
analyzer := &Analyzer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner, repo, version, versionType := analyzer.parseUsesStatement(tt.uses)
testutil.AssertEqual(t, tt.expectedOwner, owner)
testutil.AssertEqual(t, tt.expectedRepo, repo)
testutil.AssertEqual(t, tt.expectedVersion, version)
testutil.AssertEqual(t, tt.expectedType, versionType)
})
}
}
func TestAnalyzer_VersionChecking(t *testing.T) {
tests := []struct {
name string
version string
isPinned bool
isCommitSHA bool
isSemantic bool
}{
{
name: "semantic version major",
version: "v4",
isPinned: false,
isCommitSHA: false,
isSemantic: true,
},
{
name: "semantic version full",
version: "v3.8.1",
isPinned: true,
isCommitSHA: false,
isSemantic: true,
},
{
name: "commit SHA full",
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
isPinned: true,
isCommitSHA: true,
isSemantic: false,
},
{
name: "commit SHA short",
version: "8f4b7f8",
isPinned: false,
isCommitSHA: true,
isSemantic: false,
},
{
name: "branch reference",
version: "main",
isPinned: false,
isCommitSHA: false,
isSemantic: false,
},
{
name: "numeric version",
version: "1.2.3",
isPinned: true,
isCommitSHA: false,
isSemantic: true,
},
}
analyzer := &Analyzer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isPinned := analyzer.isVersionPinned(tt.version)
isCommitSHA := analyzer.isCommitSHA(tt.version)
isSemantic := analyzer.isSemanticVersion(tt.version)
testutil.AssertEqual(t, tt.isPinned, isPinned)
testutil.AssertEqual(t, tt.isCommitSHA, isCommitSHA)
testutil.AssertEqual(t, tt.isSemantic, isSemantic)
})
}
}
func TestAnalyzer_GetLatestVersion(t *testing.T) {
// Create mock GitHub client with test responses
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
tests := []struct {
name string
owner string
repo string
expectedVersion string
expectedSHA string
expectError bool
}{
{
name: "valid repository",
owner: "actions",
repo: "checkout",
expectedVersion: "v4.1.1",
expectedSHA: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
expectError: false,
},
{
name: "another valid repository",
owner: "actions",
repo: "setup-node",
expectedVersion: "v4.0.0",
expectedSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
version, sha, err := analyzer.getLatestVersion(tt.owner, tt.repo)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
testutil.AssertEqual(t, tt.expectedVersion, version)
testutil.AssertEqual(t, tt.expectedSHA, sha)
})
}
}
func TestAnalyzer_CheckOutdated(t *testing.T) {
// Create mock GitHub client
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// Create test dependencies
dependencies := []Dependency{
{
Name: "actions/checkout",
Version: "v3",
IsPinned: false,
VersionType: SemanticVersion,
Description: "Action for checking out a repo",
},
{
Name: "actions/setup-node",
Version: "v4.0.0",
IsPinned: true,
VersionType: SemanticVersion,
Description: "Setup Node.js",
},
}
outdated, err := analyzer.CheckOutdated(dependencies)
testutil.AssertNoError(t, err)
// Should detect that actions/checkout v3 is outdated (latest is v4.1.1)
if len(outdated) == 0 {
t.Error("expected to find outdated dependencies")
}
found := false
for _, dep := range outdated {
if dep.Current.Name == "actions/checkout" && dep.Current.Version == "v3" {
found = true
if dep.LatestVersion != "v4.1.1" {
t.Errorf("expected latest version v4.1.1, got %s", dep.LatestVersion)
}
if dep.UpdateType != "major" {
t.Errorf("expected major update, got %s", dep.UpdateType)
}
}
}
if !found {
t.Error("expected to find actions/checkout v3 as outdated")
}
}
func TestAnalyzer_CompareVersions(t *testing.T) {
analyzer := &Analyzer{}
tests := []struct {
name string
current string
latest string
expectedType string
}{
{
name: "major version difference",
current: "v3.0.0",
latest: "v4.0.0",
expectedType: "major",
},
{
name: "minor version difference",
current: "v4.0.0",
latest: "v4.1.0",
expectedType: "minor",
},
{
name: "patch version difference",
current: "v4.1.0",
latest: "v4.1.1",
expectedType: "patch",
},
{
name: "no difference",
current: "v4.1.1",
latest: "v4.1.1",
expectedType: "none",
},
{
name: "floating to specific",
current: "v4",
latest: "v4.1.1",
expectedType: "patch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
updateType := analyzer.compareVersions(tt.current, tt.latest)
testutil.AssertEqual(t, tt.expectedType, updateType)
})
}
}
func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Create a test action file with composite steps
actionContent := `name: 'Test Composite Action'
description: 'Test action for update testing'
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3.8.0
with:
node-version: '18'
`
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, actionContent)
// Create analyzer
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// Create test dependency
dep := Dependency{
Name: "actions/checkout",
Version: "v3",
IsPinned: false,
VersionType: SemanticVersion,
Description: "Action for checking out a repo",
}
// Generate pinned update
update, err := analyzer.GeneratePinnedUpdate(
actionPath,
dep,
"v4.1.1",
"8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
)
testutil.AssertNoError(t, err)
// Verify update details
testutil.AssertEqual(t, actionPath, update.FilePath)
testutil.AssertEqual(t, "actions/checkout@v3", update.OldUses)
testutil.AssertStringContains(t, update.NewUses, "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e")
testutil.AssertStringContains(t, update.NewUses, "# v4.1.1")
testutil.AssertEqual(t, "major", update.UpdateType)
}
func TestAnalyzer_WithCache(t *testing.T) {
// Test that caching works properly
mockResponses := testutil.MockGitHubResponses()
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: githubClient,
Cache: cacheInstance,
}
// First call should hit the API
version1, sha1, err1 := analyzer.getLatestVersion("actions", "checkout")
testutil.AssertNoError(t, err1)
// Second call should hit the cache
version2, sha2, err2 := analyzer.getLatestVersion("actions", "checkout")
testutil.AssertNoError(t, err2)
// Results should be identical
testutil.AssertEqual(t, version1, version2)
testutil.AssertEqual(t, sha1, sha2)
}
func TestAnalyzer_RateLimitHandling(t *testing.T) {
// Create mock client that returns rate limit error
rateLimitResponse := &http.Response{
StatusCode: 403,
Header: http.Header{
"X-RateLimit-Remaining": []string{"0"},
"X-RateLimit-Reset": []string{fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix())},
},
Body: testutil.NewStringReader(`{"message": "API rate limit exceeded"}`),
}
mockClient := &testutil.MockHTTPClient{
Responses: map[string]*http.Response{
"GET https://api.github.com/repos/actions/checkout/releases/latest": rateLimitResponse,
},
}
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
analyzer := &Analyzer{
GitHubClient: client,
Cache: cacheInstance,
}
// This should handle the rate limit gracefully
_, _, err := analyzer.getLatestVersion("actions", "checkout")
if err == nil {
t.Error("expected rate limit error to be returned")
}
testutil.AssertStringContains(t, err.Error(), "rate limit")
}
func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
// Test graceful degradation when GitHub client is not available
analyzer := &Analyzer{
GitHubClient: nil,
Cache: nil,
}
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.CompositeActionYML)
deps, err := analyzer.AnalyzeActionFile(actionPath)
// Should still parse dependencies but without GitHub API data
testutil.AssertNoError(t, err)
if len(deps) > 0 {
// Dependencies should have basic info but no GitHub API data
for _, dep := range deps {
if dep.Description != "" {
t.Error("expected empty description when GitHub client is not available")
}
}
}
}
// mockTransport wraps our mock HTTP client for GitHub client.
type mockTransport struct {
client *testutil.MockHTTPClient
}
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.client.Do(req)
}

View File

@@ -0,0 +1,55 @@
package dependencies
import (
"time"
"github.com/ivuorinen/gh-action-readme/internal/cache"
)
// CacheAdapter adapts the cache.Cache to implement DependencyCache interface.
type CacheAdapter struct {
cache *cache.Cache
}
// NewCacheAdapter creates a new cache adapter.
func NewCacheAdapter(c *cache.Cache) *CacheAdapter {
return &CacheAdapter{cache: c}
}
// Get retrieves a value from the cache.
func (ca *CacheAdapter) Get(key string) (any, bool) {
return ca.cache.Get(key)
}
// Set stores a value in the cache with default TTL.
func (ca *CacheAdapter) Set(key string, value any) error {
return ca.cache.Set(key, value)
}
// SetWithTTL stores a value in the cache with custom TTL.
func (ca *CacheAdapter) SetWithTTL(key string, value any, ttl time.Duration) error {
return ca.cache.SetWithTTL(key, value, ttl)
}
// NoOpCache implements DependencyCache with no-op operations for when caching is disabled.
type NoOpCache struct{}
// NewNoOpCache creates a new no-op cache.
func NewNoOpCache() *NoOpCache {
return &NoOpCache{}
}
// Get always returns false (cache miss).
func (noc *NoOpCache) Get(_ string) (any, bool) {
return nil, false
}
// Set does nothing.
func (noc *NoOpCache) Set(_ string, _ any) error {
return nil
}
// SetWithTTL does nothing.
func (noc *NoOpCache) SetWithTTL(_ string, _ any, _ time.Duration) error {
return nil
}

View File

@@ -0,0 +1,51 @@
package dependencies
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// parseCompositeActionFromFile reads and parses a composite action file.
func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) {
// Read the file
data, err := os.ReadFile(actionPath)
if err != nil {
return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err)
}
// Parse YAML
var action ActionWithComposite
if err := yaml.Unmarshal(data, &action); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
return &action, nil
}
// parseCompositeAction parses an action.yml file with composite action support.
func (a *Analyzer) parseCompositeAction(actionPath string) (*ActionWithComposite, error) {
// Use the real file parser
action, err := a.parseCompositeActionFromFile(actionPath)
if err != nil {
return nil, err
}
// If this is not a composite action, return empty steps
if action.Runs.Using != compositeUsing {
action.Runs.Steps = []CompositeStep{}
}
return action, nil
}
// IsCompositeAction checks if an action file defines a composite action.
func IsCompositeAction(actionPath string) (bool, error) {
action, err := (&Analyzer{}).parseCompositeActionFromFile(actionPath)
if err != nil {
return false, err
}
return action.Runs.Using == compositeUsing, nil
}

View File

@@ -0,0 +1,27 @@
package dependencies
// CompositeStep represents a step in a composite action.
type CompositeStep struct {
Name string `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
With map[string]any `yaml:"with,omitempty"`
Run string `yaml:"run,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
}
// CompositeRuns represents the runs section of a composite action.
type CompositeRuns struct {
Using string `yaml:"using"`
Steps []CompositeStep `yaml:"steps"`
}
// ActionWithComposite represents an action.yml with composite steps support.
type ActionWithComposite struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Inputs map[string]any `yaml:"inputs"`
Outputs map[string]any `yaml:"outputs"`
Runs CompositeRuns `yaml:"runs"`
Branding any `yaml:"branding,omitempty"`
}