Skip to content
2 changes: 1 addition & 1 deletion pkg/cli/compile_args.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func expandDirectoryArg(dirPath string, verbose bool) ([]string, error) {
compileArgsLog.Printf("Expanding directory argument: %s", dirPath)

if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Compiling all workflows in directory: "+dirPath))
fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr("Compiling all workflows in directory: "+dirPath))
}

mdFiles, err := getMarkdownWorkflowFiles(dirPath)
Expand Down
188 changes: 188 additions & 0 deletions pkg/cli/compile_batch_notices_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//go:build !integration

package cli

import (
"bytes"
"os"
"strings"
"testing"

"github.com/github/gh-aw/pkg/workflow"
)

// TestDisplayBatchCompilationNotices verifies that displayBatchCompilationNotices
// aggregates experimental-feature notices and the Copilot billing tip into a single
// block rather than repeating them per workflow, and that success messages are
// suppressed when batch/quiet mode is active.
func TestDisplayBatchCompilationNotices(t *testing.T) {
tests := []struct {
name string
featureUsage map[string]int
copilotTipNeeded bool
config CompileConfig
expectedInOutput []string
notExpectedInOutput []string
}{
{
name: "no notices when nothing was used",
featureUsage: map[string]int{},
copilotTipNeeded: false,
config: CompileConfig{},
expectedInOutput: []string{},
notExpectedInOutput: []string{
"Experimental features",
"copilot",
},
},
{
name: "single experimental feature shows aggregate notice",
featureUsage: map[string]int{
"Using experimental feature: canvas": 3,
},
copilotTipNeeded: false,
config: CompileConfig{},
expectedInOutput: []string{
"Experimental features in use:",
"canvas: 3 workflows",
},
notExpectedInOutput: []string{
"Using experimental feature: canvas",
"Using experimental feature: canvas\nUsing experimental feature: canvas",
},
},
{
name: "multiple experimental features sorted by count desc",
featureUsage: map[string]int{
"Using experimental feature: canvas": 2,
"Using experimental feature: safe-read": 5,
},
copilotTipNeeded: false,
config: CompileConfig{},
expectedInOutput: []string{
"Experimental features in use:",
"safe-read: 5 workflows",
"canvas: 2 workflows",
},
notExpectedInOutput: []string{},
},
{
name: "copilot tip shown when needed",
featureUsage: map[string]int{},
copilotTipNeeded: true,
config: CompileConfig{},
expectedInOutput: []string{
"Copilot token-based inference may be available",
},
notExpectedInOutput: []string{},
},
{
name: "no output in JSON mode",
featureUsage: map[string]int{
"Using experimental feature: canvas": 2,
},
copilotTipNeeded: true,
config: CompileConfig{JSONOutput: true},
expectedInOutput: []string{},
notExpectedInOutput: []string{
"Experimental features",
"Copilot token-based inference",
},
},
{
name: "no output in verbose mode",
featureUsage: map[string]int{
"Using experimental feature: canvas": 2,
},
copilotTipNeeded: true,
config: CompileConfig{Verbose: true},
expectedInOutput: []string{},
notExpectedInOutput: []string{
"Experimental features",
"Copilot token-based inference",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := workflow.NewCompiler()
compiler.SetBatchMode(true)
compiler.SetExperimentalFeatureUsage(tt.featureUsage)
compiler.SetCopilotTipNeeded(tt.copilotTipNeeded)

oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w

displayBatchCompilationNotices(compiler, tt.config)

w.Close()
os.Stderr = oldStderr

var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()

for _, expected := range tt.expectedInOutput {
if !strings.Contains(output, expected) {
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
}
}
for _, notExpected := range tt.notExpectedInOutput {
if strings.Contains(output, notExpected) {
t.Errorf("expected output NOT to contain %q, got:\n%s", notExpected, output)
}
}
})
}
}

// TestBatchModeSetOnMultipleSpecificFiles verifies that compileSpecificFiles enables
// batch mode (quiet, aggregation) when more than one file is provided and verbose is off.
// It also checks that displayBatchCompilationNotices is called at the end by confirming
// the per-workflow success message is absent from stderr for multiple-file batches.
func TestBatchModeSetOnSpecificFilesWhenMultiple(t *testing.T) {
compiler := workflow.NewCompiler()
compiler.SetBatchMode(false)
compiler.SetQuiet(false)

// Simulating the batch-mode condition directly: when !config.Verbose && len(files) > 1
files := []string{"a.md", "b.md", "c.md"}
config := CompileConfig{Verbose: false}

batchMode := !config.Verbose && len(files) > 1
if !batchMode {
t.Fatal("expected batchMode=true for 3 non-verbose files")
}

compiler.SetBatchMode(batchMode)
compiler.SetQuiet(batchMode)

// Set some feature usage so the notice would appear in batch mode
compiler.SetExperimentalFeatureUsage(map[string]int{
"Using experimental feature: canvas": 2,
})

oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w

displayBatchCompilationNotices(compiler, config)

w.Close()
os.Stderr = oldStderr

var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()

if !strings.Contains(output, "Experimental features in use:") {
t.Errorf("expected aggregated experimental features notice, got:\n%s", output)
}
// Verify each feature is mentioned only once regardless of count
occurrences := strings.Count(output, "canvas")
if occurrences != 1 {
t.Errorf("expected 'canvas' to appear exactly once in batch output, got %d occurrences:\n%s", occurrences, output)
}
}
2 changes: 1 addition & 1 deletion pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func setupRepositoryContext(compiler *workflow.Compiler, config CompileConfig) {
parts := strings.SplitN(config.ScheduleSeed, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
compileCompilerSetupLog.Printf("Invalid --schedule-seed value %q: expected 'owner/repo' format", config.ScheduleSeed)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(
fmt.Sprintf("--schedule-seed %q is not in 'owner/repo' format; ignoring and falling back to git remote detection", config.ScheduleSeed),
))
} else {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/compile_external_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func handleBatchToolError(toolName string, err error, strict, verbose bool) erro
}
// In non-strict mode, errors are warnings
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%s warnings: %v", toolName, err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("%s warnings: %v", toolName, err)))
}
return nil
}
18 changes: 9 additions & 9 deletions pkg/cli/compile_file_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func compileSingleFile(ctx context.Context, compiler *workflow.Compiler, file st
// Regular workflow file - compile normally
compileHelpersLog.Printf("Compiling as regular workflow: %s", file)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatProgressMessage("Compiling: "+file))
fmt.Fprintln(os.Stderr, console.FormatProgressMessageStderr("Compiling: "+file))
}

if err := CompileWorkflowWithValidation(ctx, compiler, file, CompileValidationOptions{Verbose: verbose}); err != nil {
Expand Down Expand Up @@ -103,7 +103,7 @@ func compileAllWorkflowFiles(ctx context.Context, compiler *workflow.Compiler, w
if len(mdFiles) == 0 {
compileHelpersLog.Printf("No workflow markdown files found in %s after frontmatter filtering", workflowsDir)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow markdown files found in "+workflowsDir+" (workflow files must start with a frontmatter opener on the first line)"))
fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr("No workflow markdown files found in "+workflowsDir+" (workflow files must start with a frontmatter opener on the first line)"))
}
return stats, nil
}
Expand All @@ -118,7 +118,7 @@ func compileAllWorkflowFiles(ctx context.Context, compiler *workflow.Compiler, w
if err != nil {
compileHelpersLog.Printf("Failed to resolve absolute path for %s: %v", file, err)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to resolve absolute path for %s: %v", file, err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Failed to resolve absolute path for %s: %v", file, err)))
}
} else {
file = absFile
Expand Down Expand Up @@ -174,9 +174,9 @@ func compileModifiedFilesWithDependencies(ctx context.Context, compiler *workflo
}
}

fmt.Fprintln(os.Stderr, console.FormatProgressMessage("Watching for file changes"))
fmt.Fprintln(os.Stderr, console.FormatProgressMessageStderr("Watching for file changes"))
if verbose {
fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("Recompiling %d workflow(s) affected by %d change(s)...", len(workflowsToCompile), len(files))))
fmt.Fprintln(os.Stderr, console.FormatProgressMessageStderr(fmt.Sprintf("Recompiling %d workflow(s) affected by %d change(s)...", len(workflowsToCompile), len(files))))
}

// Reset warning count before compilation
Expand All @@ -202,7 +202,7 @@ func compileModifiedFilesWithDependencies(ctx context.Context, compiler *workflo
if err := actionCache.Save(); err != nil {
compileHelpersLog.Printf("Failed to save action cache: %v", err)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save action cache: %v", err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Failed to save action cache: %v", err)))
}
} else {
compileHelpersLog.Print("Action cache saved successfully")
Expand All @@ -214,7 +214,7 @@ func compileModifiedFilesWithDependencies(ctx context.Context, compiler *workflo
if successCount > 0 || hasActionCacheEntries {
if _, err := ensureGitAttributes(); err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Failed to update .gitattributes: %v", err)))
}
}
} else {
Expand All @@ -234,11 +234,11 @@ func handleFileDeleted(mdFile string, verbose bool) {
if _, err := os.Stat(lockFile); err == nil {
if err := os.Remove(lockFile); err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove lock file %s: %v", lockFile, err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Failed to remove lock file %s: %v", lockFile, err)))
}
} else {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Removed corresponding lock file: "+lockFile))
fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr("Removed corresponding lock file: "+lockFile))
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/cli/compile_infrastructure.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ func updateGitAttributes(successCount int, actionCache *workflow.ActionCache, ve
if err != nil {
compileInfrastructureLog.Printf("Failed to update .gitattributes: %v", err)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Failed to update .gitattributes: %v", err)))
}
return err
}
if updated {
compileInfrastructureLog.Printf("Successfully updated .gitattributes")
if verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated .gitattributes to mark .lock.yml files as generated"))
fmt.Fprintln(os.Stderr, console.FormatSuccessMessageStderr("Updated .gitattributes to mark .lock.yml files as generated"))
}
} else {
compileInfrastructureLog.Print(".gitattributes already up to date")
Expand All @@ -54,14 +54,14 @@ func saveActionCache(actionCache *workflow.ActionCache, verbose bool) error {
if err := actionCache.Save(); err != nil {
compileInfrastructureLog.Printf("Failed to save action cache: %v", err)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save action cache: %v", err)))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Failed to save action cache: %v", err)))
}
return err
}

compileInfrastructureLog.Print("Action cache saved successfully")
if verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Action cache saved to "+actionCache.GetCachePath()))
fmt.Fprintln(os.Stderr, console.FormatSuccessMessageStderr("Action cache saved to "+actionCache.GetCachePath()))
}

return nil
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/compile_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func CompileWorkflows(ctx context.Context, config CompileConfig) ([]*workflow.Wo
// Check context cancellation at the start
select {
case <-ctx.Done():
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled"))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr("Operation cancelled"))
return nil, ctx.Err()
default:
}
Expand Down Expand Up @@ -108,7 +108,7 @@ func CompileWorkflows(ctx context.Context, config CompileConfig) ([]*workflow.Wo
var markdownFile string
if len(config.MarkdownFiles) > 0 {
if len(config.MarkdownFiles) > 1 {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Watch mode only supports a single file, using the first one"))
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr("Watch mode only supports a single file, using the first one"))
}
// Resolve the workflow file to get the full path
resolvedFile, err := resolveWorkflowFile(config.MarkdownFiles[0], config.Verbose)
Expand Down
Loading
Loading