From 46645aaac55002aff68665124fd6fbd577b52523 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:47:42 +0000 Subject: [PATCH 1/7] Add workflow frontmatter format command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- cmd/gh-aw/command_groups_test.go | 1 + cmd/gh-aw/main.go | 13 +- pkg/cli/format_command.go | 290 +++++++++++++++++++++++++++++++ pkg/cli/format_command_test.go | 133 ++++++++++++++ 4 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 pkg/cli/format_command.go create mode 100644 pkg/cli/format_command_test.go diff --git a/cmd/gh-aw/command_groups_test.go b/cmd/gh-aw/command_groups_test.go index e08336b18d8..0d3a3963e9d 100644 --- a/cmd/gh-aw/command_groups_test.go +++ b/cmd/gh-aw/command_groups_test.go @@ -34,6 +34,7 @@ func TestCommandGroupAssignments(t *testing.T) { {name: "validate command in development group", commandName: "validate", expectedGroup: "development", shouldHaveGroup: true}, {name: "mcp command in development group", commandName: "mcp", expectedGroup: "development", shouldHaveGroup: true}, {name: "fix command in development group", commandName: "fix", expectedGroup: "development", shouldHaveGroup: true}, + {name: "format command in development group", commandName: "format", expectedGroup: "development", shouldHaveGroup: true}, {name: "domains command in development group", commandName: "domains", expectedGroup: "development", shouldHaveGroup: true}, // Execution Commands diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 739f2ca6623..2bb342ebec5 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -557,10 +557,10 @@ func runCompileCmd(cmd *cobra.Command, args []string) error { } type commandSet struct { - addCmd, addWizardCmd, editCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command - mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command - fixCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command - domainsCmd, experimentsCmd, forecastCmd, gradersCmd, modelsCmd, envCmd, jsonSchemaCmd *cobra.Command + addCmd, addWizardCmd, editCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command + mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command + fixCmd, formatCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command + domainsCmd, experimentsCmd, forecastCmd, gradersCmd, modelsCmd, envCmd, jsonSchemaCmd *cobra.Command } func fixPathForCommand(s string) string { @@ -729,6 +729,7 @@ func createCommandSet() commandSet { prCmd: cli.NewPRCommand(), secretsCmd: cli.NewSecretsCommand(), fixCmd: cli.NewFixCommand(), + formatCmd: cli.NewFormatCommand(), upgradeCmd: cli.NewUpgradeCommand(validateEngine), completionCmd: cli.NewCompletionCommand(), hashCmd: cli.NewHashCommand(), @@ -855,7 +856,7 @@ func assignCommandGroups(cmds commandSet) { removeCmd.GroupID, cmds.editCmd.GroupID, cmds.updateCmd.GroupID, cmds.deployCmd.GroupID, cmds.upgradeCmd.GroupID = "setup", "setup", "setup", "setup", "setup" cmds.secretsCmd.GroupID, cmds.envCmd.GroupID, cmds.doctorCmd.GroupID = "setup", "setup", "setup" compileCmd.GroupID, cmds.validateCmd.GroupID, cmds.lintCmd.GroupID = "development", "development", "development" - cmds.mcpCmd.GroupID, cmds.fixCmd.GroupID, cmds.domainsCmd.GroupID = "development", "development", "development" + cmds.mcpCmd.GroupID, cmds.fixCmd.GroupID, cmds.formatCmd.GroupID, cmds.domainsCmd.GroupID = "development", "development", "development", "development" runCmd.GroupID, enableCmd.GroupID, disableCmd.GroupID, cmds.trialCmd.GroupID = "execution", "execution", "execution", "execution" cmds.logsCmd.GroupID, cmds.auditCmd.GroupID, cmds.viewCmd.GroupID = "analysis", "analysis", "analysis" cmds.healthCmd.GroupID, cmds.outcomesCmd.GroupID, cmds.checksCmd.GroupID = "analysis", "analysis", "analysis" @@ -870,7 +871,7 @@ func addCommandsToRoot(cmds commandSet) { compileCmd, cmds.addCmd, cmds.addWizardCmd, cmds.editCmd, cmds.updateCmd, cmds.deployCmd, cmds.upgradeCmd, cmds.trialCmd, newCmd, cmds.initCmd, runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.viewCmd, cmds.healthCmd, cmds.outcomesCmd, cmds.checksCmd, cmds.mcpCmd, cmds.mcpServerCmd, cmds.prCmd, versionCmd, cmds.secretsCmd, - cmds.fixCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd, + cmds.fixCmd, cmds.formatCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd, cmds.domainsCmd, cmds.experimentsCmd, cmds.forecastCmd, cmds.gradersCmd, cmds.modelsCmd, cmds.envCmd, cmds.jsonSchemaCmd, ) } diff --git a/pkg/cli/format_command.go b/pkg/cli/format_command.go new file mode 100644 index 00000000000..a87cf870a21 --- /dev/null +++ b/pkg/cli/format_command.go @@ -0,0 +1,290 @@ +package cli + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var formatLog = logger.New("cli:format_command") + +// FormatConfig contains configuration for the format command. +type FormatConfig struct { + WorkflowIDs []string + WorkflowDir string + Verbose bool +} + +// RunFormat formats workflow frontmatter using the given configuration. +func RunFormat(config FormatConfig) error { + return runFormatCommand(config.WorkflowIDs, config.WorkflowDir, config.Verbose) +} + +// NewFormatCommand creates the format command. +func NewFormatCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "format [workflow]...", + Short: "Apply codemods and normalize agentic workflow frontmatter", + Long: `Apply all available codemods and normalize YAML frontmatter in agentic workflow files. + +Formatting uses two-space indentation and deterministic field ordering while retaining comments +and Markdown content. If no workflows are specified, all Markdown files in .github/workflows are +formatted. + +` + WorkflowIDExplanation, + Example: ` ` + string(constants.CLIExtensionPrefix) + ` format + ` + string(constants.CLIExtensionPrefix) + ` format my-workflow + ` + string(constants.CLIExtensionPrefix) + ` format --dir custom/workflows`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowDir, _ := cmd.Flags().GetString("dir") + verbose, _ := cmd.Flags().GetBool("verbose") + return runFormatCommand(args, workflowDir, verbose) + }, + } + + cmd.Flags().StringP("dir", "d", "", "Workflow directory (default: $GH_AW_WORKFLOWS_DIR or .github/workflows)") + cmd.ValidArgsFunction = CompleteWorkflowNames + RegisterDirFlagCompletion(cmd, "dir") + return cmd +} + +func runFormatCommand(workflowIDs []string, workflowDir string, verbose bool) error { + formatLog.Printf("Formatting workflows: workflowIDs=%v, workflowDir=%s", workflowIDs, workflowDir) + files, err := resolveFormatWorkflowFiles(workflowIDs, workflowDir, verbose) + if err != nil { + return err + } + if len(files) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow files found.")) + return nil + } + + codemods := GetAllCodemods() + for _, file := range files { + if err := formatWorkflowFile(file, codemods, verbose); err != nil { + return fmt.Errorf("failed to format %s: %w", filepath.Base(file), err) + } + } + return nil +} + +func resolveFormatWorkflowFiles(workflowIDs []string, workflowDir string, verbose bool) ([]string, error) { + if workflowDir == "" { + workflowDir = constants.GetWorkflowDir() + } else { + workflowDir = filepath.Clean(workflowDir) + } + + if len(workflowIDs) == 0 { + return getMarkdownWorkflowFiles(workflowDir) + } + + files := make([]string, 0, len(workflowIDs)) + for _, workflowID := range workflowIDs { + file, err := resolveWorkflowFileInDir(workflowID, verbose, workflowDir) + if err != nil { + return nil, err + } + files = append(files, file) + } + return files, nil +} + +func formatWorkflowFile(filePath string, codemods []Codemod, verbose bool) error { + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + formatted, appliedCodemods, err := formatWorkflowContentWithInfo(string(content), filePath, codemods) + if err != nil { + return err + } + if formatted == string(content) { + console.LogVerbose(verbose, filepath.Base(filePath)+" is already formatted") + return nil + } + + if err := os.WriteFile(filePath, []byte(formatted), constants.FilePermSensitive); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + if err := scaffoldSerenaSharedWorkflowIfNeeded(filePath, appliedCodemods, formatted, verbose); err != nil { + return fmt.Errorf("failed to scaffold shared Serena workflow: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(filepath.Base(filePath))) + return nil +} + +func formatWorkflowContent(content, filePath string, codemods []Codemod) (string, error) { + formatted, _, err := formatWorkflowContentWithInfo(content, filePath, codemods) + return formatted, err +} + +func formatWorkflowContentWithInfo(content, filePath string, codemods []Codemod) (string, []string, error) { + fixed, appliedCodemods, err := applyFormatCodemods(content, filePath, codemods) + if err != nil { + return "", nil, err + } + formatted, err := normalizeFrontmatter(fixed) + return formatted, appliedCodemods, err +} + +func applyFormatCodemods(content, filePath string, codemods []Codemod) (string, []string, error) { + currentContent := content + var appliedCodemods []string + + for _, codemod := range codemods { + currentResult, err := parser.ExtractFrontmatterFromContent(currentContent) + if err != nil { + return "", nil, err + } + + var newContent string + var applied bool + if codemod.ApplyWithContext != nil { + newContent, applied, err = codemod.ApplyWithContext(currentContent, currentResult.Frontmatter, filePath) + } else { + newContent, applied, err = codemod.Apply(currentContent, currentResult.Frontmatter) + } + if err != nil { + wrappedErr := fmt.Errorf("codemod %s failed: %w", codemod.ID, err) + if codemod.Guided { + return "", nil, &GuidedError{Cause: wrappedErr} + } + return "", nil, wrappedErr + } + if applied { + currentContent = newContent + appliedCodemods = append(appliedCodemods, codemod.Name) + } + } + + return currentContent, appliedCodemods, nil +} + +func normalizeFrontmatter(content string) (string, error) { + frontmatter, suffix, err := splitFrontmatterForFormatting(content) + if err != nil { + return "", err + } + if strings.TrimSpace(frontmatter) == "" { + return "---\n---" + suffix, nil + } + + var document yaml.Node + decoder := yaml.NewDecoder(strings.NewReader(frontmatter)) + if err := decoder.Decode(&document); err != nil { + return "", fmt.Errorf("failed to parse frontmatter: %w", err) + } + var extra yaml.Node + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return "", errors.New("frontmatter must contain a single YAML document") + } + return "", fmt.Errorf("failed to parse frontmatter: %w", err) + } + + if len(document.Content) == 0 { + return "---\n---" + suffix, nil + } + root := document.Content[0] + if root.Kind != yaml.MappingNode { + return "", errors.New("frontmatter must be a YAML mapping") + } + orderYAMLMapping(root, true) + + var output bytes.Buffer + encoder := yaml.NewEncoder(&output) + encoder.SetIndent(2) + if err := encoder.Encode(&document); err != nil { + return "", fmt.Errorf("failed to encode frontmatter: %w", err) + } + if err := encoder.Close(); err != nil { + return "", fmt.Errorf("failed to encode frontmatter: %w", err) + } + + formattedYAML := output.String() + formattedYAML = strings.TrimPrefix(formattedYAML, "---\n") + formattedYAML = strings.TrimSuffix(formattedYAML, "...\n") + if formattedYAML != "" && !strings.HasSuffix(formattedYAML, "\n") { + formattedYAML += "\n" + } + return "---\n" + formattedYAML + "---" + suffix, nil +} + +func splitFrontmatterForFormatting(content string) (string, string, error) { + firstNewline := strings.IndexByte(content, '\n') + if firstNewline < 0 || strings.TrimSpace(content[:firstNewline]) != "---" { + return "", "", errors.New("workflow does not contain YAML frontmatter") + } + + for start := firstNewline + 1; start <= len(content); { + end := strings.IndexByte(content[start:], '\n') + if end < 0 { + end = len(content) + } else { + end += start + } + line := strings.TrimSuffix(content[start:end], "\r") + if line == "---" { + return content[firstNewline+1 : start], content[end:], nil + } + if end == len(content) { + break + } + start = end + 1 + } + return "", "", errors.New("frontmatter not properly closed") +} + +func orderYAMLMapping(node *yaml.Node, topLevel bool) { + if node.Kind == yaml.MappingNode { + type pair struct { + key *yaml.Node + value *yaml.Node + } + pairs := make([]pair, 0, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + pairs = append(pairs, pair{key: node.Content[i], value: node.Content[i+1]}) + } + slices.SortStableFunc(pairs, func(a, b pair) int { + return compareYAMLKeys(a.key.Value, b.key.Value, topLevel) + }) + node.Content = node.Content[:0] + for _, item := range pairs { + node.Content = append(node.Content, item.key, item.value) + } + } + + for _, child := range node.Content { + orderYAMLMapping(child, false) + } +} + +func compareYAMLKeys(a, b string, topLevel bool) int { + if topLevel { + aIndex := slices.Index(constants.PriorityWorkflowFields, a) + bIndex := slices.Index(constants.PriorityWorkflowFields, b) + switch { + case aIndex >= 0 && bIndex >= 0: + return aIndex - bIndex + case aIndex >= 0: + return -1 + case bIndex >= 0: + return 1 + } + } + return strings.Compare(a, b) +} diff --git a/pkg/cli/format_command_test.go b/pkg/cli/format_command_test.go new file mode 100644 index 00000000000..04b21269dff --- /dev/null +++ b/pkg/cli/format_command_test.go @@ -0,0 +1,133 @@ +//go:build !integration + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatWorkflowContentAppliesCodemodsAndPreservesComments(t *testing.T) { + t.Parallel() + input := `--- +timeout_minutes: 30 # migrate this +engine: + model: test + id: copilot +# Trigger comment +on: + workflow_dispatch: +permissions: + issues: read + contents: read +--- + +# Workflow + +Keep this Markdown exactly. +` + + formatted, err := formatWorkflowContent(input, "workflow.md", GetAllCodemods()) + require.NoError(t, err) + + assert.NotContains(t, formatted, "timeout_minutes:") + assert.Contains(t, formatted, "timeout-minutes: 30 # migrate this") + assert.Contains(t, formatted, "# Trigger comment") + assert.Contains(t, formatted, " workflow_dispatch:") + assert.Less(t, strings.Index(formatted, "on:"), strings.Index(formatted, "permissions:")) + assert.Less(t, strings.Index(formatted, "permissions:"), strings.Index(formatted, "engine:")) + assert.Less(t, strings.Index(formatted, " contents:"), strings.Index(formatted, " issues:")) + assert.Contains(t, formatted, "# Workflow\n\nKeep this Markdown exactly.") +} + +func TestFormatWorkflowContentRoundTrips(t *testing.T) { + t.Parallel() + tests := map[string]string{ + "nested lists and inline comment": `--- +tools: + web-fetch: + allowed-domains: + - example.com +on: + workflow_dispatch: +engine: copilot # engine comment +--- +Body with no trailing newline`, + "standalone and nested comments": `--- +# tools comment +tools: + web-fetch: + # domain comment + allowed-domains: [example.com] +engine: copilot +--- + +# Body +`, + } + + for name, input := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + once, err := formatWorkflowContent(input, "workflow.md", nil) + require.NoError(t, err) + twice, err := formatWorkflowContent(once, "workflow.md", nil) + require.NoError(t, err) + + assert.Equal(t, once, twice) + assert.Contains(t, once, "comment") + }) + } +} + +func TestNormalizeFrontmatterDoesNotTreatBlockScalarAsDelimiter(t *testing.T) { + t.Parallel() + input := `--- +description: | + before + --- + after +engine: copilot +--- +# Body` + + formatted, err := normalizeFrontmatter(input) + require.NoError(t, err) + + assert.Contains(t, formatted, " ---") + assert.Contains(t, formatted, " after") + assert.Contains(t, formatted, "engine: copilot") + assert.True(t, strings.HasSuffix(formatted, "\n# Body")) +} + +func TestRunFormatFormatsSelectedWorkflow(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + workflowPath := filepath.Join(tempDir, "example.md") + input := "---\nengine: copilot\non:\n workflow_dispatch:\n---\n# Body" + require.NoError(t, os.WriteFile(workflowPath, []byte(input), 0o644)) + + err := RunFormat(FormatConfig{ + WorkflowIDs: []string{workflowPath}, + WorkflowDir: tempDir, + }) + require.NoError(t, err) + + formatted, err := os.ReadFile(workflowPath) + require.NoError(t, err) + assert.Equal(t, "---\non:\n workflow_dispatch:\nengine: copilot\n---\n# Body", string(formatted)) +} + +func TestNewFormatCommand(t *testing.T) { + t.Parallel() + cmd := NewFormatCommand() + + assert.Equal(t, "format", cmd.Name()) + assert.NotNil(t, cmd.Flags().Lookup("dir")) + assert.NotNil(t, cmd.ValidArgsFunction) +} From 2b8663cb5d5fcac97aa10390e0d0cafd41a7c442 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:07:07 +0000 Subject: [PATCH 2/7] Clarify logical frontmatter field ordering Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/format_command.go | 16 ++++++++-------- pkg/cli/format_command_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/pkg/cli/format_command.go b/pkg/cli/format_command.go index a87cf870a21..05838eaab8a 100644 --- a/pkg/cli/format_command.go +++ b/pkg/cli/format_command.go @@ -203,7 +203,7 @@ func normalizeFrontmatter(content string) (string, error) { if root.Kind != yaml.MappingNode { return "", errors.New("frontmatter must be a YAML mapping") } - orderYAMLMapping(root, true) + orderYAMLMapping(root, constants.PriorityWorkflowFields) var output bytes.Buffer encoder := yaml.NewEncoder(&output) @@ -249,7 +249,7 @@ func splitFrontmatterForFormatting(content string) (string, string, error) { return "", "", errors.New("frontmatter not properly closed") } -func orderYAMLMapping(node *yaml.Node, topLevel bool) { +func orderYAMLMapping(node *yaml.Node, priorityFields []string) { if node.Kind == yaml.MappingNode { type pair struct { key *yaml.Node @@ -260,7 +260,7 @@ func orderYAMLMapping(node *yaml.Node, topLevel bool) { pairs = append(pairs, pair{key: node.Content[i], value: node.Content[i+1]}) } slices.SortStableFunc(pairs, func(a, b pair) int { - return compareYAMLKeys(a.key.Value, b.key.Value, topLevel) + return compareYAMLKeys(a.key.Value, b.key.Value, priorityFields) }) node.Content = node.Content[:0] for _, item := range pairs { @@ -269,14 +269,14 @@ func orderYAMLMapping(node *yaml.Node, topLevel bool) { } for _, child := range node.Content { - orderYAMLMapping(child, false) + orderYAMLMapping(child, nil) } } -func compareYAMLKeys(a, b string, topLevel bool) int { - if topLevel { - aIndex := slices.Index(constants.PriorityWorkflowFields, a) - bIndex := slices.Index(constants.PriorityWorkflowFields, b) +func compareYAMLKeys(a, b string, priorityFields []string) int { + if len(priorityFields) > 0 { + aIndex := slices.Index(priorityFields, a) + bIndex := slices.Index(priorityFields, b) switch { case aIndex >= 0 && bIndex >= 0: return aIndex - bIndex diff --git a/pkg/cli/format_command_test.go b/pkg/cli/format_command_test.go index 04b21269dff..60e91eb844e 100644 --- a/pkg/cli/format_command_test.go +++ b/pkg/cli/format_command_test.go @@ -105,6 +105,39 @@ engine: copilot assert.True(t, strings.HasSuffix(formatted, "\n# Body")) } +func TestNormalizeFrontmatterUsesLogicalThenAlphabeticalFieldOrder(t *testing.T) { + t.Parallel() + input := `--- +zzz: last +steps: [] +engine: copilot +on: workflow_dispatch +aaa: first +permissions: read-all +network: defaults +--- +` + + formatted, err := normalizeFrontmatter(input) + require.NoError(t, err) + + expectedOrder := []string{ + "on:", + "permissions:", + "network:", + "steps:", + "aaa:", + "engine:", + "zzz:", + } + previous := -1 + for _, field := range expectedOrder { + index := strings.Index(formatted, field) + require.Greater(t, index, previous, "%s was not in logical/alphabetical order:\n%s", field, formatted) + previous = index + } +} + func TestRunFormatFormatsSelectedWorkflow(t *testing.T) { t.Parallel() tempDir := t.TempDir() From 40290a172dafb9e93d2c8ab0738cd873527dc52f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:11:44 +0000 Subject: [PATCH 3/7] Make alphabetical fallback test explicit Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/format_command_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cli/format_command_test.go b/pkg/cli/format_command_test.go index 60e91eb844e..5fb9485d94e 100644 --- a/pkg/cli/format_command_test.go +++ b/pkg/cli/format_command_test.go @@ -110,7 +110,7 @@ func TestNormalizeFrontmatterUsesLogicalThenAlphabeticalFieldOrder(t *testing.T) input := `--- zzz: last steps: [] -engine: copilot +bbb: middle on: workflow_dispatch aaa: first permissions: read-all @@ -127,7 +127,7 @@ network: defaults "network:", "steps:", "aaa:", - "engine:", + "bbb:", "zzz:", } previous := -1 From e16ea510487b460789f79728d1624bcb65ee2cd2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:38:15 +0000 Subject: [PATCH 4/7] Add ADR for format command --- .../59971-add-frontmatter-format-command.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/adr/59971-add-frontmatter-format-command.md diff --git a/docs/adr/59971-add-frontmatter-format-command.md b/docs/adr/59971-add-frontmatter-format-command.md new file mode 100644 index 00000000000..76cf5d8a07f --- /dev/null +++ b/docs/adr/59971-add-frontmatter-format-command.md @@ -0,0 +1,50 @@ +# ADR-59971: Add Frontmatter Format Command + +**Date**: 2026-09-10 +**Status**: Draft +**Deciders**: gh-aw maintainers + +--- + +### Context + +`gh-aw` workflows store behavior in YAML frontmatter embedded in Markdown files, and this PR adds a CLI path that rewrites that frontmatter. The diff shows repeated requirements to preserve Markdown body content and comments, while also normalizing indentation, key naming via existing codemods, and deterministic field ordering. It also adds support for formatting individual workflows, all workflows in the default directory, and files under a custom directory. The architectural question is how workflow frontmatter normalization should be implemented without introducing a separate configuration format or breaking user-authored comments. + +### Decision + +We will add a dedicated `gh aw format` command that applies the existing codemod pipeline and then rewrites workflow frontmatter through a YAML AST-based normalizer while preserving comments and the Markdown body. We decided to integrate formatting into the CLI command set and operate directly on workflow Markdown files because the PR evidence shows the desired behavior is repository-local, repeatable, and aligned with other `gh aw` authoring commands. This approach makes frontmatter canonicalization an explicit authoring workflow instead of an incidental side effect of compile or validation paths. + +### Alternatives Considered + +#### Alternative 1: Reformat frontmatter implicitly during compile or validation + +The project could normalize workflow frontmatter whenever users run compile, validate, or related commands. This was considered because those commands already parse workflows and would have access to the same frontmatter data. It was not chosen because the diff introduces a standalone `format` command and targeted CLI tests, indicating a need for an explicit write operation that users can run on demand without coupling source-file mutation to read-oriented commands. + +#### Alternative 2: Implement formatting with text-based string rewriting only + +Another option would be to format the frontmatter with line-oriented replacements or regex-style transforms and avoid YAML AST handling. This was considered because it could be simpler for a narrow subset of field migrations. It was not chosen because the implementation and tests emphasize preserving comments, handling nested mappings, and avoiding false delimiters inside block scalars, which are stronger fits for structured YAML node processing than plain text rewriting. + +#### Alternative 3: Leave frontmatter style unmanaged and rely only on codemods + +The team could keep workflow syntax migrations limited to existing codemods and avoid introducing a canonical formatting pass. This was considered because it minimizes new CLI surface area and avoids file rewrite logic. It was not chosen because the PR explicitly adds deterministic ordering, indentation normalization, and round-trip coverage, showing that consistent formatting is now treated as part of workflow maintainability rather than an optional manual convention. + +### Consequences + +#### Positive +- Workflow authors get a dedicated command to canonicalize frontmatter structure across one file, many files, or custom workflow directories. +- Existing codemods can be reused before formatting, allowing syntax migrations and canonical layout to happen in a single operation. +- YAML-node-based normalization can preserve comments and Markdown content while still enforcing deterministic ordering and indentation. + +#### Negative +- The CLI surface area grows, adding another authoring command that maintainers must document, test, and support. +- Formatting now depends on careful frontmatter parsing and YAML encoding behavior, which can introduce edge cases around delimiters, block scalars, or comment retention. +- Rewriting files in place may create larger diffs for users when canonical ordering changes multiple fields at once. + +#### Neutral +- The command is grouped with other development-oriented CLI commands rather than changing execution behavior. +- Test coverage now includes comment preservation, round-tripping, block scalar handling, field ordering, and command registration. +- The formatter works on Markdown workflow files directly and does not introduce a new persisted intermediate representation. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 7c96536ae76647fd9532ca910deee1248cd9df79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:41:50 +0000 Subject: [PATCH 5/7] Add formatter fuzz tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/format_command.go | 26 +++++++++++- pkg/cli/format_command_fuzz_test.go | 66 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 pkg/cli/format_command_fuzz_test.go diff --git a/pkg/cli/format_command.go b/pkg/cli/format_command.go index 05838eaab8a..295056d4ab8 100644 --- a/pkg/cli/format_command.go +++ b/pkg/cli/format_command.go @@ -20,6 +20,8 @@ import ( var formatLog = logger.New("cli:format_command") +const maxYAMLFormattingPasses = 10 + // FormatConfig contains configuration for the format command. type FormatConfig struct { WorkflowIDs []string @@ -205,10 +207,30 @@ func normalizeFrontmatter(content string) (string, error) { } orderYAMLMapping(root, constants.PriorityWorkflowFields) + formattedYAML := "" + for range maxYAMLFormattingPasses { + next, err := encodeYAMLDocument(&document) + if err != nil { + return "", err + } + if next == formattedYAML { + return "---\n" + formattedYAML + "---" + suffix, nil + } + formattedYAML = next + var normalizedDocument yaml.Node + if err := yaml.Unmarshal([]byte(formattedYAML), &normalizedDocument); err != nil { + return "", fmt.Errorf("failed to parse encoded frontmatter: %w", err) + } + document = normalizedDocument + } + return "", errors.New("frontmatter formatting did not stabilize") +} + +func encodeYAMLDocument(document *yaml.Node) (string, error) { var output bytes.Buffer encoder := yaml.NewEncoder(&output) encoder.SetIndent(2) - if err := encoder.Encode(&document); err != nil { + if err := encoder.Encode(document); err != nil { return "", fmt.Errorf("failed to encode frontmatter: %w", err) } if err := encoder.Close(); err != nil { @@ -221,7 +243,7 @@ func normalizeFrontmatter(content string) (string, error) { if formattedYAML != "" && !strings.HasSuffix(formattedYAML, "\n") { formattedYAML += "\n" } - return "---\n" + formattedYAML + "---" + suffix, nil + return formattedYAML, nil } func splitFrontmatterForFormatting(content string) (string, string, error) { diff --git a/pkg/cli/format_command_fuzz_test.go b/pkg/cli/format_command_fuzz_test.go new file mode 100644 index 00000000000..0f18dbfd66c --- /dev/null +++ b/pkg/cli/format_command_fuzz_test.go @@ -0,0 +1,66 @@ +//go:build !integration + +package cli + +import ( + "strings" + "testing" +) + +func FuzzNormalizeFrontmatter(f *testing.F) { + f.Add("---\nengine: copilot\non: workflow_dispatch\n---\n# Body") + f.Add("---\ndescription: |\n before\n ---\n after\n---\nMarkdown") + f.Add("---\n000000000: |\n#00\n---") + f.Add("---\n0: |\n\n\n 00\n---") + f.Add("---\ninvalid: [\n---") + f.Add("# No frontmatter") + + f.Fuzz(func(t *testing.T, content string) { + formatted, err := normalizeFrontmatter(content) + if err != nil { + return + } + + _, originalSuffix, err := splitFrontmatterForFormatting(content) + if err != nil { + t.Fatalf("successfully formatted content could not be split: %v", err) + } + _, formattedSuffix, err := splitFrontmatterForFormatting(formatted) + if err != nil { + t.Fatalf("formatted content could not be split: %v", err) + } + if formattedSuffix != originalSuffix { + t.Fatalf("Markdown content changed during formatting") + } + + reformatted, err := normalizeFrontmatter(formatted) + if err != nil { + t.Fatalf("formatted content could not be formatted again: %v", err) + } + if reformatted != formatted { + t.Fatalf("formatting is not idempotent:\nfirst: %q\nsecond: %q", formatted, reformatted) + } + }) +} + +func FuzzSplitFrontmatterForFormatting(f *testing.F) { + f.Add("engine: copilot\n", "\n# Body") + f.Add("description: |\n before\n ---\n after\n", "\nMarkdown") + f.Add("", "") + + f.Fuzz(func(t *testing.T, frontmatter, suffix string) { + frontmatter = strings.ReplaceAll(frontmatter, "\r", "") + content := "---\n" + frontmatter + "\n---\n" + suffix + extracted, extractedSuffix, err := splitFrontmatterForFormatting(content) + if err != nil { + t.Fatalf("constructed frontmatter could not be split: %v", err) + } + reconstructed := "---\n" + extracted + "---" + extractedSuffix + if reconstructed != content { + t.Fatalf("split content did not reconstruct input") + } + if !strings.HasPrefix(reconstructed, "---\n") { + t.Fatalf("reconstructed content lost opening delimiter") + } + }) +} From 78b64ddd6f2f5414dd4123ded1a071ba9df64f8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:46:03 +0000 Subject: [PATCH 6/7] Clarify formatter fuzz invariants Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/format_command.go | 3 ++- pkg/cli/format_command_fuzz_test.go | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/cli/format_command.go b/pkg/cli/format_command.go index 295056d4ab8..f1f71762f0a 100644 --- a/pkg/cli/format_command.go +++ b/pkg/cli/format_command.go @@ -20,6 +20,7 @@ import ( var formatLog = logger.New("cli:format_command") +// Re-encoding reaches a fixed point after YAML node styles and comments are normalized. const maxYAMLFormattingPasses = 10 // FormatConfig contains configuration for the format command. @@ -223,7 +224,7 @@ func normalizeFrontmatter(content string) (string, error) { } document = normalizedDocument } - return "", errors.New("frontmatter formatting did not stabilize") + return "", fmt.Errorf("frontmatter formatting did not stabilize after %d passes", maxYAMLFormattingPasses) } func encodeYAMLDocument(document *yaml.Node) (string, error) { diff --git a/pkg/cli/format_command_fuzz_test.go b/pkg/cli/format_command_fuzz_test.go index 0f18dbfd66c..a60fa7da65b 100644 --- a/pkg/cli/format_command_fuzz_test.go +++ b/pkg/cli/format_command_fuzz_test.go @@ -50,7 +50,10 @@ func FuzzSplitFrontmatterForFormatting(f *testing.F) { f.Fuzz(func(t *testing.T, frontmatter, suffix string) { frontmatter = strings.ReplaceAll(frontmatter, "\r", "") - content := "---\n" + frontmatter + "\n---\n" + suffix + if frontmatter != "" && !strings.HasSuffix(frontmatter, "\n") { + frontmatter += "\n" + } + content := "---\n" + frontmatter + "---\n" + suffix extracted, extractedSuffix, err := splitFrontmatterForFormatting(content) if err != nil { t.Fatalf("constructed frontmatter could not be split: %v", err) From 5d093a774b15e36133f3b51fa9ce20dab75973de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:02:16 +0000 Subject: [PATCH 7/7] Reuse compile workflow discovery Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/format_command.go | 6 +++++- pkg/cli/format_command_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pkg/cli/format_command.go b/pkg/cli/format_command.go index f1f71762f0a..1e52f26cf57 100644 --- a/pkg/cli/format_command.go +++ b/pkg/cli/format_command.go @@ -91,7 +91,11 @@ func resolveFormatWorkflowFiles(workflowIDs []string, workflowDir string, verbos } if len(workflowIDs) == 0 { - return getMarkdownWorkflowFiles(workflowDir) + files, err := getMarkdownWorkflowFiles(workflowDir) + if err != nil { + return nil, err + } + return filterMarkdownFilesWithFrontmatter(files) } files := make([]string, 0, len(workflowIDs)) diff --git a/pkg/cli/format_command_test.go b/pkg/cli/format_command_test.go index 5fb9485d94e..ccbb8c5b9ec 100644 --- a/pkg/cli/format_command_test.go +++ b/pkg/cli/format_command_test.go @@ -156,6 +156,25 @@ func TestRunFormatFormatsSelectedWorkflow(t *testing.T) { assert.Equal(t, "---\non:\n workflow_dispatch:\nengine: copilot\n---\n# Body", string(formatted)) } +func TestRunFormatOnlyFormatsAgenticWorkflowsInDirectory(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + workflowPath := filepath.Join(tempDir, "workflow.md") + documentationPath := filepath.Join(tempDir, "documentation.md") + require.NoError(t, os.WriteFile(workflowPath, []byte("---\nengine: copilot\non: push\n---\n# Body"), 0o644)) + require.NoError(t, os.WriteFile(documentationPath, []byte("# Documentation\n"), 0o644)) + + err := RunFormat(FormatConfig{WorkflowDir: tempDir}) + require.NoError(t, err) + + formatted, err := os.ReadFile(workflowPath) + require.NoError(t, err) + assert.Equal(t, "---\non: push\nengine: copilot\n---\n# Body", string(formatted)) + documentation, err := os.ReadFile(documentationPath) + require.NoError(t, err) + assert.Equal(t, "# Documentation\n", string(documentation)) +} + func TestNewFormatCommand(t *testing.T) { t.Parallel() cmd := NewFormatCommand()