From c473b8386ea9916fc28357ba897b331d0b5a8486 Mon Sep 17 00:00:00 2001 From: Michele Baldessari Date: Thu, 9 Jul 2026 10:43:22 +0200 Subject: [PATCH] WIP Add support for pattern-metadata --- src/cmd/cmd_suite_test.go | 9 ++ src/cmd/init.go | 4 + src/cmd/init_test.go | 23 +++ .../resources/pattern-metadata.yaml.tmpl | 45 ++++++ src/internal/pattern/metadata.go | 113 +++++++++++++++ src/internal/pattern/metadata_test.go | 134 ++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 src/internal/embedded/resources/pattern-metadata.yaml.tmpl create mode 100644 src/internal/pattern/metadata.go create mode 100644 src/internal/pattern/metadata_test.go diff --git a/src/cmd/cmd_suite_test.go b/src/cmd/cmd_suite_test.go index 1e81090..f837bce 100644 --- a/src/cmd/cmd_suite_test.go +++ b/src/cmd/cmd_suite_test.go @@ -137,6 +137,15 @@ func runCLI(dir string, args ...string) *gexec.Session { return session } +func verifyPatternMetadataCreated(dir, patternName string) { + metadataPath := filepath.Join(dir, "pattern-metadata.yaml") + Expect(metadataPath).To(BeAnExistingFile(), "pattern-metadata.yaml should exist") + + content, err := os.ReadFile(metadataPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("name: " + patternName)) +} + func verifySkillsInstalled(dir string) { for _, target := range []string{".claude", ".cursor"} { skillDir := filepath.Join(dir, target, "skills", "pattern-author") diff --git a/src/cmd/init.go b/src/cmd/init.go index cdf06b6..bec2dda 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -57,6 +57,10 @@ func runInit(withSecrets bool) error { } } + if err := pattern.GeneratePatternMetadata(actualPatternName, repoRoot); err != nil { + return fmt.Errorf("error generating pattern metadata: %w", err) + } + if err := fileutils.InstallSkills(repoRoot); err != nil { return fmt.Errorf("error installing skills: %w", err) } diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index f9e4d17..437428d 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -69,6 +69,10 @@ var _ = Describe("patternizer init", func() { verifySkillsInstalled(tempDir) }) + It("should create a pattern-metadata.yaml file", func() { + verifyPatternMetadataCreated(tempDir, filepath.Base(tempDir)) + }) + It("should create an appropriate global values file", func() { globalValuesFile := filepath.Join(tempDir, "values-global.yaml") expectedGlobalValues := types.ValuesGlobal{ @@ -763,6 +767,25 @@ var _ = Describe("patternizer init --with-secrets", func() { }) }) +var _ = Describe("patternizer init pattern-metadata", func() { + Context("on a directory with an existing pattern-metadata.yaml", Ordered, func() { + var tempDir string + const existingContent = "metadata_version: \"1.0\"\nname: custom-name\n" + + BeforeAll(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "pattern-metadata.yaml"), []byte(existingContent), 0o644)).To(Succeed()) + _ = runCLI(tempDir, "init") + }) + + It("should not overwrite the existing pattern-metadata.yaml", func() { + actual, err := os.ReadFile(filepath.Join(tempDir, "pattern-metadata.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(actual)).To(Equal(existingContent)) + }) + }) +}) + var _ = Describe("patternizer init namespace migration", func() { Context("on a directory with list-style namespaces", Ordered, func() { var tempDir string diff --git a/src/internal/embedded/resources/pattern-metadata.yaml.tmpl b/src/internal/embedded/resources/pattern-metadata.yaml.tmpl new file mode 100644 index 0000000..7961855 --- /dev/null +++ b/src/internal/embedded/resources/pattern-metadata.yaml.tmpl @@ -0,0 +1,45 @@ +# Schema: https://github.com/validatedpatterns/pattern-ui-catalog/blob/main/pattern.schema.json +metadata_version: "1.0" +name: {{ .Name }} +description: A short description of the pattern +pattern_version: "1.0" +display_name: {{ .DisplayName }} +repo_url: {{ .RepoURL }} +docs_repo_url: https://github.com/validatedpatterns/docs +issues_url: {{ .IssuesURL }} +docs_url: https://validatedpatterns.io/patterns/{{ .Name }}/ +ci_url: https://validatedpatterns.io/ci/?pattern={{ .Name }} +tier: sandbox +owners: + - CHANGEME +org: {{ .Org }} +# requirements: +# hub: +# compute: +# aws: +# replicas: 3 +# type: m5.2xlarge +# gcp: +# replicas: 3 +# type: n1-standard-8 +# azure: +# replicas: 3 +# type: Standard_D8s_v3 +# controlPlane: +# aws: +# replicas: 3 +# type: m5.xlarge +# gcp: +# replicas: 3 +# type: n1-standard-4 +# azure: +# replicas: 3 +# type: Standard_D4s_v3 +extra_features: + hypershift_support: false + spoke_support: false +# external_requirements: +# variants: +# - name: default +# default: true +# description: Default variant diff --git a/src/internal/pattern/metadata.go b/src/internal/pattern/metadata.go new file mode 100644 index 0000000..6b1a844 --- /dev/null +++ b/src/internal/pattern/metadata.go @@ -0,0 +1,113 @@ +package pattern + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/validatedpatterns/patternizer/internal/embedded" +) + +type metadataTemplateData struct { + Name string + DisplayName string + RepoURL string + IssuesURL string + Org string +} + +// GeneratePatternMetadata creates a pattern-metadata.yaml file in the repo root. +// It skips generation if the file already exists. +func GeneratePatternMetadata(patternName, repoRoot string) error { + metadataPath := filepath.Join(repoRoot, "pattern-metadata.yaml") + if _, err := os.Stat(metadataPath); err == nil { + return nil + } + + org, repoURL := detectGitRemote(patternName, repoRoot) + + data := metadataTemplateData{ + Name: patternName, + DisplayName: toDisplayName(patternName), + RepoURL: repoURL, + IssuesURL: repoURL + "/issues", + Org: org, + } + + tmplBytes, err := embedded.Resources.ReadFile("resources/pattern-metadata.yaml.tmpl") + if err != nil { + return fmt.Errorf("reading metadata template: %w", err) + } + + tmpl, err := template.New("metadata").Parse(string(tmplBytes)) + if err != nil { + return fmt.Errorf("parsing metadata template: %w", err) + } + + f, err := os.Create(metadataPath) + if err != nil { + return fmt.Errorf("creating %s: %w", metadataPath, err) + } + defer f.Close() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("executing metadata template: %w", err) + } + + return os.Chmod(metadataPath, 0o644) +} + +func detectGitRemote(patternName, repoRoot string) (org, repoURL string) { + fallbackOrg := "CHANGEME" + fallbackURL := "https://github.com/" + fallbackOrg + "/" + patternName + + out, err := exec.Command("git", "-C", repoRoot, "remote", "get-url", "origin").Output() + if err != nil { + return fallbackOrg, fallbackURL + } + + raw := strings.TrimSpace(string(out)) + if raw == "" { + return fallbackOrg, fallbackURL + } + + return parseGitRemoteURL(raw, fallbackOrg, fallbackURL) +} + +func parseGitRemoteURL(raw, fallbackOrg, fallbackURL string) (org, repoURL string) { + if strings.HasPrefix(raw, "git@") { + raw = strings.TrimPrefix(raw, "git@") + raw = strings.Replace(raw, ":", "/", 1) + raw = "https://" + raw + } + + raw = strings.TrimSuffix(raw, ".git") + + parsed, err := url.Parse(raw) + if err != nil { + return fallbackOrg, fallbackURL + } + + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 2 { + return fallbackOrg, fallbackURL + } + + org = parts[0] + httpsURL := fmt.Sprintf("https://%s/%s/%s", parsed.Host, parts[0], parts[1]) + return org, httpsURL +} + +func toDisplayName(name string) string { + words := strings.Split(name, "-") + for i, w := range words { + if w != "" { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(words, " ") +} diff --git a/src/internal/pattern/metadata_test.go b/src/internal/pattern/metadata_test.go new file mode 100644 index 0000000..6772472 --- /dev/null +++ b/src/internal/pattern/metadata_test.go @@ -0,0 +1,134 @@ +package pattern + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GeneratePatternMetadata", func() { + Context("on a directory without a git remote", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + It("should create pattern-metadata.yaml with fallback values", func() { + Expect(GeneratePatternMetadata("my-pattern", tempDir)).To(Succeed()) + + metadataPath := filepath.Join(tempDir, "pattern-metadata.yaml") + Expect(metadataPath).To(BeAnExistingFile()) + + content, err := os.ReadFile(metadataPath) + Expect(err).NotTo(HaveOccurred()) + + s := string(content) + Expect(s).To(ContainSubstring("name: my-pattern")) + Expect(s).To(ContainSubstring("display_name: My Pattern")) + Expect(s).To(ContainSubstring("org: CHANGEME")) + Expect(s).To(ContainSubstring("repo_url: https://github.com/CHANGEME/my-pattern")) + Expect(s).To(ContainSubstring("issues_url: https://github.com/CHANGEME/my-pattern/issues")) + Expect(s).To(ContainSubstring("tier: sandbox")) + Expect(s).To(ContainSubstring("# requirements:")) + Expect(s).To(ContainSubstring("extra_features:")) + }) + }) + + Context("on a directory with a git remote", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + cmd := exec.Command("git", "init", tempDir) + Expect(cmd.Run()).To(Succeed()) + cmd = exec.Command("git", "-C", tempDir, "remote", "add", "origin", "https://github.com/testorg/my-pattern.git") + Expect(cmd.Run()).To(Succeed()) + }) + + It("should detect org and repo URL from git remote", func() { + Expect(GeneratePatternMetadata("my-pattern", tempDir)).To(Succeed()) + + content, err := os.ReadFile(filepath.Join(tempDir, "pattern-metadata.yaml")) + Expect(err).NotTo(HaveOccurred()) + + s := string(content) + Expect(s).To(ContainSubstring("org: testorg")) + Expect(s).To(ContainSubstring("repo_url: https://github.com/testorg/my-pattern")) + Expect(s).To(ContainSubstring("issues_url: https://github.com/testorg/my-pattern/issues")) + }) + }) + + Context("when pattern-metadata.yaml already exists", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tempDir, "pattern-metadata.yaml"), []byte("existing content"), 0o644)).To(Succeed()) + }) + + It("should not overwrite the existing file", func() { + Expect(GeneratePatternMetadata("my-pattern", tempDir)).To(Succeed()) + + content, err := os.ReadFile(filepath.Join(tempDir, "pattern-metadata.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal("existing content")) + }) + }) +}) + +var _ = Describe("parseGitRemoteURL", func() { + DescribeTable("should parse various remote URL formats", + func(raw, expectedOrg, expectedURL string) { + org, repoURL := parseGitRemoteURL(raw, "fallback-org", "https://fallback.example.com") + Expect(org).To(Equal(expectedOrg)) + Expect(repoURL).To(Equal(expectedURL)) + }, + Entry("HTTPS URL", "https://github.com/validatedpatterns/multicloud-gitops.git", + "validatedpatterns", "https://github.com/validatedpatterns/multicloud-gitops"), + Entry("HTTPS URL without .git", "https://github.com/validatedpatterns/multicloud-gitops", + "validatedpatterns", "https://github.com/validatedpatterns/multicloud-gitops"), + Entry("SSH URL", "git@github.com:validatedpatterns/multicloud-gitops.git", + "validatedpatterns", "https://github.com/validatedpatterns/multicloud-gitops"), + ) + + It("should fall back on invalid URLs", func() { + org, repoURL := parseGitRemoteURL("not-a-url", "fallback-org", "https://fallback.example.com") + Expect(org).To(Equal("fallback-org")) + Expect(repoURL).To(Equal("https://fallback.example.com")) + }) +}) + +var _ = Describe("toDisplayName", func() { + DescribeTable("should convert kebab-case to title case", + func(input, expected string) { + Expect(toDisplayName(input)).To(Equal(expected)) + }, + Entry("single word", "pattern", "Pattern"), + Entry("two words", "multicloud-gitops", "Multicloud Gitops"), + Entry("three words", "my-cool-pattern", "My Cool Pattern"), + ) +}) + +var _ = Describe("detectGitRemote", func() { + It("should return fallback values for a non-git directory", func() { + tempDir := GinkgoT().TempDir() + org, repoURL := detectGitRemote("test-pattern", tempDir) + Expect(org).To(Equal("CHANGEME")) + Expect(repoURL).To(Equal("https://github.com/CHANGEME/test-pattern")) + }) + + It("should detect remote from a git repo", func() { + tempDir := GinkgoT().TempDir() + Expect(exec.Command("git", "init", tempDir).Run()).To(Succeed()) + Expect(exec.Command("git", "-C", tempDir, "remote", "add", "origin", "git@github.com:myorg/myrepo.git").Run()).To(Succeed()) + + org, repoURL := detectGitRemote("myrepo", tempDir) + Expect(org).To(Equal("myorg")) + Expect(strings.HasPrefix(repoURL, "https://github.com/myorg/myrepo")).To(BeTrue()) + }) +})