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/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 5f465808b7..2dd26c5fd5 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -205,11 +205,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) } @@ -322,6 +323,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 flags to remove command removeCmd.Flags().Bool("keep-orphans", false, "Skip removal of orphaned include files that are no longer referenced by any workflow") diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 68b69ac594..6e4eb3e19c 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) } @@ -1265,12 +1265,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 } @@ -1354,7 +1350,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 262f84c355..1df130b601 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -102,7 +102,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) @@ -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 CompileWorkflows("", false, "", false, false, false) }, false, "CompileWorkflows"}, // Should succeed when .github/workflows directory exists - {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 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 } for _, test := range tests { 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