From 4cba030e473f2d7e74b81c052104cec936b73d17 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 12 Aug 2025 20:18:30 +0000 Subject: [PATCH 1/4] Add instructions flag to compile command and update related functions - Introduced a new flag `--instructions` to the compile command to control the generation of GitHub Copilot instructions. - Updated `CompileWorkflows` function to accept a new parameter for writing instructions. - Modified `ensureCopilotInstructions` to conditionally write instructions based on the new flag. - Enhanced tests to cover the new functionality and ensure correct behavior with the instructions flag. --- cmd/gh-aw/main.go | 4 ++- pkg/cli/commands.go | 20 +++++++-------- pkg/cli/commands_test.go | 4 +-- pkg/cli/copilot_instructions_test.go | 38 ++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index cbcfcdc8f7..f2485c2ff9 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -204,11 +204,12 @@ var compileCmd = &cobra.Command{ validate, _ := cmd.Flags().GetBool("validate") autoCompile, _ := cmd.Flags().GetBool("auto-compile") watch, _ := cmd.Flags().GetBool("watch") + instructions, _ := cmd.Flags().GetBool("instructions") if err := validateEngine(engineOverride); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } - if err := cli.CompileWorkflows(file, verbose, engineOverride, validate, autoCompile, watch); err != nil { + if err := cli.CompileWorkflows(file, verbose, engineOverride, validate, autoCompile, watch, instructions); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } @@ -321,6 +322,7 @@ func init() { compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation") compileCmd.Flags().Bool("auto-compile", false, "Generate auto-compile workflow file for automatic compilation") compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically") + compileCmd.Flags().Bool("instructions", false, "Generate or update GitHub Copilot instructions file") // Add all commands to root rootCmd.AddCommand(listCmd) diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index a910a0daa7..9c756069fd 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -465,7 +465,7 @@ func AddWorkflow(workflow string, number int, verbose bool, engineOverride strin } // CompileWorkflows compiles markdown files into GitHub Actions workflow files -func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, validate bool, autoCompile bool, watch bool) error { +func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, validate bool, autoCompile bool, watch bool, writeInstructions bool) error { // Create compiler with verbose flag and AI engine override compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) @@ -504,7 +504,7 @@ func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, } // Ensure copilot instructions are present - if err := ensureCopilotInstructions(verbose); err != nil { + if err := ensureCopilotInstructions(verbose, writeInstructions); err != nil { if verbose { fmt.Printf("Warning: Failed to update copilot instructions: %v\n", err) } @@ -576,7 +576,7 @@ func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, } // Ensure copilot instructions are present - if err := ensureCopilotInstructions(verbose); err != nil { + if err := ensureCopilotInstructions(verbose, writeInstructions); err != nil { if verbose { fmt.Printf("Warning: Failed to update copilot instructions: %v\n", err) } @@ -1246,12 +1246,8 @@ func compileWorkflow(filePath string, verbose bool, engineOverride string) error } } - // Ensure copilot instructions are present - if err := ensureCopilotInstructions(verbose); err != nil { - if verbose { - fmt.Printf("Warning: Failed to update copilot instructions: %v\n", err) - } - } + // Note: Instructions are only written when explicitly requested via the compile command flag + // This helper function is used in contexts where instructions should not be automatically written return nil } @@ -1335,7 +1331,11 @@ func ensureGitAttributes() error { } // ensureCopilotInstructions ensures that .github/instructions/github-agentic-workflows.md contains the copilot instructions -func ensureCopilotInstructions(verbose bool) error { +func ensureCopilotInstructions(verbose bool, writeInstructions bool) error { + if !writeInstructions { + return nil // Skip writing instructions if flag is not set + } + gitRoot, err := findGitRoot() if err != nil { return err // Not in a git repository, skip diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 37fba8e1fa..e081ace253 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -100,7 +100,7 @@ func TestCompileWorkflows(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := CompileWorkflows(tt.markdownFile, false, "", false, false, false) + err := CompileWorkflows(tt.markdownFile, false, "", false, false, false, false) if tt.expectError && err == nil { t.Errorf("Expected error for test '%s', got nil", tt.name) @@ -179,7 +179,7 @@ func TestAllCommandsExist(t *testing.T) { }{ {func() error { return ListWorkflows(false) }, false, "ListWorkflows"}, {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error - {func() error { return CompileWorkflows("", false, "", false, false, false) }, false, "CompileWorkflows"}, // Should succeed when .github/workflows directory exists + {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, true, "CompileWorkflows"}, // Should error when no markdown files exist {func() error { return RemoveWorkflows("test") }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully diff --git a/pkg/cli/copilot_instructions_test.go b/pkg/cli/copilot_instructions_test.go index 4ecc354432..b1c93920f9 100644 --- a/pkg/cli/copilot_instructions_test.go +++ b/pkg/cli/copilot_instructions_test.go @@ -64,8 +64,8 @@ func TestEnsureCopilotInstructions(t *testing.T) { } } - // Call the function - err = ensureCopilotInstructions(false) + // Call the function with writeInstructions=true to test the functionality + err = ensureCopilotInstructions(false, true) if err != nil { t.Fatalf("ensureCopilotInstructions() returned error: %v", err) } @@ -93,6 +93,40 @@ func TestEnsureCopilotInstructions(t *testing.T) { } } +func TestEnsureCopilotInstructions_WithWriteInstructionsFalse(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Change to temp directory and initialize git repo for findGitRoot to work + oldWd, _ := os.Getwd() + defer func() { + _ = os.Chdir(oldWd) + }() + err := os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + + copilotDir := filepath.Join(tempDir, ".github", "instructions") + copilotInstructionsPath := filepath.Join(copilotDir, "github-agentic-workflows.instructions.md") + + // Call the function with writeInstructions=false + err = ensureCopilotInstructions(false, false) + if err != nil { + t.Fatalf("ensureCopilotInstructions() returned error: %v", err) + } + + // Check that file does not exist + if _, err := os.Stat(copilotInstructionsPath); !os.IsNotExist(err) { + t.Fatalf("Expected copilot instructions file to not exist when writeInstructions=false") + } +} + func min(a, b int) int { if a < b { return a From 036e4f272bf4ecec03e2a295d54cc3b1f240fae5 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 12 Aug 2025 22:10:39 +0000 Subject: [PATCH 2/4] Add GitHub actions for computing body text and managing reactions --- .github/actions/compute-text/action.yml | 67 ++++++++++++ .github/actions/reaction/action.yml | 130 ++++++++++++++++++++++++ pkg/cli/commands.go | 2 +- pkg/cli/commands_test.go | 12 +-- 4 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 .github/actions/compute-text/action.yml create mode 100644 .github/actions/reaction/action.yml diff --git a/.github/actions/compute-text/action.yml b/.github/actions/compute-text/action.yml new file mode 100644 index 0000000000..7c32af2634 --- /dev/null +++ b/.github/actions/compute-text/action.yml @@ -0,0 +1,67 @@ +name: "Compute current body text" +description: "Computes the current body text based on the GitHub event context" +outputs: + text: + description: "The computed current body text based on event type" +runs: + using: "composite" + steps: + - name: Compute current body text + id: compute-text + uses: actions/github-script@v7 + with: + script: | + let text = ''; + + // Determine current body text based on event context + switch (context.eventName) { + case 'issues': + // For issues: title + body + if (context.payload.issue) { + const title = context.payload.issue.title || ''; + const body = context.payload.issue.body || ''; + text = `${title}\n\n${body}`; + } + break; + + case 'pull_request': + // For pull requests: title + body + if (context.payload.pull_request) { + const title = context.payload.pull_request.title || ''; + const body = context.payload.pull_request.body || ''; + text = `${title}\n\n${body}`; + } + break; + + case 'issue_comment': + // For issue comments: comment body + if (context.payload.comment) { + text = context.payload.comment.body || ''; + } + break; + + case 'pull_request_review_comment': + // For PR review comments: comment body + if (context.payload.comment) { + text = context.payload.comment.body || ''; + } + break; + + case 'pull_request_review': + // For PR reviews: review body + if (context.payload.review) { + text = context.payload.review.body || ''; + } + break; + + default: + // Default: empty text + text = ''; + break; + } + + // display in logs + console.log(`text: ${text}`); + + // Set the text as output + core.setOutput('text', text); \ No newline at end of file diff --git a/.github/actions/reaction/action.yml b/.github/actions/reaction/action.yml new file mode 100644 index 0000000000..9858b7290c --- /dev/null +++ b/.github/actions/reaction/action.yml @@ -0,0 +1,130 @@ +name: "Add/Remove reaction on triggering item" +description: "Adds or removes a reaction on the issue/PR/comment that triggered the workflow" +inputs: + github-token: + description: "Token with issues/pull-requests write (GITHUB_TOKEN is fine)" + required: true + mode: + description: "'add' or 'remove'" + required: true + reaction: + description: "One of +1, -1, laugh, confused, heart, hooray, rocket, eyes" + required: false + default: "eyes" + reaction-id: + description: "Optional reaction id to remove (if known)" + required: false +outputs: + reaction-id: + description: "ID of the reaction that was added (for later removal)" +runs: + using: "composite" + steps: + - name: Compute reactions API endpoint for the triggering payload + id: ctx + shell: bash + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + owner="${GITHUB_REPOSITORY%%/*}" + repo="${GITHUB_REPOSITORY##*/}" + ev="$GITHUB_EVENT_PATH" + + case "$GITHUB_EVENT_NAME" in + issues) + number=$(jq -r '.issue.number' "$ev") + endpoint="/repos/$owner/$repo/issues/$number/reactions" + ;; + issue_comment) + cid=$(jq -r '.comment.id' "$ev") + endpoint="/repos/$owner/$repo/issues/comments/$cid/reactions" + ;; + pull_request|pull_request_target) + number=$(jq -r '.pull_request.number' "$ev") + # PRs are "issues" for the reactions endpoint + endpoint="/repos/$owner/$repo/issues/$number/reactions" + ;; + pull_request_review_comment) + cid=$(jq -r '.comment.id' "$ev") + endpoint="/repos/$owner/$repo/pulls/comments/$cid/reactions" + ;; + *) + echo "Unsupported event: $GITHUB_EVENT_NAME" >&2 + exit 1 + ;; + esac + + echo "endpoint=$endpoint" >> "$GITHUB_OUTPUT" + + - name: Add reaction + if: ${{ inputs.mode == 'add' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + ENDPOINT: ${{ steps.ctx.outputs.endpoint }} + REACTION: ${{ inputs.reaction }} + run: | + set -euo pipefail + # Create (or fetch existing) reaction + # The API returns the reaction object (201 on create, 200 if it already existed) + resp=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -X POST "$ENDPOINT" \ + -f content="$REACTION" \ + || true) + + # If a concurrent create happened, fall back to listing to find our reaction + if [ -z "${resp:-}" ] || [ "$resp" = "null" ]; then + resp=$(gh api -H "Accept: application/vnd.github+json" "$ENDPOINT") + rid=$(echo "$resp" | jq -r --arg r "$REACTION" \ + '.[] | select(.content==$r and .user.login=="github-actions[bot]") | .id' | head -n1) + else + rid=$(echo "$resp" | jq -r '.id') + if [ "$rid" = "null" ] || [ -z "$rid" ]; then + # fallback to list, just in case + list=$(gh api -H "Accept: application/vnd.github+json" "$ENDPOINT") + rid=$(echo "$list" | jq -r --arg r "$REACTION" \ + '.[] | select(.content==$r and .user.login=="github-actions[bot]") | .id' | head -n1) + fi + fi + + if [ -z "${rid:-}" ]; then + echo "Warning: could not determine reaction id; cleanup will list/filter." >&2 + fi + + echo "reaction-id=${rid:-}" >> "$GITHUB_OUTPUT" + + - name: Remove reaction + if: ${{ inputs.mode == 'remove' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + ENDPOINT: ${{ steps.ctx.outputs.endpoint }} + REACTION: ${{ inputs.reaction }} + REACTION_ID_IN: ${{ inputs.reaction-id }} + run: | + set -euo pipefail + + delete_by_id () { + local rid="$1" + if [ -n "$rid" ] && [ "$rid" != "null" ]; then + gh api -H "Accept: application/vnd.github+json" -X DELETE "/reactions/$rid" || true + fi + } + + if [ -n "$REACTION_ID_IN" ]; then + # Fast path: we were given the id from the add step + delete_by_id "$REACTION_ID_IN" + exit 0 + fi + + # Fallback: list reactions on the same subject, and delete the bot's matching reaction(s) + list=$(gh api -H "Accept: application/vnd.github+json" "$ENDPOINT" || echo "[]") + echo "$list" | jq -r --arg r "$REACTION" ' + .[] | select(.content==$r and .user.login=="github-actions[bot]") | .id + ' | while read -r rid; do + delete_by_id "$rid" + done \ No newline at end of file diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index d377823e26..f67827aed4 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -1335,7 +1335,7 @@ func ensureCopilotInstructions(verbose bool, writeInstructions bool) error { if !writeInstructions { return nil // Skip writing instructions if flag is not set } - + gitRoot, err := findGitRoot() if err != nil { return err // Not in a git repository, skip diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index e081ace253..57d9c1f7c7 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -178,13 +178,13 @@ func TestAllCommandsExist(t *testing.T) { name string }{ {func() error { return ListWorkflows(false) }, false, "ListWorkflows"}, - {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error + {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, true, "CompileWorkflows"}, // Should error when no markdown files exist - {func() error { return RemoveWorkflows("test") }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully - {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully - {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully - {func() error { return DisableWorkflows("test") }, false, "DisableWorkflows"}, // Should handle missing directory gracefully - {func() error { return RunWorkflowOnGitHub("", false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name + {func() error { return RemoveWorkflows("test") }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully + {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully + {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully + {func() error { return DisableWorkflows("test") }, false, "DisableWorkflows"}, // Should handle missing directory gracefully + {func() error { return RunWorkflowOnGitHub("", false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name } for _, test := range tests { From 0b7fab6b57338e0d4d54913d1d457e80404a27fd Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 12 Aug 2025 23:33:56 +0000 Subject: [PATCH 3/4] Fix CompileWorkflows test to expect successful compilation of existing markdown files --- pkg/cli/commands_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 704c50a5e0..a2841b1533 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -181,7 +181,7 @@ func TestAllCommandsExist(t *testing.T) { }{ {func() error { return ListWorkflows(false) }, false, "ListWorkflows"}, {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error - {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, true, "CompileWorkflows"}, // Should error when no markdown files exist + {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, false, "CompileWorkflows"}, // Should compile existing markdown files successfully {func() error { return RemoveWorkflows("test", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully From 60ef51e1e96b81e5077c54490c6207ccefec42e5 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 12 Aug 2025 23:34:35 +0000 Subject: [PATCH 4/4] Refactor TestAllCommandsExist to improve clarity and ensure accurate error handling for workflow commands --- pkg/cli/commands_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index a2841b1533..1df130b601 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -180,13 +180,13 @@ func TestAllCommandsExist(t *testing.T) { name string }{ {func() error { return ListWorkflows(false) }, false, "ListWorkflows"}, - {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error + {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, false, "CompileWorkflows"}, // Should compile existing markdown files successfully - {func() error { return RemoveWorkflows("test", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully - {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully - {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully - {func() error { return DisableWorkflows("test") }, false, "DisableWorkflows"}, // Should handle missing directory gracefully - {func() error { return RunWorkflowOnGitHub("", false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name + {func() error { return RemoveWorkflows("test", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully + {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully + {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully + {func() error { return DisableWorkflows("test") }, false, "DisableWorkflows"}, // Should handle missing directory gracefully + {func() error { return RunWorkflowOnGitHub("", false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name } for _, test := range tests {