From 7d3222beb7a6a268ec85f639a7131d108b5eb9b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:13:58 +0000 Subject: [PATCH 01/10] Initial plan From 4e05283d332489da7add5b027c818044e8faa272 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:32:11 +0000 Subject: [PATCH 02/10] fix: allow dispatcher skill generation when .github/aw has no markdown Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents.go | 4 ++- pkg/cli/copilot_agents_test.go | 24 ++++++++++++++ pkg/cli/upgrade_integration_test.go | 49 +++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/pkg/cli/copilot_agents.go b/pkg/cli/copilot_agents.go index 9c8c372de36..ce040c54680 100644 --- a/pkg/cli/copilot_agents.go +++ b/pkg/cli/copilot_agents.go @@ -169,7 +169,9 @@ func buildAgenticWorkflowsSkillContent(gitRoot string) (string, error) { sort.Strings(awFiles) if len(awFiles) == 0 { - return "", fmt.Errorf("no markdown files found in %s - ensure .github/aw contains workflow documentation files", awRoot) + // .github/aw may exist for non-markdown artifacts (e.g. actions-lock.json, logs/). + // Emit a minimal skill without an explicit file list in that case. + return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1), nil } var fileList strings.Builder diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index a380ede003e..526ad97cc2e 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -367,6 +367,30 @@ func TestBuildAgenticWorkflowsSkillContentWithoutAWDirectory(t *testing.T) { } } +func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { + tempDir := testutil.TempDir(t, "test-*") + awDir := filepath.Join(tempDir, ".github", "aw") + if err := os.MkdirAll(filepath.Join(awDir, "logs"), 0o755); err != nil { + t.Fatalf("Failed to create .github/aw/logs directory: %v", err) + } + if err := os.WriteFile(filepath.Join(awDir, "actions-lock.json"), []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create actions-lock.json: %v", err) + } + + content, err := buildAgenticWorkflowsSkillContent(tempDir) + if err != nil { + t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) + } + + expected := strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1) + if content != expected { + t.Fatalf("Expected exact skill content with empty .github/aw directory:\n%s\ngot:\n%s", expected, content) + } + if strings.Contains(content, agenticWorkflowsSkillFileListPlaceholder) { + t.Fatalf("expected generated skill content to replace the file-list placeholder:\n%s", content) + } +} + func TestCheckedInAgenticWorkflowsSkillMatchesGeneratedContent(t *testing.T) { _, file, _, ok := runtime.Caller(0) if !ok { diff --git a/pkg/cli/upgrade_integration_test.go b/pkg/cli/upgrade_integration_test.go index 046acc9553d..34269c61071 100644 --- a/pkg/cli/upgrade_integration_test.go +++ b/pkg/cli/upgrade_integration_test.go @@ -3,7 +3,9 @@ package cli import ( + "os" "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -24,3 +26,50 @@ func TestUpgradeCommand_OnExistingRepository(t *testing.T) { require.NoError(t, err, "upgrade command should succeed on existing repository, output: %s", outputStr) assert.Contains(t, outputStr, "Upgrade complete", "Should report upgrade complete") } + +func TestInitAndUpgrade_WithEmptyAWDirectory(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + initGit := exec.Command("git", "init", "--quiet") + initGit.Dir = setup.tempDir + require.NoError(t, initGit.Run(), "git init should succeed") + + awDir := filepath.Join(setup.tempDir, ".github", "aw") + require.NoError(t, os.MkdirAll(filepath.Join(awDir, "logs"), 0o755), "should create .github/aw/logs") + require.NoError(t, os.WriteFile(filepath.Join(awDir, "actions-lock.json"), []byte("{}\n"), 0o644), "should create actions-lock.json") + + initCmd := exec.Command(setup.binaryPath, "init") + initCmd.Dir = setup.tempDir + initOutput, initErr := initCmd.CombinedOutput() + require.NoError(t, initErr, "init command should succeed with empty .github/aw directory, output: %s", string(initOutput)) + + skillPath := filepath.Join(setup.tempDir, ".github", "skills", "agentic-workflows", "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + t.Fatalf("expected dispatcher skill file to exist after init: %v", err) + } + + workflowPath := filepath.Join(setup.tempDir, ".github", "workflows", "example.md") + workflowContent := `--- +name: Example Agentic Workflow +on: + workflow_dispatch: +permissions: + contents: read + actions: read +engine: copilot +strict: true +timeout-minutes: 5 +--- + +Say hello. +` + require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0o644), "should create sample workflow") + + upgradeCmd := exec.Command(setup.binaryPath, "upgrade", "--no-fix", "--skip-extension-upgrade") + upgradeCmd.Dir = setup.tempDir + upgradeOutput, upgradeErr := upgradeCmd.CombinedOutput() + upgradeOutputStr := string(upgradeOutput) + require.NoError(t, upgradeErr, "upgrade command should succeed with empty .github/aw directory, output: %s", upgradeOutputStr) + assert.Contains(t, upgradeOutputStr, "Upgrade complete", "Should report upgrade complete") +} From 3a6f26df003c7df1a7b6e446a837fb919b199bb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:33:35 +0000 Subject: [PATCH 03/10] test: cover empty .github/aw init/upgrade path more thoroughly Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents_test.go | 8 ++++++-- pkg/cli/upgrade_integration_test.go | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index 526ad97cc2e..83da2a4ffc6 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -369,6 +369,7 @@ func TestBuildAgenticWorkflowsSkillContentWithoutAWDirectory(t *testing.T) { func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { tempDir := testutil.TempDir(t, "test-*") + withoutAWDir := testutil.TempDir(t, "test-*") awDir := filepath.Join(tempDir, ".github", "aw") if err := os.MkdirAll(filepath.Join(awDir, "logs"), 0o755); err != nil { t.Fatalf("Failed to create .github/aw/logs directory: %v", err) @@ -382,9 +383,12 @@ func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) } - expected := strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1) + expected, err := buildAgenticWorkflowsSkillContent(withoutAWDir) + if err != nil { + t.Fatalf("buildAgenticWorkflowsSkillContent() without .github/aw directory returned error: %v", err) + } if content != expected { - t.Fatalf("Expected exact skill content with empty .github/aw directory:\n%s\ngot:\n%s", expected, content) + t.Fatalf("Expected skill content with empty .github/aw directory to match content without .github/aw directory:\nexpected:\n%s\ngot:\n%s", expected, content) } if strings.Contains(content, agenticWorkflowsSkillFileListPlaceholder) { t.Fatalf("expected generated skill content to replace the file-list placeholder:\n%s", content) diff --git a/pkg/cli/upgrade_integration_test.go b/pkg/cli/upgrade_integration_test.go index 34269c61071..dc958fa8dd7 100644 --- a/pkg/cli/upgrade_integration_test.go +++ b/pkg/cli/upgrade_integration_test.go @@ -43,6 +43,12 @@ func TestInitAndUpgrade_WithEmptyAWDirectory(t *testing.T) { initCmd.Dir = setup.tempDir initOutput, initErr := initCmd.CombinedOutput() require.NoError(t, initErr, "init command should succeed with empty .github/aw directory, output: %s", string(initOutput)) + if _, err := os.Stat(filepath.Join(awDir, "actions-lock.json")); err != nil { + t.Fatalf("expected actions-lock.json to be preserved after init: %v", err) + } + if _, err := os.Stat(filepath.Join(awDir, "logs")); err != nil { + t.Fatalf("expected .github/aw/logs to be preserved after init: %v", err) + } skillPath := filepath.Join(setup.tempDir, ".github", "skills", "agentic-workflows", "SKILL.md") if _, err := os.Stat(skillPath); err != nil { @@ -72,4 +78,10 @@ Say hello. upgradeOutputStr := string(upgradeOutput) require.NoError(t, upgradeErr, "upgrade command should succeed with empty .github/aw directory, output: %s", upgradeOutputStr) assert.Contains(t, upgradeOutputStr, "Upgrade complete", "Should report upgrade complete") + if _, err := os.Stat(filepath.Join(awDir, "actions-lock.json")); err != nil { + t.Fatalf("expected actions-lock.json to be preserved after upgrade: %v", err) + } + if _, err := os.Stat(filepath.Join(awDir, "logs")); err != nil { + t.Fatalf("expected .github/aw/logs to be preserved after upgrade: %v", err) + } } From ebe1bbd6fde31c41a703ef2a48f336d7540e9a58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:34:33 +0000 Subject: [PATCH 04/10] test: tighten empty aw coverage assertions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents_test.go | 3 +++ pkg/cli/upgrade_integration_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index 83da2a4ffc6..20c7860b2d2 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -365,6 +365,9 @@ func TestBuildAgenticWorkflowsSkillContentWithoutAWDirectory(t *testing.T) { if strings.Contains(content, agenticWorkflowsSkillFileListPlaceholder) { t.Fatalf("expected generated skill content to replace the file-list placeholder:\n%s", content) } + if strings.Contains(content, "- `.github/aw/") { + t.Fatalf("expected generated skill content without .github/aw markdown file list when none exist:\n%s", content) + } } func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { diff --git a/pkg/cli/upgrade_integration_test.go b/pkg/cli/upgrade_integration_test.go index dc958fa8dd7..74a016ad82d 100644 --- a/pkg/cli/upgrade_integration_test.go +++ b/pkg/cli/upgrade_integration_test.go @@ -27,7 +27,7 @@ func TestUpgradeCommand_OnExistingRepository(t *testing.T) { assert.Contains(t, outputStr, "Upgrade complete", "Should report upgrade complete") } -func TestInitAndUpgrade_WithEmptyAWDirectory(t *testing.T) { +func TestInitAndUpgradeWithEmptyAWDirectory(t *testing.T) { setup := setupIntegrationTest(t) defer setup.cleanup() From c9d549bd2666bbeff38982b3066bd41e2729ea22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:35:42 +0000 Subject: [PATCH 05/10] test: align empty aw assertions with require patterns Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents_test.go | 17 +++++------------ pkg/cli/upgrade_integration_test.go | 20 ++++++++------------ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index 20c7860b2d2..005394b9096 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/require" ) // TestDeleteLegacyAgentFiles tests deletion of old agent files. @@ -374,22 +375,14 @@ func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { tempDir := testutil.TempDir(t, "test-*") withoutAWDir := testutil.TempDir(t, "test-*") awDir := filepath.Join(tempDir, ".github", "aw") - if err := os.MkdirAll(filepath.Join(awDir, "logs"), 0o755); err != nil { - t.Fatalf("Failed to create .github/aw/logs directory: %v", err) - } - if err := os.WriteFile(filepath.Join(awDir, "actions-lock.json"), []byte("{}"), 0o644); err != nil { - t.Fatalf("Failed to create actions-lock.json: %v", err) - } + require.NoError(t, os.MkdirAll(filepath.Join(awDir, "logs"), 0o755), "Failed to create .github/aw/logs directory") + require.NoError(t, os.WriteFile(filepath.Join(awDir, "actions-lock.json"), []byte("{}"), 0o644), "Failed to create actions-lock.json") content, err := buildAgenticWorkflowsSkillContent(tempDir) - if err != nil { - t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) - } + require.NoError(t, err, "buildAgenticWorkflowsSkillContent() returned error") expected, err := buildAgenticWorkflowsSkillContent(withoutAWDir) - if err != nil { - t.Fatalf("buildAgenticWorkflowsSkillContent() without .github/aw directory returned error: %v", err) - } + require.NoError(t, err, "buildAgenticWorkflowsSkillContent() without .github/aw directory returned error") if content != expected { t.Fatalf("Expected skill content with empty .github/aw directory to match content without .github/aw directory:\nexpected:\n%s\ngot:\n%s", expected, content) } diff --git a/pkg/cli/upgrade_integration_test.go b/pkg/cli/upgrade_integration_test.go index 74a016ad82d..ba9199335ad 100644 --- a/pkg/cli/upgrade_integration_test.go +++ b/pkg/cli/upgrade_integration_test.go @@ -43,12 +43,10 @@ func TestInitAndUpgradeWithEmptyAWDirectory(t *testing.T) { initCmd.Dir = setup.tempDir initOutput, initErr := initCmd.CombinedOutput() require.NoError(t, initErr, "init command should succeed with empty .github/aw directory, output: %s", string(initOutput)) - if _, err := os.Stat(filepath.Join(awDir, "actions-lock.json")); err != nil { - t.Fatalf("expected actions-lock.json to be preserved after init: %v", err) - } - if _, err := os.Stat(filepath.Join(awDir, "logs")); err != nil { - t.Fatalf("expected .github/aw/logs to be preserved after init: %v", err) - } + _, err := os.Stat(filepath.Join(awDir, "actions-lock.json")) + require.NoError(t, err, "expected actions-lock.json to be preserved after init") + _, err = os.Stat(filepath.Join(awDir, "logs")) + require.NoError(t, err, "expected .github/aw/logs to be preserved after init") skillPath := filepath.Join(setup.tempDir, ".github", "skills", "agentic-workflows", "SKILL.md") if _, err := os.Stat(skillPath); err != nil { @@ -78,10 +76,8 @@ Say hello. upgradeOutputStr := string(upgradeOutput) require.NoError(t, upgradeErr, "upgrade command should succeed with empty .github/aw directory, output: %s", upgradeOutputStr) assert.Contains(t, upgradeOutputStr, "Upgrade complete", "Should report upgrade complete") - if _, err := os.Stat(filepath.Join(awDir, "actions-lock.json")); err != nil { - t.Fatalf("expected actions-lock.json to be preserved after upgrade: %v", err) - } - if _, err := os.Stat(filepath.Join(awDir, "logs")); err != nil { - t.Fatalf("expected .github/aw/logs to be preserved after upgrade: %v", err) - } + _, err = os.Stat(filepath.Join(awDir, "actions-lock.json")) + require.NoError(t, err, "expected actions-lock.json to be preserved after upgrade") + _, err = os.Stat(filepath.Join(awDir, "logs")) + require.NoError(t, err, "expected .github/aw/logs to be preserved after upgrade") } From aa8b12afb594b2cbbf615c3f517b2c546c9d0cb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:36:49 +0000 Subject: [PATCH 06/10] test: polish empty aw integration and unit assertions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents_test.go | 5 ++--- pkg/cli/upgrade_integration_test.go | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index 005394b9096..f1092c61ee5 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -383,9 +384,7 @@ func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { expected, err := buildAgenticWorkflowsSkillContent(withoutAWDir) require.NoError(t, err, "buildAgenticWorkflowsSkillContent() without .github/aw directory returned error") - if content != expected { - t.Fatalf("Expected skill content with empty .github/aw directory to match content without .github/aw directory:\nexpected:\n%s\ngot:\n%s", expected, content) - } + assert.Equal(t, expected, content, "skill content with empty .github/aw should match content without .github/aw") if strings.Contains(content, agenticWorkflowsSkillFileListPlaceholder) { t.Fatalf("expected generated skill content to replace the file-list placeholder:\n%s", content) } diff --git a/pkg/cli/upgrade_integration_test.go b/pkg/cli/upgrade_integration_test.go index ba9199335ad..fe6674180a1 100644 --- a/pkg/cli/upgrade_integration_test.go +++ b/pkg/cli/upgrade_integration_test.go @@ -49,9 +49,8 @@ func TestInitAndUpgradeWithEmptyAWDirectory(t *testing.T) { require.NoError(t, err, "expected .github/aw/logs to be preserved after init") skillPath := filepath.Join(setup.tempDir, ".github", "skills", "agentic-workflows", "SKILL.md") - if _, err := os.Stat(skillPath); err != nil { - t.Fatalf("expected dispatcher skill file to exist after init: %v", err) - } + _, err = os.Stat(skillPath) + require.NoError(t, err, "expected dispatcher skill file to exist after init") workflowPath := filepath.Join(setup.tempDir, ".github", "workflows", "example.md") workflowContent := `--- From 2815e20aaf44c6100e9ce32f4eb2a5b049826a1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:15:40 +0000 Subject: [PATCH 07/10] refactor dispatcher fallback and add empty-aw warning Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents.go | 9 +++++++-- pkg/cli/copilot_agents_test.go | 4 +--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/cli/copilot_agents.go b/pkg/cli/copilot_agents.go index ce040c54680..fab489f6f4f 100644 --- a/pkg/cli/copilot_agents.go +++ b/pkg/cli/copilot_agents.go @@ -148,13 +148,17 @@ func buildAgenticWorkflowsAgentContent(gitRoot string) (string, error) { return agenticWorkflowsAgentTemplate, nil } +func minimalAgenticWorkflowsSkillContent() string { + return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1) +} + func buildAgenticWorkflowsSkillContent(gitRoot string) (string, error) { awRoot := filepath.Join(gitRoot, ".github", "aw") entries, err := os.ReadDir(awRoot) if err != nil { if os.IsNotExist(err) { // No .github/aw directory yet — emit a minimal skill without the file list. - return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1), nil + return minimalAgenticWorkflowsSkillContent(), nil } return "", fmt.Errorf("failed to read .github/aw directory for skill generation (%s): %w", awRoot, err) } @@ -171,7 +175,8 @@ func buildAgenticWorkflowsSkillContent(gitRoot string) (string, error) { if len(awFiles) == 0 { // .github/aw may exist for non-markdown artifacts (e.g. actions-lock.json, logs/). // Emit a minimal skill without an explicit file list in that case. - return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1), nil + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(".github/aw exists but contains no markdown workflow files — emitting minimal skill")) + return minimalAgenticWorkflowsSkillContent(), nil } var fileList strings.Builder diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index f1092c61ee5..9ce14580b52 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -385,9 +385,7 @@ func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { expected, err := buildAgenticWorkflowsSkillContent(withoutAWDir) require.NoError(t, err, "buildAgenticWorkflowsSkillContent() without .github/aw directory returned error") assert.Equal(t, expected, content, "skill content with empty .github/aw should match content without .github/aw") - if strings.Contains(content, agenticWorkflowsSkillFileListPlaceholder) { - t.Fatalf("expected generated skill content to replace the file-list placeholder:\n%s", content) - } + assert.NotContains(t, content, agenticWorkflowsSkillFileListPlaceholder, "expected generated skill content to replace the file-list placeholder") } func TestCheckedInAgenticWorkflowsSkillMatchesGeneratedContent(t *testing.T) { From 70d9164d1652de210734fdb532dc2e658f27731c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:37:19 +0000 Subject: [PATCH 08/10] Use remote gh-aw file list with embedded fallback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents.go | 89 ++++++++++++++----- pkg/cli/copilot_agents_test.go | 57 +++++++----- .../agentic_workflows_fallback_aw_files.json | 48 ++++++++++ 3 files changed, 150 insertions(+), 44 deletions(-) create mode 100644 pkg/cli/data/agentic_workflows_fallback_aw_files.json diff --git a/pkg/cli/copilot_agents.go b/pkg/cli/copilot_agents.go index fab489f6f4f..9b65f2482cd 100644 --- a/pkg/cli/copilot_agents.go +++ b/pkg/cli/copilot_agents.go @@ -1,8 +1,11 @@ package cli import ( + "context" _ "embed" + "encoding/json" "fmt" + "net/http" "os" "path/filepath" "sort" @@ -17,6 +20,7 @@ import ( var copilotAgentsLog = logger.New("cli:copilot_agents") const agenticWorkflowsSkillFileListPlaceholder = "{{AW_FILE_LIST}}" +const ghAWMarkdownFilesAPIURL = "https://api.github.com/repos/github/gh-aw/contents/.github/aw?ref=main" //go:embed data/agentic_workflows_agent.md var agenticWorkflowsAgentTemplate string @@ -24,6 +28,11 @@ var agenticWorkflowsAgentTemplate string //go:embed data/agentic_workflows_skill.md var agenticWorkflowsSkillTemplate string +//go:embed data/agentic_workflows_fallback_aw_files.json +var agenticWorkflowsFallbackAWFiles string + +var listAgenticWorkflowsMarkdownFiles = fetchAgenticWorkflowsMarkdownFiles + // ensureAgenticWorkflowsDispatcher ensures that .github/skills/agentic-workflows/SKILL.md // exists and contains the routing instructions loaded by the Agentic Workflows agent. func ensureAgenticWorkflowsDispatcher(verbose bool, skipInstructions bool) error { @@ -153,30 +162,16 @@ func minimalAgenticWorkflowsSkillContent() string { } func buildAgenticWorkflowsSkillContent(gitRoot string) (string, error) { - awRoot := filepath.Join(gitRoot, ".github", "aw") - entries, err := os.ReadDir(awRoot) - if err != nil { - if os.IsNotExist(err) { - // No .github/aw directory yet — emit a minimal skill without the file list. - return minimalAgenticWorkflowsSkillContent(), nil - } - return "", fmt.Errorf("failed to read .github/aw directory for skill generation (%s): %w", awRoot, err) - } + _ = gitRoot - awFiles := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { - continue - } - awFiles = append(awFiles, entry.Name()) + awFiles, err := listAgenticWorkflowsMarkdownFiles(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch .github/aw markdown file list from github/gh-aw: %v. Falling back to embedded list.", err))) + awFiles = embeddedFallbackAWMarkdownFiles() } sort.Strings(awFiles) - if len(awFiles) == 0 { - // .github/aw may exist for non-markdown artifacts (e.g. actions-lock.json, logs/). - // Emit a minimal skill without an explicit file list in that case. - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(".github/aw exists but contains no markdown workflow files — emitting minimal skill")) - return minimalAgenticWorkflowsSkillContent(), nil + return "", fmt.Errorf("no .github/aw markdown files available from remote or embedded fallback") } var fileList strings.Builder @@ -191,6 +186,60 @@ func buildAgenticWorkflowsSkillContent(gitRoot string) (string, error) { return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, fileList.String(), 1), nil } +type gitHubRepositoryContentEntry struct { + Name string `json:"name"` + Type string `json:"type"` +} + +func fetchAgenticWorkflowsMarkdownFiles(ctx context.Context) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghAWMarkdownFilesAPIURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build github API request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "gh-aw") + + client := &http.Client{Timeout: constants.DefaultHTTPClientTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("github API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github API returned %s", resp.Status) + } + + var entries []gitHubRepositoryContentEntry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return nil, fmt.Errorf("failed to decode github API response: %w", err) + } + + awFiles := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.Type != "file" || !strings.HasSuffix(entry.Name, ".md") { + continue + } + awFiles = append(awFiles, entry.Name) + } + + if len(awFiles) == 0 { + return nil, fmt.Errorf("github API returned no markdown files") + } + + sort.Strings(awFiles) + return awFiles, nil +} + +func embeddedFallbackAWMarkdownFiles() []string { + var awFiles []string + if err := json.Unmarshal([]byte(agenticWorkflowsFallbackAWFiles), &awFiles); err != nil { + return nil + } + sort.Strings(awFiles) + return awFiles +} + // cleanupOldPromptFile removes an old prompt file from .github/prompts/ if it exists func cleanupOldPromptFile(promptFileName string, verbose bool) error { gitRoot, err := gitutil.FindGitRoot() diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index 9ce14580b52..584e8e4d8fb 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -3,10 +3,12 @@ package cli import ( + "context" "os" "os/exec" "path/filepath" "runtime" + "sort" "strings" "testing" @@ -319,16 +321,7 @@ func TestCheckedInAgenticWorkflowsAgentMatchesGeneratedContent(t *testing.T) { func TestBuildAgenticWorkflowsSkillContent(t *testing.T) { tempDir := testutil.TempDir(t, "test-*") - awDir := filepath.Join(tempDir, ".github", "aw") - if err := os.MkdirAll(awDir, 0o755); err != nil { - t.Fatalf("Failed to create .github/aw directory: %v", err) - } - - for _, name := range []string{"workflow-z.md", "workflow-a.md", "ignore.txt"} { - if err := os.WriteFile(filepath.Join(awDir, name), []byte("# test"), 0o644); err != nil { - t.Fatalf("Failed to create %s: %v", name, err) - } - } + withMockAWMarkdownFileList(t, []string{"workflow-z.md", "workflow-a.md"}, nil) content, err := buildAgenticWorkflowsSkillContent(tempDir) if err != nil { @@ -344,9 +337,6 @@ func TestBuildAgenticWorkflowsSkillContent(t *testing.T) { if content != expected { t.Fatalf("Expected exact skill content:\n%s\ngot:\n%s", expected, content) } - if strings.Contains(content, "ignore.txt") { - t.Fatalf("expected non-markdown files to be excluded from generated skill content:\n%s", content) - } if strings.Contains(content, ".github/agents/agentic-workflows") { t.Fatalf("expected generated skill content to avoid agent cross-references:\n%s", content) } @@ -354,38 +344,34 @@ func TestBuildAgenticWorkflowsSkillContent(t *testing.T) { func TestBuildAgenticWorkflowsSkillContentWithoutAWDirectory(t *testing.T) { tempDir := testutil.TempDir(t, "test-*") + withMockAWMarkdownFileList(t, []string{"workflow-a.md"}, nil) content, err := buildAgenticWorkflowsSkillContent(tempDir) if err != nil { t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) } - expected := strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1) + expected := strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "- `.github/aw/workflow-a.md`\n", 1) if content != expected { t.Fatalf("Expected exact skill content without .github/aw directory:\n%s\ngot:\n%s", expected, content) } if strings.Contains(content, agenticWorkflowsSkillFileListPlaceholder) { t.Fatalf("expected generated skill content to replace the file-list placeholder:\n%s", content) } - if strings.Contains(content, "- `.github/aw/") { - t.Fatalf("expected generated skill content without .github/aw markdown file list when none exist:\n%s", content) + if !strings.Contains(content, "- `.github/aw/workflow-a.md`") { + t.Fatalf("expected generated skill content to include remotely sourced markdown files:\n%s", content) } } -func TestBuildAgenticWorkflowsSkillContentWithEmptyAWDirectory(t *testing.T) { +func TestBuildAgenticWorkflowsSkillContentFallsBackToEmbeddedFileList(t *testing.T) { tempDir := testutil.TempDir(t, "test-*") - withoutAWDir := testutil.TempDir(t, "test-*") - awDir := filepath.Join(tempDir, ".github", "aw") - require.NoError(t, os.MkdirAll(filepath.Join(awDir, "logs"), 0o755), "Failed to create .github/aw/logs directory") - require.NoError(t, os.WriteFile(filepath.Join(awDir, "actions-lock.json"), []byte("{}"), 0o644), "Failed to create actions-lock.json") + withMockAWMarkdownFileList(t, nil, assert.AnError) content, err := buildAgenticWorkflowsSkillContent(tempDir) require.NoError(t, err, "buildAgenticWorkflowsSkillContent() returned error") - expected, err := buildAgenticWorkflowsSkillContent(withoutAWDir) - require.NoError(t, err, "buildAgenticWorkflowsSkillContent() without .github/aw directory returned error") - assert.Equal(t, expected, content, "skill content with empty .github/aw should match content without .github/aw") assert.NotContains(t, content, agenticWorkflowsSkillFileListPlaceholder, "expected generated skill content to replace the file-list placeholder") + assert.Contains(t, content, "- `.github/aw/create-agentic-workflow.md`\n", "expected embedded fallback markdown file list to be used") } func TestCheckedInAgenticWorkflowsSkillMatchesGeneratedContent(t *testing.T) { @@ -395,6 +381,18 @@ func TestCheckedInAgenticWorkflowsSkillMatchesGeneratedContent(t *testing.T) { } gitRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) + awEntries, err := os.ReadDir(filepath.Join(gitRoot, ".github", "aw")) + require.NoError(t, err, "failed to read .github/aw for test fixture") + awFiles := make([]string, 0, len(awEntries)) + for _, entry := range awEntries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + awFiles = append(awFiles, entry.Name()) + } + sort.Strings(awFiles) + withMockAWMarkdownFileList(t, awFiles, nil) + expected, err := buildAgenticWorkflowsSkillContent(gitRoot) if err != nil { t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) @@ -409,3 +407,14 @@ func TestCheckedInAgenticWorkflowsSkillMatchesGeneratedContent(t *testing.T) { t.Fatalf("Checked-in skill file is out of sync with generated content\nexpected:\n%s\nactual:\n%s", expected, string(actual)) } } + +func withMockAWMarkdownFileList(t *testing.T, files []string, err error) { + t.Helper() + previous := listAgenticWorkflowsMarkdownFiles + listAgenticWorkflowsMarkdownFiles = func(context.Context) ([]string, error) { + return append([]string(nil), files...), err + } + t.Cleanup(func() { + listAgenticWorkflowsMarkdownFiles = previous + }) +} diff --git a/pkg/cli/data/agentic_workflows_fallback_aw_files.json b/pkg/cli/data/agentic_workflows_fallback_aw_files.json new file mode 100644 index 00000000000..0380769da71 --- /dev/null +++ b/pkg/cli/data/agentic_workflows_fallback_aw_files.json @@ -0,0 +1,48 @@ +[ + "agentic-chat.md", + "asciicharts.md", + "campaign.md", + "charts-trending.md", + "charts.md", + "cli-commands.md", + "context.md", + "create-agentic-workflow.md", + "create-shared-agentic-workflow.md", + "debug-agentic-workflow.md", + "dependabot.md", + "deployment-status.md", + "experiments.md", + "github-agentic-workflows.md", + "github-mcp-server.md", + "llms.md", + "memory.md", + "messages.md", + "network.md", + "patterns.md", + "pr-reviewer.md", + "report.md", + "reuse.md", + "safe-outputs-automation.md", + "safe-outputs-content.md", + "safe-outputs-management.md", + "safe-outputs-runtime.md", + "safe-outputs.md", + "serena-tool.md", + "shared-safe-jobs.md", + "skills.md", + "subagents.md", + "syntax-agentic.md", + "syntax-core.md", + "syntax-tools-imports.md", + "syntax.md", + "test-coverage.md", + "test-expression.md", + "token-optimization.md", + "triggers.md", + "update-agentic-workflow.md", + "upgrade-agentic-workflows.md", + "visual-regression.md", + "workflow-constraints.md", + "workflow-editing.md", + "workflow-patterns.md" +] From 3d70182c1f0f97f081c887959c1a2042a627b5ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:39:03 +0000 Subject: [PATCH 09/10] Refine remote AW fetch error handling and tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents.go | 13 +++++++++---- pkg/cli/copilot_agents_test.go | 12 +++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/cli/copilot_agents.go b/pkg/cli/copilot_agents.go index 9b65f2482cd..8874681a855 100644 --- a/pkg/cli/copilot_agents.go +++ b/pkg/cli/copilot_agents.go @@ -4,7 +4,9 @@ import ( "context" _ "embed" "encoding/json" + "errors" "fmt" + "net" "net/http" "os" "path/filepath" @@ -56,7 +58,7 @@ func ensureAgenticWorkflowsDispatcher(verbose bool, skipInstructions bool) error return fmt.Errorf("failed to create .github/skills/agentic-workflows directory: %w", err) } - skillContent, err := buildAgenticWorkflowsSkillContent(gitRoot) + skillContent, err := buildAgenticWorkflowsSkillContent() if err != nil { copilotAgentsLog.Printf("Failed to build dispatcher skill: %v", err) return fmt.Errorf("failed to build dispatcher skill: %w", err) @@ -161,9 +163,7 @@ func minimalAgenticWorkflowsSkillContent() string { return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1) } -func buildAgenticWorkflowsSkillContent(gitRoot string) (string, error) { - _ = gitRoot - +func buildAgenticWorkflowsSkillContent() (string, error) { awFiles, err := listAgenticWorkflowsMarkdownFiles(context.Background()) if err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch .github/aw markdown file list from github/gh-aw: %v. Falling back to embedded list.", err))) @@ -202,6 +202,10 @@ func fetchAgenticWorkflowsMarkdownFiles(ctx context.Context) ([]string, error) { client := &http.Client{Timeout: constants.DefaultHTTPClientTimeout} resp, err := client.Do(req) if err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return nil, fmt.Errorf("github API request timed out after %s: %w", constants.DefaultHTTPClientTimeout, err) + } return nil, fmt.Errorf("github API request failed: %w", err) } defer resp.Body.Close() @@ -234,6 +238,7 @@ func fetchAgenticWorkflowsMarkdownFiles(ctx context.Context) ([]string, error) { func embeddedFallbackAWMarkdownFiles() []string { var awFiles []string if err := json.Unmarshal([]byte(agenticWorkflowsFallbackAWFiles), &awFiles); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse embedded .github/aw fallback markdown file list: %v", err))) return nil } sort.Strings(awFiles) diff --git a/pkg/cli/copilot_agents_test.go b/pkg/cli/copilot_agents_test.go index 584e8e4d8fb..9f80098cdba 100644 --- a/pkg/cli/copilot_agents_test.go +++ b/pkg/cli/copilot_agents_test.go @@ -320,10 +320,9 @@ func TestCheckedInAgenticWorkflowsAgentMatchesGeneratedContent(t *testing.T) { } func TestBuildAgenticWorkflowsSkillContent(t *testing.T) { - tempDir := testutil.TempDir(t, "test-*") withMockAWMarkdownFileList(t, []string{"workflow-z.md", "workflow-a.md"}, nil) - content, err := buildAgenticWorkflowsSkillContent(tempDir) + content, err := buildAgenticWorkflowsSkillContent() if err != nil { t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) } @@ -343,10 +342,9 @@ func TestBuildAgenticWorkflowsSkillContent(t *testing.T) { } func TestBuildAgenticWorkflowsSkillContentWithoutAWDirectory(t *testing.T) { - tempDir := testutil.TempDir(t, "test-*") withMockAWMarkdownFileList(t, []string{"workflow-a.md"}, nil) - content, err := buildAgenticWorkflowsSkillContent(tempDir) + content, err := buildAgenticWorkflowsSkillContent() if err != nil { t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) } @@ -364,10 +362,9 @@ func TestBuildAgenticWorkflowsSkillContentWithoutAWDirectory(t *testing.T) { } func TestBuildAgenticWorkflowsSkillContentFallsBackToEmbeddedFileList(t *testing.T) { - tempDir := testutil.TempDir(t, "test-*") withMockAWMarkdownFileList(t, nil, assert.AnError) - content, err := buildAgenticWorkflowsSkillContent(tempDir) + content, err := buildAgenticWorkflowsSkillContent() require.NoError(t, err, "buildAgenticWorkflowsSkillContent() returned error") assert.NotContains(t, content, agenticWorkflowsSkillFileListPlaceholder, "expected generated skill content to replace the file-list placeholder") @@ -393,7 +390,7 @@ func TestCheckedInAgenticWorkflowsSkillMatchesGeneratedContent(t *testing.T) { sort.Strings(awFiles) withMockAWMarkdownFileList(t, awFiles, nil) - expected, err := buildAgenticWorkflowsSkillContent(gitRoot) + expected, err := buildAgenticWorkflowsSkillContent() if err != nil { t.Fatalf("buildAgenticWorkflowsSkillContent() returned error: %v", err) } @@ -412,6 +409,7 @@ func withMockAWMarkdownFileList(t *testing.T, files []string, err error) { t.Helper() previous := listAgenticWorkflowsMarkdownFiles listAgenticWorkflowsMarkdownFiles = func(context.Context) ([]string, error) { + // Return a copy so tests can't mutate shared backing arrays across invocations. return append([]string(nil), files...), err } t.Cleanup(func() { From ce381baba47fe6c4e470b86763c0f198c60bef7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:41:00 +0000 Subject: [PATCH 10/10] Fix golint findings in copilot agents dispatcher code Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_agents.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/cli/copilot_agents.go b/pkg/cli/copilot_agents.go index 8874681a855..1842e2fce3d 100644 --- a/pkg/cli/copilot_agents.go +++ b/pkg/cli/copilot_agents.go @@ -159,10 +159,6 @@ func buildAgenticWorkflowsAgentContent(gitRoot string) (string, error) { return agenticWorkflowsAgentTemplate, nil } -func minimalAgenticWorkflowsSkillContent() string { - return strings.Replace(agenticWorkflowsSkillTemplate, agenticWorkflowsSkillFileListPlaceholder, "", 1) -} - func buildAgenticWorkflowsSkillContent() (string, error) { awFiles, err := listAgenticWorkflowsMarkdownFiles(context.Background()) if err != nil { @@ -171,7 +167,7 @@ func buildAgenticWorkflowsSkillContent() (string, error) { } sort.Strings(awFiles) if len(awFiles) == 0 { - return "", fmt.Errorf("no .github/aw markdown files available from remote or embedded fallback") + return "", errors.New("no .github/aw markdown files available from remote or embedded fallback") } var fileList strings.Builder @@ -228,7 +224,7 @@ func fetchAgenticWorkflowsMarkdownFiles(ctx context.Context) ([]string, error) { } if len(awFiles) == 0 { - return nil, fmt.Errorf("github API returned no markdown files") + return nil, errors.New("github API returned no markdown files") } sort.Strings(awFiles)