Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/prompts/create-agentic-workflow.prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ Analyze the user's response and map it to agentic workflows. Ask clarifying ques
- ⚠️ If you think the task requires **network access beyond localhost**, explicitly ask about configuring the top-level `network:` allowlist (ecosystems like `node`, `python`, `playwright`, or specific domains).
- 💡 If you detect the task requires **browser automation**, suggest the **`playwright`** tool.

**Scheduling Best Practices:**
- 📅 When creating a **daily scheduled workflow**, analyze existing workflows to find an unused hour. Check `.github/workflows/*.md` for existing `schedule:` triggers and suggest an available hour.
- 🚫 **Avoid weekend scheduling**: For daily workflows, use `cron: "0 <hour> * * 1-5"` to run only on weekdays (Monday-Friday) instead of `* * *` which includes weekends.
- Example daily schedule avoiding weekends: `cron: "0 14 * * 1-5"` (2 PM UTC, weekdays only)

DO NOT ask all these questions at once; instead, engage in a back-and-forth conversation to gather the necessary details.

4. **Tools & MCP Servers**
Expand Down
8 changes: 8 additions & 0 deletions pkg/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import (

"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/constants"
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/githubnext/gh-aw/pkg/workflow"
"github.com/spf13/cobra"
)

var auditLog = logger.New("cli:audit")

// NewAuditCommand creates the audit command
func NewAuditCommand() *cobra.Command {
auditCmd := &cobra.Command{
Expand Down Expand Up @@ -148,11 +151,14 @@ func isPermissionError(err error) bool {

// AuditWorkflowRun audits a single workflow run and generates a report
func AuditWorkflowRun(runInfo RunURLInfo, outputDir string, verbose bool, parse bool, jsonOutput bool) error {
auditLog.Printf("Starting audit for workflow run: runID=%d, owner=%s, repo=%s", runInfo.RunID, runInfo.Owner, runInfo.Repo)

if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Auditing workflow run %d...", runInfo.RunID)))
}

runOutputDir := filepath.Join(outputDir, fmt.Sprintf("run-%d", runInfo.RunID))
auditLog.Printf("Using output directory: %s", runOutputDir)

// Check if we have locally cached artifacts first
hasLocalCache := dirExists(runOutputDir) && !isDirEmpty(runOutputDir)
Expand Down Expand Up @@ -188,10 +194,12 @@ func AuditWorkflowRun(runInfo RunURLInfo, outputDir string, verbose bool, parse
}

// Download artifacts for the run
auditLog.Printf("Downloading artifacts for run %d", runInfo.RunID)
err := downloadRunArtifacts(runInfo.RunID, runOutputDir, verbose)
if err != nil {
// Gracefully handle cases where the run legitimately has no artifacts
if errors.Is(err, ErrNoArtifacts) {
auditLog.Printf("No artifacts found for run %d", runInfo.RunID)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("No artifacts attached to this run. Proceeding with metadata-only audit."))
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/cli/run_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ import (

"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/constants"
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/githubnext/gh-aw/pkg/parser"
)

var runLog = logger.New("cli:run_command")

// RunWorkflowOnGitHub runs an agentic workflow on GitHub Actions
func RunWorkflowOnGitHub(workflowIdOrName string, enable bool, engineOverride string, repoOverride string, autoMergePRs bool, verbose bool) error {
runLog.Printf("Starting workflow run: workflow=%s, enable=%v, engineOverride=%s, repo=%s", workflowIdOrName, enable, engineOverride, repoOverride)

if workflowIdOrName == "" {
return fmt.Errorf("workflow name or ID is required")
}
Expand All @@ -32,12 +37,14 @@ func RunWorkflowOnGitHub(workflowIdOrName string, enable bool, engineOverride st

// Validate workflow exists and is runnable
if repoOverride != "" {
runLog.Printf("Validating remote workflow: %s in repo %s", workflowIdOrName, repoOverride)
// For remote repositories, use remote validation
if err := validateRemoteWorkflow(workflowIdOrName, repoOverride, verbose); err != nil {
return fmt.Errorf("failed to validate remote workflow: %w", err)
}
// Note: We skip local runnable check for remote workflows as we assume they are properly configured
} else {
runLog.Printf("Validating local workflow: %s", workflowIdOrName)
// For local workflows, use existing local validation
workflowFile, err := resolveWorkflowFile(workflowIdOrName, verbose)
if err != nil {
Expand All @@ -53,6 +60,7 @@ func RunWorkflowOnGitHub(workflowIdOrName string, enable bool, engineOverride st
if !runnable {
return fmt.Errorf("workflow '%s' cannot be run on GitHub Actions - it must have 'workflow_dispatch' trigger", workflowIdOrName)
}
runLog.Printf("Workflow is runnable: %s", workflowFile)
}

// Handle --enable flag logic: check workflow state and enable if needed
Expand Down Expand Up @@ -198,12 +206,14 @@ func RunWorkflowOnGitHub(workflowIdOrName string, enable bool, engineOverride st
}

fmt.Printf("Successfully triggered workflow: %s\n", lockFileName)
runLog.Printf("Workflow triggered successfully: %s", lockFileName)

// Try to get the latest run for this workflow to show a direct link
// Add a delay to allow GitHub Actions time to register the new workflow run
runInfo, runErr := getLatestWorkflowRunWithRetry(lockFileName, repoOverride, verbose)
if runErr == nil && runInfo.URL != "" {
fmt.Printf("\n🔗 View workflow run: %s\n", runInfo.URL)
runLog.Printf("Workflow run URL: %s (ID: %d)", runInfo.URL, runInfo.DatabaseID)
} else if verbose && runErr != nil {
fmt.Printf("Note: Could not get workflow run URL: %v\n", runErr)
}
Expand Down
13 changes: 13 additions & 0 deletions pkg/parser/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import (
"strings"

"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/santhosh-tekuri/jsonschema/v6"
)

var schemaLog = logger.New("parser:schema")

//go:embed schemas/main_workflow_schema.json
var mainWorkflowSchema string

Expand All @@ -25,8 +28,11 @@ var mcpConfigSchema string

// ValidateMainWorkflowFrontmatterWithSchema validates main workflow frontmatter using JSON schema
func ValidateMainWorkflowFrontmatterWithSchema(frontmatter map[string]any) error {
schemaLog.Print("Validating main workflow frontmatter with schema")

// First run the standard schema validation
if err := validateWithSchema(frontmatter, mainWorkflowSchema, "main workflow file"); err != nil {
schemaLog.Printf("Schema validation failed for main workflow: %v", err)
return err
}

Expand All @@ -47,8 +53,11 @@ func ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter map[string

// ValidateIncludedFileFrontmatterWithSchema validates included file frontmatter using JSON schema
func ValidateIncludedFileFrontmatterWithSchema(frontmatter map[string]any) error {
schemaLog.Print("Validating included file frontmatter with schema")

// First run the standard schema validation
if err := validateWithSchema(frontmatter, includedFileSchema, "included file"); err != nil {
schemaLog.Printf("Schema validation failed for included file: %v", err)
return err
}

Expand Down Expand Up @@ -321,6 +330,7 @@ func validateEngineSpecificRules(frontmatter map[string]any) error {

// Handle string format engine
if engineStr, ok := engine.(string); ok {
schemaLog.Printf("Validating engine-specific rules for string engine: %s", engineStr)
// String format doesn't support permissions, so no validation needed
_ = engineStr
return nil
Expand All @@ -338,9 +348,12 @@ func validateEngineSpecificRules(frontmatter map[string]any) error {
return nil // Missing or invalid ID, but this should be caught by schema validation
}

schemaLog.Printf("Validating engine-specific rules for engine: %s", engineID)

// Check if codex engine has permissions configured
if engineID == "codex" {
if _, hasPermissions := engineMap["permissions"]; hasPermissions {
schemaLog.Printf("Codex engine has invalid permissions configuration")
return errors.New("engine permissions are not supported for codex engine. Only Claude engine supports permissions configuration")
}
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/expressions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"unicode"

"github.com/githubnext/gh-aw/pkg/constants"
"github.com/githubnext/gh-aw/pkg/logger"
)

var expressionsLog = logger.New("workflow:expressions")

// ConditionNode represents a node in a condition expression tree
type ConditionNode interface {
Render() string
Expand Down Expand Up @@ -497,20 +500,24 @@ const (
// Supports && (AND), || (OR), ! (NOT), and parentheses for grouping
// Example: "condition1 && (condition2 || !condition3)"
func ParseExpression(expression string) (ConditionNode, error) {
expressionsLog.Printf("Parsing expression: %s", expression)

if strings.TrimSpace(expression) == "" {
return nil, fmt.Errorf("empty expression")
}

parser := &ExpressionParser{}
tokens, err := parser.tokenize(expression)
if err != nil {
expressionsLog.Printf("Failed to tokenize expression: %v", err)
return nil, err
}
parser.tokens = tokens
parser.pos = 0

result, err := parser.parseOrExpression()
if err != nil {
expressionsLog.Printf("Failed to parse expression: %v", err)
return nil, err
}

Expand All @@ -519,6 +526,7 @@ func ParseExpression(expression string) (ConditionNode, error) {
return nil, fmt.Errorf("unexpected token '%s' at position %d", parser.current().value, parser.current().pos)
}

expressionsLog.Printf("Successfully parsed expression with %d tokens", len(tokens))
return result, nil
}

Expand Down Expand Up @@ -761,6 +769,8 @@ func BreakLongExpression(expression string) []string {
return []string{expression}
}

expressionsLog.Printf("Breaking long expression: length=%d", len(expression))

var lines []string
current := ""
i := 0
Expand Down
12 changes: 12 additions & 0 deletions pkg/workflow/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import (
"sort"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
"github.com/goccy/go-yaml"
)

var permissionsLog = logger.New("workflow:permissions")

// PermissionsParser provides functionality to parse and analyze GitHub Actions permissions
type PermissionsParser struct {
rawPermissions string
Expand All @@ -20,6 +23,8 @@ type PermissionsParser struct {

// NewPermissionsParser creates a new PermissionsParser instance
func NewPermissionsParser(permissionsYAML string) *PermissionsParser {
permissionsLog.Print("Creating new permissions parser")

parser := &PermissionsParser{
rawPermissions: permissionsYAML,
parsedPerms: make(map[string]string),
Expand All @@ -31,9 +36,12 @@ func NewPermissionsParser(permissionsYAML string) *PermissionsParser {
// parse parses the permissions YAML and populates the internal structures
func (p *PermissionsParser) parse() {
if p.rawPermissions == "" {
permissionsLog.Print("No permissions to parse")
return
}

permissionsLog.Printf("Parsing permissions YAML: length=%d", len(p.rawPermissions))

// Remove the "permissions:" prefix if present and get just the YAML content
yamlContent := strings.TrimSpace(p.rawPermissions)
if strings.HasPrefix(yamlContent, "permissions:") {
Expand Down Expand Up @@ -138,12 +146,16 @@ func (p *PermissionsParser) parse() {

// HasContentsReadAccess returns true if the permissions allow reading contents
func (p *PermissionsParser) HasContentsReadAccess() bool {
permissionsLog.Print("Checking contents read access")

// Handle shorthand permissions
if p.isShorthand {
switch p.shorthandValue {
case "read-all", "write-all", "read", "write":
permissionsLog.Printf("Shorthand permissions grant contents read: %s", p.shorthandValue)
return true
case "none":
permissionsLog.Print("Shorthand 'none' denies contents read")
return false
}
return false
Expand Down
Loading