Skip to content
67 changes: 67 additions & 0 deletions .github/actions/compute-text/action.yml
Original file line number Diff line number Diff line change
@@ -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);
130 changes: 130 additions & 0 deletions .github/actions/reaction/action.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 10 additions & 10 deletions pkg/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions pkg/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 36 additions & 2 deletions pkg/cli/copilot_instructions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down