diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3999ccc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`plasmactl-bump` is a launchr plugin to update the version of Ansible roles which were updated in last commit. It detects modified resources (Ansible roles) via git history, bumps their versions, and propagates version changes through dependency chains across the Plasma platform. + +**Module**: `github.com/skilld-labs/plasmactl-bump/v2` +**Go version**: 1.25.0 (CGO disabled) +**Framework**: [launchr](https://github.com/launchrctl/launchr) plugin system + +## Build & Development Commands + +```bash +make build # Build binary to ./bin/launchr +make test # Run all tests (requires gotestfmt) +make test-short # Run short tests only +make lint # Run golangci-lint with auto-fix +make deps # Download Go dependencies +make all # deps + test-short + build +make clean # Remove ./bin/ +DEBUG=1 make build # Build with debug symbols +``` + +## Architecture + +### Plugin Registration + +The plugin registers via `init()` in `plugin.go`, using `//go:embed` for YAML action definitions. Entry point is `cmd/launchr/main.go` which imports the plugin as a blank import. + +### Three Actions + +1. **bump** (`actionBump.go`): Detects files changed since the last "Bumper"-authored commit, maps them to resources, and sets each resource's version to the commit's short hash (first 13 chars) in `meta/plasma.yaml`. Creates a commit authored by `Bumper `. + +2. **bump --sync** (`actionSync.go`, `actionSync.resources.go`, `actionSync.variables.go`): Propagates versions through dependency chains. Works on a post-`compose` build directory (`.compose/build`). Builds a chronological timeline of version changes, then applies propagation so dependent resources get composite versions like `original-propagated`. + +3. **dependencies** (`dependencies.go`): Shows dependency tree for a given resource. + +### Core Packages + +- **`pkg/repository`** (`git.go`): Git operations via `go-git`. `Bumper` struct handles commit traversal, detecting own commits, and creating bump commits. All `PlainOpen` calls use `PlainOpenWithOptions` with `EnableDotGitCommonDir: true` to support git worktrees. + +- **`pkg/sync`**: Resource/variable management and dependency resolution. + - `inventory.go`: Builds resource dependency graph by walking filesystem, parsing `tasks/*.yaml` for `include_role` references, topologically sorting with `topsort`. + - `inventory.resource.go`: `Resource` type, `OrderedMap[T]` generic ordered map, MRN (Machine Resource Name) format `platform__kind__role`, version read/write from `meta/plasma.yaml`. + - `inventory.variable.go`: Variable tracking from `group_vars/vars.yaml` and `vault.yaml`, variable-to-variable and variable-to-resource dependency maps. + - `timeline.go`: `TimelineItem` interface with `TimelineResourcesItem` and `TimelineVariablesItem` implementations, chronological sorting. + - `filesCrawler.go`: File discovery with Jinja2 template variable extraction. + - `yaml.go`: YAML parsing with ansible-vault support. + +### Key Conventions + +- **MRN format**: `platform__kind__role` (double underscore separator), e.g. `interaction__softwares__grafana` +- **Resource path pattern**: `{platform}/{kind}/roles/{role}/meta/plasma.yaml` +- **Valid kinds**: applications, services, softwares, executors, flows, skills, functions, libraries, entities +- **Version format**: Short git hash (`abc1234567890`) or composite `baseversion-propagatedversion` +- **Bump commit author**: `Bumper` with email `no-reply@skilld.cloud`, message `versions bump` + +### Sync Flow (Propagation) + +1. Initialize inventory from `.compose/build` directory +2. Gather resources from domain (`.`) and packages (`.compose/packages/`) +3. Resolve duplicate resources across namespaces by matching build version +4. Build timeline: find commits where each resource version was set +5. Build propagation map: iterate timeline chronologically, find dependents +6. Update resources: compose `original-propagated` version strings + +### Concurrency + +Worker goroutines (bounded by `runtime.NumCPU()`) process packages in parallel with mutex-protected shared state. The `sync` stdlib package is imported as `async` to avoid name collision with the `pkg/sync` package. + +## Linting + +Uses golangci-lint v2 with: dupl (threshold: 100), errcheck, goconst, gosec, govet, ineffassign, revive, staticcheck, unused. Formatter: goimports. + +## CI + +GitHub Actions workflow (`.github/workflows/commit.yml`) runs four jobs: sync with vault integration, basic bump commands, go-linters, go-tests. diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/actionSync.resources.go b/actionSync.resources.go index b2af5e0..5f6ec83 100644 --- a/actionSync.resources.go +++ b/actionSync.resources.go @@ -224,7 +224,7 @@ func collectResourcesCommits(r *git.Repository, beforeDate string) (*sync.Ordere } func (s *syncAction) findResourcesChangeTime(ctx context.Context, namespaceResources *sync.OrderedMap[*sync.Resource], gitPath string, mx *async.Mutex, p *pterm.ProgressbarPrinter) error { - repo, err := git.PlainOpen(gitPath) + repo, err := git.PlainOpenWithOptions(gitPath, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) if err != nil { return fmt.Errorf("%s - %w", gitPath, err) } @@ -250,7 +250,7 @@ func (s *syncAction) findResourcesChangeTime(ctx context.Context, namespaceResou if !ok { return } - if err = s.processResource(r, groups, commitsMap, repo, gitPath, mx); err != nil { + if err = s.processResource(r, groups, commitsMap, repo, mx); err != nil { if p != nil { _, _ = p.Stop() } @@ -295,12 +295,7 @@ func (s *syncAction) findResourcesChangeTime(ctx context.Context, namespaceResou return nil } -func (s *syncAction) processResource(resource *sync.Resource, commitsGroups *sync.OrderedMap[*CommitsGroup], commitsMap map[string]map[string]string, _ *git.Repository, gitPath string, mx *async.Mutex) error { - repo, err := git.PlainOpen(gitPath) - if err != nil { - return fmt.Errorf("%s - %w", gitPath, err) - } - +func (s *syncAction) processResource(resource *sync.Resource, commitsGroups *sync.OrderedMap[*CommitsGroup], commitsMap map[string]map[string]string, repo *git.Repository, mx *async.Mutex) error { buildResource := sync.NewResource(resource.GetName(), s.buildDir) currentVersion, debug, err := buildResource.GetVersion() for _, d := range debug { diff --git a/actionSync.variables.go b/actionSync.variables.go index a9ce2be..cfef895 100644 --- a/actionSync.variables.go +++ b/actionSync.variables.go @@ -41,6 +41,11 @@ func (s *syncAction) populateTimelineVars(buildInv *sync.Inventory) error { varsFiles = append(varsFiles, paths...) } + repo, err := git.PlainOpenWithOptions(s.domainDir, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) + if err != nil { + return fmt.Errorf("%s - %w", s.domainDir, err) + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -66,7 +71,7 @@ func (s *syncAction) populateTimelineVars(buildInv *sync.Inventory) error { if !ok { return } - if err = s.findVariableUpdateTime(varsFile, buildInv, s.domainDir, &mx); err != nil { + if err = s.findVariableUpdateTime(varsFile, buildInv, repo, &mx); err != nil { if p != nil { _, _ = p.Stop() } @@ -109,12 +114,7 @@ func (s *syncAction) populateTimelineVars(buildInv *sync.Inventory) error { return nil } -func (s *syncAction) findVariableUpdateTime(varsFile string, inv *sync.Inventory, gitPath string, mx *async.Mutex) error { - repo, err := git.PlainOpen(gitPath) - if err != nil { - return fmt.Errorf("%s - %w", gitPath, err) - } - +func (s *syncAction) findVariableUpdateTime(varsFile string, inv *sync.Inventory, repo *git.Repository, mx *async.Mutex) error { ref, err := repo.Head() if err != nil { return fmt.Errorf("can't get HEAD ref > %w", err) diff --git a/pkg/repository/git.go b/pkg/repository/git.go index 6e5d54f..ff9641a 100644 --- a/pkg/repository/git.go +++ b/pkg/repository/git.go @@ -38,7 +38,7 @@ type Commit struct { // NewBumper returns new instance of [Bumper]. func NewBumper() (*Bumper, error) { - r, err := git.PlainOpen("./") + r, err := git.PlainOpenWithOptions("./", &git.PlainOpenOptions{EnableDotGitCommonDir: true}) if err != nil { return nil, err } diff --git a/pkg/repository/git_test.go b/pkg/repository/git_test.go new file mode 100644 index 0000000..4f04a33 --- /dev/null +++ b/pkg/repository/git_test.go @@ -0,0 +1,458 @@ +package repository + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/storer" +) + +// initTestRepo creates a temporary git repository with one commit. +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("git init: %v", err) + } + + w, err := repo.Worktree() + if err != nil { + t.Fatalf("worktree: %v", err) + } + + if err = os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0600); err != nil { + t.Fatalf("write file: %v", err) + } + + if _, err = w.Add("README.md"); err != nil { + t.Fatalf("git add: %v", err) + } + + _, err = w.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("commit: %v", err) + } + + return dir +} + +// initTestRepoWithResource creates a git repo with an Ansible role resource +// structure and multiple commits, including a bump commit. +func initTestRepoWithResource(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("git init: %v", err) + } + + w, err := repo.Worktree() + if err != nil { + t.Fatalf("worktree: %v", err) + } + + // Commit 1: initial resource with meta/plasma.yaml. + metaDir := filepath.Join(dir, "interaction", "softwares", "roles", "grafana", "meta") + if err = os.MkdirAll(metaDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + tasksDir := filepath.Join(dir, "interaction", "softwares", "roles", "grafana", "tasks") + if err = os.MkdirAll(tasksDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + plasmaYaml := "plasma:\n version: \"aaa1111111111\"\n description: Test resource\n" + if err = os.WriteFile(filepath.Join(metaDir, "plasma.yaml"), []byte(plasmaYaml), 0600); err != nil { + t.Fatalf("write plasma.yaml: %v", err) + } + + configYaml := "- include_role:\n name: interaction.softwares.postgres\n" + if err = os.WriteFile(filepath.Join(tasksDir, "configuration.yaml"), []byte(configYaml), 0600); err != nil { + t.Fatalf("write configuration.yaml: %v", err) + } + + if _, err = w.Add("."); err != nil { + t.Fatalf("git add: %v", err) + } + + _, err = w.Commit("add grafana resource", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Developer", + Email: "dev@test.com", + When: time.Now().Add(-2 * time.Hour), + }, + }) + if err != nil { + t.Fatalf("commit 1: %v", err) + } + + // Commit 2: bump commit (by Bumper author). + plasmaYaml = "plasma:\n version: \"bbb2222222222\"\n description: Test resource\n" + if err = os.WriteFile(filepath.Join(metaDir, "plasma.yaml"), []byte(plasmaYaml), 0600); err != nil { + t.Fatalf("write plasma.yaml: %v", err) + } + + if _, err = w.Add("."); err != nil { + t.Fatalf("git add: %v", err) + } + + _, err = w.Commit(BumpMessage, &git.CommitOptions{ + Author: &object.Signature{ + Name: Author, + Email: "no-reply@skilld.cloud", + When: time.Now().Add(-1 * time.Hour), + }, + }) + if err != nil { + t.Fatalf("commit 2: %v", err) + } + + // Commit 3: a regular change after bump. + templateDir := filepath.Join(dir, "interaction", "softwares", "roles", "grafana", "templates") + if err = os.MkdirAll(templateDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + if err = os.WriteFile(filepath.Join(templateDir, "config.j2"), []byte("port={{ grafana_port }}"), 0600); err != nil { + t.Fatalf("write template: %v", err) + } + + if _, err = w.Add("."); err != nil { + t.Fatalf("git add: %v", err) + } + + _, err = w.Commit("update grafana template", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Developer", + Email: "dev@test.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("commit 3: %v", err) + } + + return dir +} + +// createWorktree creates a git worktree using the git CLI. +func createWorktree(t *testing.T, repoDir string) string { + t.Helper() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git CLI not available") + } + + cmd := exec.Command("git", "branch", "test-wt") + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git branch: %s: %v", out, err) + } + + wtDir := filepath.Join(t.TempDir(), "wt") + cmd = exec.Command("git", "worktree", "add", wtDir, "test-wt") + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git worktree add: %s: %v", out, err) + } + + return wtDir +} + +func TestNewBumper(t *testing.T) { + repoDir := initTestRepo(t) + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + if err = os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + + bumper, err := NewBumper() + if err != nil { + t.Fatalf("NewBumper: %v", err) + } + + if bumper.IsOwnCommit() { + t.Error("expected IsOwnCommit() to be false") + } + + commits, err := bumper.GetCommits(false) + if err != nil { + t.Fatalf("GetCommits: %v", err) + } + + if len(commits) == 0 { + t.Error("expected at least one commit") + } +} + +func TestNewBumperWorktree(t *testing.T) { + repoDir := initTestRepo(t) + wtDir := createWorktree(t, repoDir) + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + if err = os.Chdir(wtDir); err != nil { + t.Fatal(err) + } + + bumper, err := NewBumper() + if err != nil { + t.Fatalf("NewBumper in worktree: %v", err) + } + + if bumper.IsOwnCommit() { + t.Error("expected IsOwnCommit() to be false") + } + + commits, err := bumper.GetCommits(false) + if err != nil { + t.Fatalf("GetCommits in worktree: %v", err) + } + + if len(commits) == 0 { + t.Error("expected at least one commit") + } +} + +func TestPlainOpenWithOptionsRegularRepo(t *testing.T) { + repoDir := initTestRepo(t) + + repo, err := git.PlainOpenWithOptions(repoDir, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) + if err != nil { + t.Fatalf("PlainOpenWithOptions on regular repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("Head: %v", err) + } + + _, err = repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("CommitObject: %v", err) + } +} + +func TestPlainOpenWithOptionsWorktree(t *testing.T) { + repoDir := initTestRepo(t) + wtDir := createWorktree(t, repoDir) + + repo, err := git.PlainOpenWithOptions(wtDir, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) + if err != nil { + t.Fatalf("PlainOpenWithOptions on worktree: %v", err) + } + + // Verify commit objects are accessible (requires shared objects database from main repo). + head, err := repo.Head() + if err != nil { + t.Fatalf("Head: %v", err) + } + + _, err = repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("CommitObject in worktree: %v", err) + } + + // Verify log iteration works (used by sync timeline population). + cIter, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + t.Fatalf("Log in worktree: %v", err) + } + + count := 0 + err = cIter.ForEach(func(c *object.Commit) error { + count++ + return nil + }) + if err != nil { + t.Fatalf("Log.ForEach in worktree: %v", err) + } + + if count == 0 { + t.Error("expected at least one commit from log iteration") + } +} + +// TestBumpWorkflowInWorktree is an integration test that exercises the full +// bump workflow from a git worktree: opening the repo, detecting the bumper +// commit, collecting commits with changed files, and accessing file contents +// from commit objects (the pattern used by sync/propagation). +func TestBumpWorkflowInWorktree(t *testing.T) { + repoDir := initTestRepoWithResource(t) + wtDir := createWorktree(t, repoDir) + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + if err = os.Chdir(wtDir); err != nil { + t.Fatal(err) + } + + // Phase 1: Bumper detects own commit correctly. + bumper, err := NewBumper() + if err != nil { + t.Fatalf("NewBumper in worktree: %v", err) + } + + // HEAD is a developer commit (commit 3), not a bump commit. + if bumper.IsOwnCommit() { + t.Error("expected IsOwnCommit() to be false for developer commit") + } + + // Phase 2: GetCommits returns only the post-bump commit (stops at Bumper author). + commits, err := bumper.GetCommits(false) + if err != nil { + t.Fatalf("GetCommits: %v", err) + } + + if len(commits) == 0 { + t.Fatal("expected at least one commit") + } + + // The commit after bump should contain the template file change. + foundTemplate := false + for _, c := range commits { + for _, f := range c.Files { + if filepath.Base(f) == "config.j2" { + foundTemplate = true + } + } + } + + if !foundTemplate { + t.Error("expected to find config.j2 in post-bump commits") + } + + // Phase 3: Sync patterns - open by path, access commit objects and files. + // This mirrors what findResourcesChangeTime and processResource do. + repo, err := git.PlainOpenWithOptions(wtDir, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) + if err != nil { + t.Fatalf("PlainOpenWithOptions: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("Head: %v", err) + } + + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("CommitObject: %v", err) + } + + // Access resource file from commit tree (pattern from processResource). + metaPath := filepath.Join("interaction", "softwares", "roles", "grafana", "meta", "plasma.yaml") + file, err := headCommit.File(metaPath) + if err != nil { + t.Fatalf("File(%s) from commit: %v", metaPath, err) + } + + contents, err := file.Contents() + if err != nil { + t.Fatalf("file.Contents: %v", err) + } + + if contents == "" { + t.Error("expected non-empty plasma.yaml contents") + } + + // Phase 4: Log iteration with author detection (pattern from collectResourcesCommits). + cIter, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + t.Fatalf("Log: %v", err) + } + + var bumperCommits, devCommits int + err = cIter.ForEach(func(c *object.Commit) error { + if c.Author.Name == Author { + bumperCommits++ + } else { + devCommits++ + } + return nil + }) + if err != nil { + t.Fatalf("Log.ForEach: %v", err) + } + + if bumperCommits != 1 { + t.Errorf("expected 1 bumper commit, got %d", bumperCommits) + } + + if devCommits != 2 { + t.Errorf("expected 2 developer commits, got %d", devCommits) + } + + // Phase 5: Tree diff between commits (pattern from GetCommits). + cIter, err = repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + t.Fatalf("Log: %v", err) + } + + var prevCommit *object.Commit + diffFound := false + + err = cIter.ForEach(func(c *object.Commit) error { + if prevCommit == nil { + prevCommit = c + return nil + } + + prevTree, errT := prevCommit.Tree() + if errT != nil { + return errT + } + + currentTree, errT := c.Tree() + if errT != nil { + return errT + } + + diff, errT := currentTree.Diff(prevTree) + if errT != nil { + return errT + } + + if len(diff) > 0 { + diffFound = true + } + + prevCommit = c + return storer.ErrStop + }) + if err != nil { + t.Fatalf("Log.ForEach diff: %v", err) + } + + if !diffFound { + t.Error("expected to find diffs between commits") + } +}