From b5a0d1464f612ec87be57d97aef19dbd8693fde3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:29:10 +0000 Subject: [PATCH 1/9] Initial plan From b45f07c4925ad26acabcfaaf655c31a1d6d0a70c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:37:06 +0000 Subject: [PATCH 2/9] Add internal extension scaffold support Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- .../internal/cmd/init.go | 537 ++++++++++++++++-- .../internal/cmd/init_test.go | 123 ++++ 2 files changed, 616 insertions(+), 44 deletions(-) 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..66a7c1baea4 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 @@ -55,6 +57,178 @@ const ( maxExtensionTagLength = 64 ) +const internalCiBuildTemplate = `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) { + $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 +} +` + +const internalCiTestTemplate = `$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 +` + +const internalVersionTemplate = `{{.Metadata.Version}} +` + +const internalCspellTemplate = `import: ../../.vscode/cspell.yaml +words: [] +` + +const internalLintWorkflowTemplate = `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}} +` + +const internalReleasePipelineTemplate = `# 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 {{"}}"}} +` + var extensionNamespacePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$`) func newInitCommand(noPrompt *bool) *cobra.Command { @@ -76,6 +250,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 +266,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 +297,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.", + ) + initCmd.Flags().StringVar( &flags.id, "id", "", @@ -159,6 +346,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 } @@ -195,11 +388,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 { + flags.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 +420,28 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } } + if flags.internalScaffold { + codeowners, err := parseCodeowners(flags.codeowners) + if err != nil { + return err + } + flags.codeowners = codeowners + + 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 +512,16 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { ) } + if flags.internalScaffold { + repoRoot := filepath.Clean(filepath.Join(cwd, "..", "..", "..")) + if err := createInternalExtensionScaffold(extensionMetadata, repoRoot, flags.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 +658,10 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { // collectExtensionMetadataFromFlags creates extension metadata from command-line flags func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchema, error) { + if flags.internalScaffold && flags.language == "" { + flags.language = "go" + } + // Validate that the language is supported validLanguages := map[string]bool{ "go": true, @@ -434,6 +670,10 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem "python": true, } + if flags.internalScaffold && flags.language != "go" { + return nil, fmt.Errorf("--internal currently supports Go extensions only") + } + if !validLanguages[flags.language] { return nil, fmt.Errorf( "invalid language '%s', supported languages are: go, dotnet, javascript, python", @@ -467,6 +707,10 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem // Set a default description description := "An azd extension" + version := "0.0.1" + if flags.internalScaffold { + version = "0.0.1-preview" + } // Default namespace to ID if not provided namespace := flags.id @@ -491,12 +735,16 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem Language: flags.language, Tags: tags, Usage: formatUsage(namespace), - Version: "0.0.1", + Version: version, 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")) @@ -575,37 +823,41 @@ 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 { + 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)) @@ -618,6 +870,11 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) return nil, err } + version := "0.0.1" + if internalScaffold { + version = "0.0.1-preview" + } + absExtensionPath, err := filepath.Abs(idPrompt.Value) if err != nil { return nil, fmt.Errorf("failed to get absolute path for extension directory: %w", err) @@ -629,10 +886,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: version, Path: absExtensionPath, }, nil } @@ -722,6 +979,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 +1144,143 @@ 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"): internalCiBuildTemplate, + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "ci-test.ps1"): internalCiTestTemplate, + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "version.txt"): internalVersionTemplate, + filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "cspell.yaml"): internalCspellTemplate, + filepath.Join(".github", "workflows", fmt.Sprintf("lint-ext-%s.yml", sanitizedId)): internalLintWorkflowTemplate, + filepath.Join("eng", "pipelines", fmt.Sprintf("release-ext-%s.yml", sanitizedId)): internalReleasePipelineTemplate, + } + + for relPath, tmpl := range files { + if err := executeTemplateToFile(filepath.Join(repoRoot, relPath), tmpl, templateData); err != nil { + return err + } + } + + return addCodeownersEntry( + filepath.Join(repoRoot, ".github", "CODEOWNERS"), + fmt.Sprintf("/cli/azd/extensions/%s/", extensionMetadata.Id), + codeowners, + ) +} + +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 +} + +func addCodeownersEntry(filePath, extensionPath string, codeowners []string) error { + entry := fmt.Sprintf("%-44s %s", extensionPath, strings.Join(codeowners, " ")) + + contents, err := os.ReadFile(filePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read CODEOWNERS: %w", err) + } + + if strings.Contains(string(contents), extensionPath) { + return nil + } + + var updated bytes.Buffer + updated.Write(contents) + if len(contents) > 0 && !bytes.HasSuffix(contents, []byte("\n")) { + updated.WriteByte('\n') + } + if len(contents) > 0 { + updated.WriteByte('\n') + } + updated.WriteString(entry) + updated.WriteByte('\n') + + if err := os.MkdirAll(filepath.Dir(filePath), internal.PermissionDirectory); err != nil { + return fmt.Errorf("failed to create CODEOWNERS directory: %w", err) + } + if err := os.WriteFile(filePath, updated.Bytes(), internal.PermissionFile); err != nil { + return fmt.Errorf("failed to update CODEOWNERS: %w", err) + } + + return nil +} + +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 +1386,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..f27cdd40cf8 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 @@ -381,6 +381,31 @@ func TestCollectExtensionMetadataFromFlagsTags(t *testing.T) { assert.Equal(t, []string{"alpha", "beta", "gamma"}, metadata.Tags) } +func TestCollectExtensionMetadataFromFlagsInternalDefaultsToGoPreview(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-preview", 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 TestCollectExtensionMetadataFromFlagsInvalidTags(t *testing.T) { _, err := collectExtensionMetadataFromFlags(&initFlags{ id: "test.extension", @@ -393,6 +418,82 @@ 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, ".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 TestValidateExtensionNamespace(t *testing.T) { t.Parallel() @@ -452,6 +553,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 +699,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) { From 0e713f392e698e622d5787d732f0730b8ccec09e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:56:34 +0000 Subject: [PATCH 3/9] Extract internal scaffold templates Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- .../internal/cmd/init.go | 221 ++++-------------- .../internal/go/extension/ci-build.ps1.tmpl | 70 ++++++ .../internal/go/extension/ci-test.ps1 | 25 ++ .../internal/go/extension/cspell.yaml | 2 + .../internal/go/extension/version.txt.tmpl | 1 + .../go/pipelines/release-ext.yml.tmpl | 40 ++++ .../internal/go/workflows/lint-ext.yml.tmpl | 22 ++ .../internal/resources/resources.go | 3 + 8 files changed, 205 insertions(+), 179 deletions(-) create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-build.ps1.tmpl create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-test.ps1 create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/cspell.yaml create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/version.txt.tmpl create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/pipelines/release-ext.yml.tmpl create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/workflows/lint-ext.yml.tmpl 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 66a7c1baea4..4d3c73b5df8 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -57,177 +57,7 @@ const ( maxExtensionTagLength = 64 ) -const internalCiBuildTemplate = `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) { - $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 -} -` - -const internalCiTestTemplate = `$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 -` - -const internalVersionTemplate = `{{.Metadata.Version}} -` - -const internalCspellTemplate = `import: ../../.vscode/cspell.yaml -words: [] -` - -const internalLintWorkflowTemplate = `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}} -` - -const internalReleasePipelineTemplate = `# 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 {{"}}"}} -` +const internalScaffoldResourceBase = "internal/go" var extensionNamespacePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$`) @@ -1165,16 +995,40 @@ func createInternalExtensionScaffold( } files := map[string]string{ - filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "ci-build.ps1"): internalCiBuildTemplate, - filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "ci-test.ps1"): internalCiTestTemplate, - filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "version.txt"): internalVersionTemplate, - filepath.Join("cli", "azd", "extensions", extensionMetadata.Id, "cspell.yaml"): internalCspellTemplate, - filepath.Join(".github", "workflows", fmt.Sprintf("lint-ext-%s.yml", sanitizedId)): internalLintWorkflowTemplate, - filepath.Join("eng", "pipelines", fmt.Sprintf("release-ext-%s.yml", sanitizedId)): internalReleasePipelineTemplate, + 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", + ), + 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, tmpl := range files { - if err := executeTemplateToFile(filepath.Join(repoRoot, relPath), tmpl, templateData); err != nil { + for relPath, templatePath := range files { + if err := executeTemplateFileToFile(resources.Internal, templatePath, filepath.Join(repoRoot, relPath), templateData); err != nil { return err } } @@ -1186,6 +1040,15 @@ func createInternalExtensionScaffold( ) } +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 { 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..86b05d803a0 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/ci-build.ps1.tmpl @@ -0,0 +1,70 @@ +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) { + $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/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 From 2795dfb307428d142312487faa252cda63213de4 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 9 Jun 2026 18:49:22 +0000 Subject: [PATCH 4/9] Add default extension version and refactor internal scaffold handling - Introduced a constant for the default extension version. - Updated internal scaffold logic to use the new version constant. - Refactored codeowners handling in the internal scaffold process. - Enhanced tests to reflect changes in default version behavior. --- .../internal/cmd/init.go | 129 ++++++++++++------ .../internal/cmd/init_test.go | 71 +++++++++- .../internal/go/extension/ci-build.ps1.tmpl | 12 +- .../internal/go/extension/golangci.yaml | 17 +++ 4 files changed, 188 insertions(+), 41 deletions(-) create mode 100644 cli/azd/extensions/microsoft.azd.extensions/internal/resources/internal/go/extension/golangci.yaml 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 4d3c73b5df8..06765055116 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -55,6 +55,9 @@ 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" @@ -205,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) @@ -224,7 +228,7 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } if flags.internalScaffold { - flags.codeowners, err = promptInternalCodeowners(ctx, azdClient) + codeowners, err = promptInternalCodeowners(ctx, azdClient) if err != nil { return fmt.Errorf("failed to prompt for CODEOWNERS: %w", err) } @@ -250,12 +254,16 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } } + var repoRoot string if flags.internalScaffold { - codeowners, err := parseCodeowners(flags.codeowners) - if err != nil { - return err + // 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 + } } - flags.codeowners = codeowners if extensionMetadata.Language != "go" { return fmt.Errorf("--internal currently supports Go extensions only") @@ -264,7 +272,7 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { return err } - repoRoot, err := findAzureDevRepoRoot(cwd) + repoRoot, err = findAzureDevRepoRoot(cwd) if err != nil { return err } @@ -343,8 +351,7 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { } if flags.internalScaffold { - repoRoot := filepath.Clean(filepath.Join(cwd, "..", "..", "..")) - if err := createInternalExtensionScaffold(extensionMetadata, repoRoot, flags.codeowners); err != nil { + 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), @@ -488,8 +495,9 @@ func runInitAction(ctx context.Context, flags *initFlags) (err error) { // collectExtensionMetadataFromFlags creates extension metadata from command-line flags func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchema, error) { - if flags.internalScaffold && flags.language == "" { - flags.language = "go" + language := flags.language + if flags.internalScaffold && language == "" { + language = "go" } // Validate that the language is supported @@ -500,14 +508,14 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem "python": true, } - if flags.internalScaffold && flags.language != "go" { + if flags.internalScaffold && language != "go" { return nil, fmt.Errorf("--internal currently supports Go extensions only") } - if !validLanguages[flags.language] { + if !validLanguages[language] { return nil, fmt.Errorf( "invalid language '%s', supported languages are: go, dotnet, javascript, python", - flags.language, + language, ) } @@ -537,10 +545,6 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem // Set a default description description := "An azd extension" - version := "0.0.1" - if flags.internalScaffold { - version = "0.0.1-preview" - } // Default namespace to ID if not provided namespace := flags.id @@ -562,10 +566,10 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem Description: description, Namespace: namespace, Capabilities: capabilities, - Language: flags.language, + Language: language, Tags: tags, Usage: formatUsage(namespace), - Version: version, + Version: defaultExtensionVersion, Path: absExtensionPath, }, nil } @@ -700,11 +704,6 @@ func collectExtensionMetadata( return nil, err } - version := "0.0.1" - if internalScaffold { - version = "0.0.1-preview" - } - absExtensionPath, err := filepath.Abs(idPrompt.Value) if err != nil { return nil, fmt.Errorf("failed to get absolute path for extension directory: %w", err) @@ -719,7 +718,7 @@ func collectExtensionMetadata( Language: language, Tags: tags, Usage: formatUsage(namespace), - Version: version, + Version: defaultExtensionVersion, Path: absExtensionPath, }, nil } @@ -1015,6 +1014,12 @@ func createInternalExtensionScaffold( "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", @@ -1028,7 +1033,8 @@ func createInternalExtensionScaffold( } for relPath, templatePath := range files { - if err := executeTemplateFileToFile(resources.Internal, templatePath, filepath.Join(repoRoot, relPath), templateData); err != nil { + outputPath := filepath.Join(repoRoot, relPath) + if err := executeTemplateFileToFile(resources.Internal, templatePath, outputPath, templateData); err != nil { return err } } @@ -1070,39 +1076,86 @@ func executeTemplateToFile(filePath, tmplText string, data any) error { 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 := fmt.Sprintf("%-44s %s", extensionPath, strings.Join(codeowners, " ")) + 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) } - if strings.Contains(string(contents), extensionPath) { - return nil + 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 + } } - var updated bytes.Buffer - updated.Write(contents) - if len(contents) > 0 && !bytes.HasSuffix(contents, []byte("\n")) { - updated.WriteByte('\n') + 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) } - if len(contents) > 0 { - updated.WriteByte('\n') + + output := strings.Join(updated, "\n") + if !strings.HasSuffix(output, "\n") { + output += "\n" } - updated.WriteString(entry) - updated.WriteByte('\n') if err := os.MkdirAll(filepath.Dir(filePath), internal.PermissionDirectory); err != nil { return fmt.Errorf("failed to create CODEOWNERS directory: %w", err) } - if err := os.WriteFile(filePath, updated.Bytes(), internal.PermissionFile); err != nil { + //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( 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 f27cdd40cf8..7ea52139d85 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,7 +382,7 @@ func TestCollectExtensionMetadataFromFlagsTags(t *testing.T) { assert.Equal(t, []string{"alpha", "beta", "gamma"}, metadata.Tags) } -func TestCollectExtensionMetadataFromFlagsInternalDefaultsToGoPreview(t *testing.T) { +func TestCollectExtensionMetadataFromFlagsInternalDefaultsToGo(t *testing.T) { metadata, err := collectExtensionMetadataFromFlags(&initFlags{ internalScaffold: true, id: "test.extension", @@ -391,7 +392,7 @@ func TestCollectExtensionMetadataFromFlagsInternalDefaultsToGoPreview(t *testing require.NoError(t, err) assert.Equal(t, "go", metadata.Language) - assert.Equal(t, "0.0.1-preview", metadata.Version) + assert.Equal(t, "0.0.1", metadata.Version) } func TestCollectExtensionMetadataFromFlagsInternalRejectsNonGo(t *testing.T) { @@ -474,6 +475,12 @@ func TestCreateInternalExtensionScaffold(t *testing.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"), @@ -494,6 +501,66 @@ func TestCreateInternalExtensionScaffoldRejectsUnsafeId(t *testing.T) { require.ErrorContains(t, err, "invalid extension id") } +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() 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 index 86b05d803a0..47a52c62ece 100644 --- 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 @@ -49,7 +49,17 @@ try { } if ($BuildRecordMode) { - $buildFlags += "-tags=record" + # 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 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 From 7280769b4b7935a5b96cccbac23fa7cea1341180 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 9 Jun 2026 21:28:46 +0000 Subject: [PATCH 5/9] Add notice and fix cspell --- cli/azd/.vscode/cspell.yaml | 6 ------ cli/azd/extensions/microsoft.azd.extensions/cspell.yaml | 4 ++++ .../microsoft.azd.extensions/internal/cmd/init.go | 6 +++++- 3 files changed, 9 insertions(+), 7 deletions(-) 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/cspell.yaml b/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml index bf8aa741d07..f58a1e46db5 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml +++ b/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml @@ -1,5 +1,9 @@ import: ../../.vscode/cspell.yaml overrides: + - 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 06765055116..3588f50c1ce 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -658,7 +658,11 @@ func collectExtensionMetadata( } language := "go" - if !internalScaffold { + if internalScaffold { + fmt.Println(output.WithGrayFormat( + "Defaulting to Go - language selection skipped as internal scaffolding only supports Go currently.", + )) + } else { languageChoices := []*azdext.SelectChoice{ { Label: "Go", From c2224cfe97d159343dedea297385411de7feeadf Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 9 Jun 2026 21:39:18 +0000 Subject: [PATCH 6/9] Polish and add changelog entry --- cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md | 3 ++- .../microsoft.azd.extensions/internal/cmd/init.go | 6 +++--- cli/azd/extensions/microsoft.azd.extensions/version.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) 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/internal/cmd/init.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go index 3588f50c1ce..f37fd913303 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -133,7 +133,7 @@ func newInitCommand(noPrompt *bool) *cobra.Command { initCmd.Flags().BoolVar( &flags.internalScaffold, "internal", false, - "Scaffold Azure/azure-dev first-party extension files. Currently supports Go extensions.", + "Scaffold Azure/azure-dev first-party extension files. Currently supports Go extensions only.", ) initCmd.Flags().StringVar( @@ -659,9 +659,9 @@ func collectExtensionMetadata( language := "go" if internalScaffold { - fmt.Println(output.WithGrayFormat( + fmt.Println( "Defaulting to Go - language selection skipped as internal scaffolding only supports Go currently.", - )) + ) } else { languageChoices := []*azdext.SelectChoice{ { 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 From 4878bb8dd18ce7785165a045d7c404b08da2331d Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 9 Jun 2026 21:52:10 +0000 Subject: [PATCH 7/9] Fix cspell --- cli/azd/extensions/microsoft.azd.extensions/cspell.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml b/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml index f58a1e46db5..e3bb7fc3344 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml +++ b/cli/azd/extensions/microsoft.azd.extensions/cspell.yaml @@ -1,5 +1,8 @@ import: ../../.vscode/cspell.yaml overrides: + - filename: CHANGELOG.md + words: + - codeowners - filename: internal/cmd/init.go words: - codeowner From 26ade03ab8be1c326322f20a3bbe223ef7d37461 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 11 Jun 2026 22:44:38 +0000 Subject: [PATCH 8/9] Address feedback --- .../microsoft.azd.extensions/internal/cmd/init.go | 10 ++++++++++ .../internal/cmd/init_test.go | 12 ++++++++++++ 2 files changed, 22 insertions(+) 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 f37fd913303..ed8a31b7755 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -511,6 +511,11 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem 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( @@ -597,6 +602,11 @@ func collectExtensionMetadata( 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{ 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 7ea52139d85..11664c308fb 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 @@ -407,6 +407,18 @@ func TestCollectExtensionMetadataFromFlagsInternalRejectsNonGo(t *testing.T) { 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", From e10be6ff2c5e08974ddf4568ac5f978c988a078c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:26:56 +0000 Subject: [PATCH 9/9] Add repo-root coverage for internal init Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../internal/cmd/init_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 11664c308fb..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 @@ -513,6 +513,41 @@ func TestCreateInternalExtensionScaffoldRejectsUnsafeId(t *testing.T) { 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()