diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 60b5c7ab853..f99cd65bbf3 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -334,12 +334,6 @@ overrides: - filename: pkg/extensions/manager.go words: - myext - - filename: extensions/microsoft.azd.extensions/internal/resources/languages/**/.gitignore - words: - - rsuser - - userosscache - - docstates - - dylib - filename: docs/recording-functional-tests-guide.md words: - httptest diff --git a/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md b/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md index 77b92b76f0d..de3394223dd 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md +++ b/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md @@ -1,9 +1,10 @@ # Release History -## 0.11.2 (2026-06-05) +## 0.12.0 (Unreleased) - [[#8552]](https://github.com/Azure/azure-dev/pull/8552) Embed language template dotfiles so generated extensions include a `.gitignore` (the Go template excludes `bin/`). - [[#8552]](https://github.com/Azure/azure-dev/pull/8552) Warn during `azd x build` when the local extension source registry is missing or does not contain the extension, since the binaries are installed but the extension would not appear in `azd extension list`. +- [[#8570]](https://github.com/Azure/azure-dev/pull/8570) Add `--internal` to `azd x init` to scaffold first-party Go extensions in the `Azure/azure-dev` repository, including CI workflows, release pipeline, and a suitable `.github/CODEOWNERS` entry. ## 0.11.1 (2026-06-03) diff --git a/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml b/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml index bf8aa741d07..e3bb7fc3344 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml +++ b/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml @@ -1,5 +1,12 @@ import: ../../.vscode/cspell.yaml overrides: + - filename: CHANGELOG.md + words: + - codeowners + - filename: internal/cmd/init.go + words: + - codeowner + - codeowners - filename: internal/resources/languages/**/.gitignore words: - rsuser diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go index a2d53f1cddb..ed8a31b7755 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -35,14 +35,16 @@ import ( ) type initFlags struct { - createRegistry bool - noPrompt bool - id string - name string - capabilities []string - language string - namespace string - tags []string + createRegistry bool + noPrompt bool + internalScaffold bool + id string + name string + capabilities []string + language string + namespace string + tags []string + codeowners []string } // extensionSchemaHeader is prepended to generated extension.yaml files so editor @@ -53,8 +55,13 @@ const extensionSchemaHeader = "# yaml-language-server: $schema=" + const ( maxExtensionTags = 10 maxExtensionTagLength = 64 + + // defaultExtensionVersion is the initial version for newly scaffolded extensions. + defaultExtensionVersion = "0.0.1" ) +const internalScaffoldResourceBase = "internal/go" + var extensionNamespacePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$`) func newInitCommand(noPrompt *bool) *cobra.Command { @@ -76,6 +83,10 @@ func newInitCommand(noPrompt *bool) *cobra.Command { flags.noPrompt = *noPrompt } + if flags.internalScaffold && flags.createRegistry { + return fmt.Errorf("--internal cannot be used with --registry") + } + // Validate required parameters when in headless mode if flags.noPrompt { var missingParams []string @@ -88,9 +99,12 @@ func newInitCommand(noPrompt *bool) *cobra.Command { if len(flags.capabilities) == 0 { missingParams = append(missingParams, "--capabilities") } - if flags.language == "" { + if flags.language == "" && !flags.internalScaffold { missingParams = append(missingParams, "--language") } + if flags.internalScaffold && len(flags.codeowners) == 0 { + missingParams = append(missingParams, "--codeowners") + } if len(missingParams) > 0 { return fmt.Errorf( @@ -116,6 +130,12 @@ func newInitCommand(noPrompt *bool) *cobra.Command { "When set will create a local extension source registry.", ) + initCmd.Flags().BoolVar( + &flags.internalScaffold, + "internal", false, + "Scaffold Azure/azure-dev first-party extension files. Currently supports Go extensions only.", + ) + initCmd.Flags().StringVar( &flags.id, "id", "", @@ -159,6 +179,12 @@ func newInitCommand(noPrompt *bool) *cobra.Command { ), ) + initCmd.Flags().StringSliceVar( + &flags.codeowners, + "codeowners", []string{}, + "GitHub handles or teams for the generated CODEOWNERS entry when --internal is set.", + ) + return initCmd } @@ -182,6 +208,7 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } var extensionMetadata *models.ExtensionSchema + var codeowners []string cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current working directory: %w", err) @@ -195,11 +222,18 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } } else if !flags.createRegistry { // Interactive mode - collect metadata through prompts - extensionMetadata, err = collectExtensionMetadata(ctx, azdClient) + extensionMetadata, err = collectExtensionMetadata(ctx, azdClient, flags.internalScaffold) if err != nil { return fmt.Errorf("failed to collect extension metadata: %w", err) } + if flags.internalScaffold { + codeowners, err = promptInternalCodeowners(ctx, azdClient) + if err != nil { + return fmt.Errorf("failed to prompt for CODEOWNERS: %w", err) + } + } + fmt.Println() confirmResponse, err := azdClient. Prompt(). @@ -220,6 +254,32 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } } + var repoRoot string + if flags.internalScaffold { + // Interactive mode collects validated codeowners via prompt; headless mode + // parses them from the --codeowners flag here. + if codeowners == nil { + codeowners, err = parseCodeowners(flags.codeowners) + if err != nil { + return err + } + } + + if extensionMetadata.Language != "go" { + return fmt.Errorf("--internal currently supports Go extensions only") + } + if err := validateInternalExtensionId(extensionMetadata.Id); err != nil { + return err + } + + repoRoot, err = findAzureDevRepoRoot(cwd) + if err != nil { + return err + } + cwd = filepath.Join(repoRoot, "cli", "azd", "extensions") + extensionMetadata.Path = filepath.Join(cwd, extensionMetadata.Id) + } + extensionPath := filepath.Join(cwd, extensionMetadata.Id) if info, err := os.Stat(extensionPath); err == nil { if !info.IsDir() { @@ -290,6 +350,15 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { ) } + if flags.internalScaffold { + if err := createInternalExtensionScaffold(extensionMetadata, repoRoot, codeowners); err != nil { + return ux.Error, common.NewDetailedError( + "Error creating internal scaffold", + fmt.Errorf("failed to create internal extension files: %w", err), + ) + } + } + return ux.Success, nil } @@ -426,6 +495,11 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { // collectExtensionMetadataFromFlags creates extension metadata from command-line flags func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchema, error) { + language := flags.language + if flags.internalScaffold && language == "" { + language = "go" + } + // Validate that the language is supported validLanguages := map[string]bool{ "go": true, @@ -434,10 +508,19 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem "python": true, } - if !validLanguages[flags.language] { + if flags.internalScaffold && language != "go" { + return nil, fmt.Errorf("--internal currently supports Go extensions only") + } + if flags.internalScaffold { + if err := validateInternalExtensionId(flags.id); err != nil { + return nil, err + } + } + + if !validLanguages[language] { return nil, fmt.Errorf( "invalid language '%s', supported languages are: go, dotnet, javascript, python", - flags.language, + language, ) } @@ -488,15 +571,19 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem Description: description, Namespace: namespace, Capabilities: capabilities, - Language: flags.language, + Language: language, Tags: tags, Usage: formatUsage(namespace), - Version: "0.0.1", + Version: defaultExtensionVersion, Path: absExtensionPath, }, nil } -func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) (*models.ExtensionSchema, error) { +func collectExtensionMetadata( + ctx context.Context, + azdClient *azdext.AzdClient, + internalScaffold bool, +) (*models.ExtensionSchema, error) { fmt.Println() fmt.Println("Please provide the following information to create your extension.") fmt.Printf("Values can be changed later in the %s file.\n", output.WithHighLightFormat("extension.yaml")) @@ -515,6 +602,11 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) if err != nil { return nil, fmt.Errorf("failed to prompt for extension ID: %w", err) } + if internalScaffold { + if err := validateInternalExtensionId(idPrompt.Value); err != nil { + return nil, err + } + } displayNamePrompt, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -575,37 +667,45 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) return nil, fmt.Errorf("failed to prompt for capabilities: %w", err) } - languageChoices := []*azdext.SelectChoice{ - { - Label: "Go", - Value: "go", - }, - { - Label: "C#", - Value: "dotnet", - }, - { - Label: "JavaScript", - Value: "javascript", - }, - { - Label: "Python", - Value: "python", - }, - } + language := "go" + if internalScaffold { + fmt.Println( + "Defaulting to Go - language selection skipped as internal scaffolding only supports Go currently.", + ) + } else { + languageChoices := []*azdext.SelectChoice{ + { + Label: "Go", + Value: "go", + }, + { + Label: "C#", + Value: "dotnet", + }, + { + Label: "JavaScript", + Value: "javascript", + }, + { + Label: "Python", + Value: "python", + }, + } - programmingLanguagePrompt, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a programming language for your extension", - Choices: languageChoices, - EnableFiltering: new(false), - DisplayNumbers: new(false), - HelpMessage: "Programming language is used to define the language in which your extension is written. " + - "You can select one programming language.", - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for programming language: %w", err) + programmingLanguagePrompt, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a programming language for your extension", + Choices: languageChoices, + EnableFiltering: new(false), + DisplayNumbers: new(false), + HelpMessage: "Programming language is used to define the language in which your extension is written. " + + "You can select one programming language.", + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for programming language: %w", err) + } + language = languageChoices[*programmingLanguagePrompt.Value].Value } capabilities := make([]extensions.CapabilityType, len(capabilitiesPrompt.Values)) @@ -629,10 +729,10 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) Description: descriptionPrompt.Value, Namespace: namespace, Capabilities: capabilities, - Language: languageChoices[*programmingLanguagePrompt.Value].Value, + Language: language, Tags: tags, Usage: formatUsage(namespace), - Version: "0.0.1", + Version: defaultExtensionVersion, Path: absExtensionPath, }, nil } @@ -722,6 +822,59 @@ func parseTags(rawTags string) ([]string, error) { return tags, nil } +func promptInternalCodeowners(ctx context.Context, azdClient *azdext.AzdClient) ([]string, error) { + for { + codeownersPrompt, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter CODEOWNERS for this extension (comma-separated)", + Placeholder: "@github-handle, @Azure/team", + RequiredMessage: "At least one CODEOWNER is required for --internal", + Required: true, + HelpMessage: "These GitHub users or teams will be added to .github/CODEOWNERS " + + "for the generated first-party extension directory.", + }, + }) + if err != nil { + return nil, err + } + + codeowners, err := parseCodeowners([]string{codeownersPrompt.Value}) + if err != nil { + fmt.Println(output.WithErrorFormat(err.Error())) + continue + } + + return codeowners, nil + } +} + +func parseCodeowners(values []string) ([]string, error) { + var codeowners []string + for _, value := range values { + for _, owner := range strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || unicode.IsSpace(r) + }) { + owner = strings.TrimSpace(owner) + if owner == "" { + continue + } + if !strings.HasPrefix(owner, "@") { + return nil, fmt.Errorf("invalid CODEOWNER '%s': values must be GitHub users or teams starting with @", owner) + } + if strings.ContainsFunc(owner, unicode.IsControl) { + return nil, fmt.Errorf("invalid CODEOWNER '%s': values must not contain control characters", owner) + } + codeowners = append(codeowners, owner) + } + } + + if len(codeowners) == 0 { + return nil, errors.New("at least one CODEOWNER is required when using --internal") + } + + return codeowners, nil +} + func capabilityPromptChoices() []*azdext.MultiSelectChoice { choices := make([]*azdext.MultiSelectChoice, len(extensions.ValidCapabilities)) for i, cap := range extensions.ValidCapabilities { @@ -834,6 +987,230 @@ func createExtensionDirectory( return nil } +func createInternalExtensionScaffold( + extensionMetadata *models.ExtensionSchema, + repoRoot string, + codeowners []string, +) error { + if err := validateInternalExtensionId(extensionMetadata.Id); err != nil { + return err + } + + sanitizedId := strings.ReplaceAll(extensionMetadata.Id, ".", "-") + templateData := &ExtensionTemplate{ + Metadata: extensionMetadata, + SanitizedId: sanitizedId, + LeafNamespace: path.Base(strings.ReplaceAll(extensionMetadata.Namespace, ".", "/")), + DotNet: &DotNetTemplate{ + Namespace: internal.ToPascalCase(extensionMetadata.Id), + ExeName: extensionMetadata.SafeDashId(), + }, + } + + files := map[string]string{ + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "ci-build.ps1"): path.Join( + internalScaffoldResourceBase, + "extension", + "ci-build.ps1.tmpl", + ), + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "ci-test.ps1"): path.Join( + internalScaffoldResourceBase, + "extension", + "ci-test.ps1", + ), + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "version.txt"): path.Join( + internalScaffoldResourceBase, + "extension", + "version.txt.tmpl", + ), + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "cspell.yaml"): path.Join( + internalScaffoldResourceBase, + "extension", + "cspell.yaml", + ), + // Stored without the leading dot because go:embed excludes dotfiles. + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, ".golangci.yaml"): path.Join( + internalScaffoldResourceBase, + "extension", + "golangci.yaml", + ), + filepath.Join(".github", "workflows", fmt.Sprintf("lint-ext-%s.yml", sanitizedId)): path.Join( + internalScaffoldResourceBase, + "workflows", + "lint-ext.yml.tmpl", + ), + filepath.Join("eng", "pipelines", fmt.Sprintf("release-ext-%s.yml", sanitizedId)): path.Join( + internalScaffoldResourceBase, + "pipelines", + "release-ext.yml.tmpl", + ), + } + + for relPath, templatePath := range files { + outputPath := filepath.Join(repoRoot, relPath) + if err := executeTemplateFileToFile(resources.Internal, templatePath, outputPath, templateData); err != nil { + return err + } + } + + return addCodeownersEntry( + filepath.Join(repoRoot, ".github", "CODEOWNERS"), + fmt.Sprintf("/cli/azd/extensions/%s/", extensionMetadata.Id), + codeowners, + ) +} + +func executeTemplateFileToFile(srcFS fs.FS, templatePath, filePath string, data any) error { + templateBytes, err := fs.ReadFile(srcFS, templatePath) + if err != nil { + return fmt.Errorf("failed to read template %s: %w", templatePath, err) + } + + return executeTemplateToFile(filePath, string(templateBytes), data) +} + +func executeTemplateToFile(filePath, tmplText string, data any) error { + tmpl, err := template.New(filepath.Base(filePath)).Funcs(templateFuncs).Parse(tmplText) + if err != nil { + return fmt.Errorf("failed to parse template for %s: %w", filePath, err) + } + + var processed bytes.Buffer + if err := tmpl.Execute(&processed, data); err != nil { + return fmt.Errorf("failed to execute template for %s: %w", filePath, err) + } + + if err := os.MkdirAll(filepath.Dir(filePath), internal.PermissionDirectory); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", filePath, err) + } + if err := os.WriteFile(filePath, processed.Bytes(), internal.PermissionFile); err != nil { + return fmt.Errorf("failed to write file %s: %w", filePath, err) + } + + return nil +} + +// codeownersExtensionRulePrefix identifies existing extension rules in +// .github/CODEOWNERS used to anchor where new entries are inserted. +const codeownersExtensionRulePrefix = "/cli/azd/extensions/" + +// addCodeownersEntry inserts a CODEOWNERS rule for extensionPath. Because +// CODEOWNERS is last-match-wins, ordering matters: the entry is inserted +// alphabetically within the existing block of /cli/azd/extensions/ rules when +// one exists, and appended to the end of the file otherwise. +func addCodeownersEntry(filePath, extensionPath string, codeowners []string) error { + entry := extensionPath + " " + strings.Join(codeowners, " ") + + contents, err := os.ReadFile(filePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read CODEOWNERS: %w", err) + } + + lines := strings.Split(string(contents), "\n") + + // Skip if a rule for this path already exists. Match on the first field of + // each line so comments mentioning the path don't suppress the new entry. + for _, line := range lines { + if fields := strings.Fields(line); len(fields) > 0 && fields[0] == extensionPath { + return nil + } + } + + insertAt := codeownersInsertIndex(lines, extensionPath) + + var updated []string + if insertAt >= 0 { + updated = slices.Insert(slices.Clone(lines), insertAt, entry) + } else { + // No existing extension rules; append at the end of the file. + updated = slices.Clone(lines) + for len(updated) > 0 && updated[len(updated)-1] == "" { + updated = updated[:len(updated)-1] + } + if len(updated) > 0 { + updated = append(updated, "") + } + updated = append(updated, entry) + } + + output := strings.Join(updated, "\n") + if !strings.HasSuffix(output, "\n") { + output += "\n" + } + + if err := os.MkdirAll(filepath.Dir(filePath), internal.PermissionDirectory); err != nil { + return fmt.Errorf("failed to create CODEOWNERS directory: %w", err) + } + //nolint:gosec // G703: path is repo-root + .github/CODEOWNERS; extension id is validated + if err := os.WriteFile(filePath, []byte(output), internal.PermissionFile); err != nil { + return fmt.Errorf("failed to update CODEOWNERS: %w", err) + } + + return nil +} + +// codeownersInsertIndex returns the line index at which a rule for +// extensionPath should be inserted to keep the /cli/azd/extensions/ block +// alphabetically sorted, or -1 when no extension rules exist. +func codeownersInsertIndex(lines []string, extensionPath string) int { + insertAt := -1 + for i, line := range lines { + fields := strings.Fields(line) + if len(fields) == 0 || !strings.HasPrefix(fields[0], codeownersExtensionRulePrefix) { + continue + } + + if fields[0] > extensionPath { + return i + } + // Insert after the last rule that sorts before the new path. + insertAt = i + 1 + } + + return insertAt +} + +func validateInternalExtensionId(id string) error { + if !extensionNamespacePattern.MatchString(id) { + return fmt.Errorf( + "invalid extension id '%s' for --internal: use lowercase letters, numbers, and hyphens "+ + "separated by single dots (for example, 'azure.ai.example')", + id, + ) + } + + return nil +} + +func findAzureDevRepoRoot(startDir string) (string, error) { + dir, err := filepath.Abs(startDir) + if err != nil { + return "", fmt.Errorf("failed to resolve current directory: %w", err) + } + + for { + if pathExists(filepath.Join(dir, ".git")) && + pathExists(filepath.Join(dir, "cli", "azd", "extensions")) && + pathExists(filepath.Join(dir, ".github")) && + pathExists(filepath.Join(dir, "eng", "pipelines")) { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", errors.New("--internal must be run from inside the Azure/azure-dev repository") +} + +func pathExists(filePath string) bool { + _, err := os.Stat(filePath) + return err == nil +} + func copyAndProcessTemplates(srcFS fs.FS, srcDir, destDir string, data any) error { return fs.WalkDir(srcFS, srcDir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -939,6 +1316,8 @@ func subprocessErrorTail(output []byte) string { // ExtensionTemplate contains values used when rendering extension project templates. type ExtensionTemplate struct { Metadata *models.ExtensionSchema + // SanitizedId is the extension ID with dots replaced by dashes for CI file names. + SanitizedId string // LeafNamespace is the final dot-separated segment of Metadata.Namespace, used as the // cobra Use/Name for the extension's root command. For nested namespaces like // "ai.agents", users invoke the extension via "azd ai agents" (azd splits on '.'), diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go index 502f6eff76f..b095e41ad91 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "testing" "text/template" @@ -381,6 +382,43 @@ func TestCollectExtensionMetadataFromFlagsTags(t *testing.T) { assert.Equal(t, []string{"alpha", "beta", "gamma"}, metadata.Tags) } +func TestCollectExtensionMetadataFromFlagsInternalDefaultsToGo(t *testing.T) { + metadata, err := collectExtensionMetadataFromFlags(&initFlags{ + internalScaffold: true, + id: "test.extension", + name: "Test Extension", + capabilities: []string{string(extensions.CustomCommandCapability)}, + }) + + require.NoError(t, err) + assert.Equal(t, "go", metadata.Language) + assert.Equal(t, "0.0.1", metadata.Version) +} + +func TestCollectExtensionMetadataFromFlagsInternalRejectsNonGo(t *testing.T) { + _, err := collectExtensionMetadataFromFlags(&initFlags{ + internalScaffold: true, + id: "test.extension", + name: "Test Extension", + capabilities: []string{string(extensions.CustomCommandCapability)}, + language: "python", + }) + + require.ErrorContains(t, err, "Go extensions only") +} + +func TestCollectExtensionMetadataFromFlagsInternalRejectsUnsafeId(t *testing.T) { + _, err := collectExtensionMetadataFromFlags(&initFlags{ + internalScaffold: true, + id: "../bad", + name: "Test Extension", + namespace: "bad", + capabilities: []string{string(extensions.CustomCommandCapability)}, + }) + + require.ErrorContains(t, err, "invalid extension id") +} + func TestCollectExtensionMetadataFromFlagsInvalidTags(t *testing.T) { _, err := collectExtensionMetadataFromFlags(&initFlags{ id: "test.extension", @@ -393,6 +431,183 @@ func TestCollectExtensionMetadataFromFlagsInvalidTags(t *testing.T) { require.ErrorContains(t, err, "control characters") } +func TestCreateInternalExtensionScaffold(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, "cli", "azd", "extensions", "azure.ai.example"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".github"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(repoRoot, ".github", "CODEOWNERS"), + []byte("/** @default\n"), + 0o600, + )) + + metadata := &models.ExtensionSchema{ + Id: "azure.ai.example", + Namespace: "ai.example", + Version: "0.0.1-preview", + Language: "go", + } + + err := createInternalExtensionScaffold(metadata, repoRoot, []string{"@owner", "@Azure/team"}) + require.NoError(t, err) + + sanitizedId := "azure-ai-example" + assertFileContains( + t, + filepath.Join(repoRoot, ".github", "workflows", "lint-ext-"+sanitizedId+".yml"), + "name: ext-azure-ai-example-ci", + "working-directory: cli/azd/extensions/azure.ai.example", + "${{ github.workflow }}-${{ github.event.pull_request.number }}", + ) + assertFileContains( + t, + filepath.Join(repoRoot, "eng", "pipelines", "release-ext-"+sanitizedId+".yml"), + "AzdExtensionId: azure.ai.example", + "SanitizedExtensionId: azure-ai-example", + ) + assertFileContains( + t, + filepath.Join(repoRoot, "cli", "azd", "extensions", "azure.ai.example", "ci-build.ps1"), + "azure.ai.example/internal/cmd.Version=$Version", + ) + assertFileContains( + t, + filepath.Join(repoRoot, "cli", "azd", "extensions", "azure.ai.example", "ci-test.ps1"), + "go test ./... -v -count=1", + ) + assertFileContains( + t, + filepath.Join(repoRoot, "cli", "azd", "extensions", "azure.ai.example", "version.txt"), + "0.0.1-preview", + ) + assertFileContains( + t, + filepath.Join(repoRoot, "cli", "azd", "extensions", "azure.ai.example", "cspell.yaml"), + "import: ../../.vscode/cspell.yaml", + ) + assertFileContains( + t, + filepath.Join(repoRoot, "cli", "azd", "extensions", "azure.ai.example", ".golangci.yaml"), + "version: \"2\"", + "line-length: 220", + ) + assertFileContains( + t, + filepath.Join(repoRoot, ".github", "CODEOWNERS"), + "/cli/azd/extensions/azure.ai.example/", + "@owner @Azure/team", + ) +} + +func TestCreateInternalExtensionScaffoldRejectsUnsafeId(t *testing.T) { + t.Parallel() + + err := createInternalExtensionScaffold( + &models.ExtensionSchema{Id: "../bad", Namespace: "bad", Version: "0.0.1-preview"}, + t.TempDir(), + []string{"@owner"}, + ) + + require.ErrorContains(t, err, "invalid extension id") +} + +func TestFindAzureDevRepoRoot(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".github"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, "eng", "pipelines"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, "cli", "azd", "extensions"), 0o755)) + + tests := []struct { + name string + startDir string + wantErr string + }{ + {name: "repo root", startDir: repoRoot}, + {name: "nested directory", startDir: filepath.Join(repoRoot, "cli", "azd", "extensions")}, + {name: "outside repo", startDir: t.TempDir(), wantErr: "must be run from inside the Azure/azure-dev repository"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + root, err := findAzureDevRepoRoot(tt.startDir) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, repoRoot, root) + }) + } +} + +func TestAddCodeownersEntry(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + codeownersPath := filepath.Join(dir, "CODEOWNERS") + require.NoError(t, os.WriteFile(codeownersPath, []byte( + "# see /cli/azd/extensions/azure.ai.example/ for details\n"+ + "/** @default\n"+ + "\n"+ + "/cli/azd/extensions/azure.ai.agents/ @agents\n"+ + "/cli/azd/extensions/azure.ai.projects/ @projects\n"+ + "\n"+ + "# This file (most specific -- must be last)\n"+ + "/.github/CODEOWNERS @admins\n", + ), 0o600)) + + entryPath := "/cli/azd/extensions/azure.ai.example/" + + // A comment mentioning the path must not suppress the new entry. + require.NoError(t, addCodeownersEntry(codeownersPath, entryPath, []string{"@owner"})) + + // The entry is inserted alphabetically within the extensions block, not + // appended after later rules (CODEOWNERS is last-match-wins). + contents, err := os.ReadFile(codeownersPath) + require.NoError(t, err) + lines := strings.Split(string(contents), "\n") + agentsIdx := slices.IndexFunc(lines, func(l string) bool { + return strings.HasPrefix(l, "/cli/azd/extensions/azure.ai.agents/") + }) + exampleIdx := slices.IndexFunc(lines, func(l string) bool { + return strings.HasPrefix(l, entryPath+" ") + }) + projectsIdx := slices.IndexFunc(lines, func(l string) bool { + return strings.HasPrefix(l, "/cli/azd/extensions/azure.ai.projects/") + }) + require.GreaterOrEqual(t, exampleIdx, 0) + assert.Less(t, agentsIdx, exampleIdx) + assert.Less(t, exampleIdx, projectsIdx) + assert.Contains(t, lines[exampleIdx], "@owner") + assert.Equal(t, "/.github/CODEOWNERS @admins", lines[len(lines)-2]) + + // Calling again must be idempotent. + require.NoError(t, addCodeownersEntry(codeownersPath, entryPath, []string{"@owner"})) + after, err := os.ReadFile(codeownersPath) + require.NoError(t, err) + assert.Equal(t, string(contents), string(after)) +} + +func TestAddCodeownersEntryNoExtensionsBlock(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + codeownersPath := filepath.Join(dir, "CODEOWNERS") + require.NoError(t, os.WriteFile(codeownersPath, []byte("/** @default\n"), 0o600)) + + entryPath := "/cli/azd/extensions/azure.ai.example/" + require.NoError(t, addCodeownersEntry(codeownersPath, entryPath, []string{"@owner"})) + assertFileContains(t, codeownersPath, "/** @default", entryPath+" @owner") +} + func TestValidateExtensionNamespace(t *testing.T) { t.Parallel() @@ -452,6 +667,18 @@ func TestParseTags(t *testing.T) { require.ErrorContains(t, err, "control characters") } +func TestParseCodeowners(t *testing.T) { + codeowners, err := parseCodeowners([]string{"@owner, @Azure/team", "@second"}) + require.NoError(t, err) + assert.Equal(t, []string{"@owner", "@Azure/team", "@second"}, codeowners) + + _, err = parseCodeowners(nil) + require.ErrorContains(t, err, "at least one CODEOWNER") + + _, err = parseCodeowners([]string{"owner"}) + require.ErrorContains(t, err, "starting with @") +} + func TestWriteCollectedWarnings(t *testing.T) { var buf bytes.Buffer writeCollectedWarnings(&buf, []string{"first warning", "second warning"}) @@ -586,6 +813,16 @@ func TestTemplateGoStringQuotesDescription(t *testing.T) { } } +func assertFileContains(t *testing.T, filePath string, expected ...string) { + t.Helper() + + contents, err := os.ReadFile(filePath) + require.NoError(t, err) + for _, value := range expected { + assert.Contains(t, string(contents), value) + } +} + func slicesContainSubstring(values []string, substring string) bool { for _, value := range values { if strings.Contains(value, substring) { diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-build.ps1.tmpl b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-build.ps1.tmpl new file mode 100644 index 00000000000..47a52c62ece --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-build.ps1.tmpl @@ -0,0 +1,80 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) + +$PSNativeCommandArgumentPassing = 'Legacy' + +go clean +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +$buildFlags = @( + "-trimpath", + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +$buildFlags += @( + "-tags=cfi,cfg,osusergo", + "-ldflags=-s -w -X {{.Metadata.Id}}/internal/cmd.Version=$Version -X {{.Metadata.Id}}/internal/cmd.Commit=$SourceVersion -X {{.Metadata.Id}}/internal/cmd.BuildDate=$(Get-Date -Format o) ", + "-o=$OutputFileName" +) + +function PrintFlags() { + foreach ($buildFlag in $buildFlags) { + Write-Host " $buildFlag" + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build" + PrintFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + if ($BuildRecordMode) { + # Modify build tags to include record + $recordTagPatched = $false + for ($i = 0; $i -lt $buildFlags.Length; $i++) { + if ($buildFlags[$i].StartsWith("-tags=")) { + $buildFlags[$i] += ",record" + $recordTagPatched = $true + } + } + if (-not $recordTagPatched) { + $buildFlags += "-tags=record" + } + $recordOutput = "-o=$OutputFileName-record" + if ($IsWindows) { $recordOutput += ".exe" } + $buildFlags += $recordOutput + + Write-Host "Running: go build (record)" + PrintFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build (record)" + exit $LASTEXITCODE + } + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-test.ps1 b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-test.ps1 new file mode 100644 index 00000000000..347e1b6e107 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-test.ps1 @@ -0,0 +1,25 @@ +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +if ($IsWindows) { + $gotestsumBinary += ".exe" +} +$gotestsum = Join-Path $gopath "bin" $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + & $gotestsum --format testname -- ./... -count=1 +} else { + Write-Host "gotestsum not found, using go test..." -ForegroundColor Yellow + go test ./... -v -count=1 +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Tests failed with exit code: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/cspell.yaml b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/cspell.yaml new file mode 100644 index 00000000000..258d305a22d --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/cspell.yaml @@ -0,0 +1,2 @@ +import: ../../.vscode/cspell.yaml +words: [] diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/golangci.yaml b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/version.txt.tmpl b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/version.txt.tmpl new file mode 100644 index 00000000000..1345cfe1729 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/version.txt.tmpl @@ -0,0 +1 @@ +{{.Metadata.Version}} diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/pipelines/release-ext.yml.tmpl b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/pipelines/release-ext.yml.tmpl new file mode 100644 index 00000000000..ba0c0fd66fd --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/pipelines/release-ext.yml.tmpl @@ -0,0 +1,40 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - go.mod + - cli/azd/extensions/{{.Metadata.Id}} + - eng/pipelines/release-azd-extension.yml + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/{{.Metadata.Id}} + - eng/pipelines/release-ext-{{.SanitizedId}}.yml + - eng/pipelines/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +parameters: + - name: PublishToDevRegistry + displayName: Publish to dev registry + type: boolean + default: false + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: {{.Metadata.Id}} + SanitizedExtensionId: {{.SanitizedId}} + AzdExtensionDirectory: cli/azd/extensions/{{.Metadata.Id}} + PublishToDevRegistry: ${{"{{"}} parameters.PublishToDevRegistry {{"}}"}} diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/workflows/lint-ext.yml.tmpl b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/workflows/lint-ext.yml.tmpl new file mode 100644 index 00000000000..d350247133f --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/workflows/lint-ext.yml.tmpl @@ -0,0 +1,22 @@ +name: ext-{{.SanitizedId}}-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/{{.Metadata.Id}}/**" + - ".github/workflows/lint-ext-{{.SanitizedId}}.yml" + branches: [main] + +concurrency: + group: ${{"{{"}} github.workflow {{"}}"}}-${{"{{"}} github.event.pull_request.number {{"}}"}} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + lint: + uses: ./.github/workflows/lint-go.yml + with: + working-directory: cli/azd/extensions/{{.Metadata.Id}} diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/resources.go b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/resources.go index 3f54984f1e8..c9d957810d4 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/resources.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/resources.go @@ -12,3 +12,6 @@ import ( // //go:embed all:languages var Languages embed.FS + +//go:embed internal +var Internal embed.FS diff --git a/cli/azd/extensions/microsoft.azd.extensions/version.txt b/cli/azd/extensions/microsoft.azd.extensions/version.txt index a8839f70de0..d33c3a2128b 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/version.txt +++ b/cli/azd/extensions/microsoft.azd.extensions/version.txt @@ -1 +1 @@ -0.11.2 \ No newline at end of file +0.12.0 \ No newline at end of file