diff --git a/pkg/cli/audit_cross_run_render.go b/pkg/cli/audit_cross_run_render.go index 31dbc74ddf4..bee22a1a385 100644 --- a/pkg/cli/audit_cross_run_render.go +++ b/pkg/cli/audit_cross_run_render.go @@ -6,11 +6,11 @@ import ( "os" "strconv" "strings" - "time" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/timeutil" ) var crossRunRenderLog = logger.New("cli:audit_cross_run_render") @@ -344,11 +344,7 @@ func formatRunIDs(ids []int64) string { // formatDurationNs formats a nanosecond duration as a human-readable string. func formatDurationNs(ns int64) string { - if ns <= 0 { - return "—" - } - d := time.Duration(ns) - return d.Round(time.Second).String() + return timeutil.FormatDurationNs(ns) } // safePercent returns percentage of part/total, returning 0 when total is 0. diff --git a/pkg/cli/compile_batch_operations.go b/pkg/cli/compile_batch_operations.go index aefaa496f94..ab22cfe8a29 100644 --- a/pkg/cli/compile_batch_operations.go +++ b/pkg/cli/compile_batch_operations.go @@ -37,6 +37,30 @@ import ( var compileBatchOperationsLog = logger.New("cli:compile_batch_operations") +// RunActionlintOnFiles runs actionlint on multiple lock files in a single batch. +// This is more efficient than running actionlint once per file. +func RunActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + return runActionlintOnFiles(lockFiles, verbose, strict) +} + +// RunZizmorOnFiles runs zizmor on multiple lock files in a single batch. +// This is more efficient than running zizmor once per file. +func RunZizmorOnFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + return runZizmorOnFiles(lockFiles, verbose, strict) +} + +// RunPoutineOnDirectory runs poutine security scanner once on a directory. +// Poutine scans all workflows in a directory, so it only needs to run once. +func RunPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error { + return runPoutineOnDirectory(workflowDir, verbose, strict) +} + // runBatchLockFileTool runs a batch tool on lock files with uniform error handling func runBatchLockFileTool(toolName string, lockFiles []string, verbose bool, strict bool, runner func([]string, bool, bool) error) error { if len(lockFiles) == 0 { diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index e5269ed52da..915d93ee69b 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -15,30 +15,6 @@ import ( var compileValidationLog = logger.New("cli:compile_validation") -// RunActionlintOnFiles runs actionlint on multiple lock files in a single batch -// This is more efficient than running actionlint once per file -func RunActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error { - if len(lockFiles) == 0 { - return nil - } - return runActionlintOnFiles(lockFiles, verbose, strict) -} - -// RunZizmorOnFiles runs zizmor on multiple lock files in a single batch -// This is more efficient than running zizmor once per file -func RunZizmorOnFiles(lockFiles []string, verbose bool, strict bool) error { - if len(lockFiles) == 0 { - return nil - } - return runZizmorOnFiles(lockFiles, verbose, strict) -} - -// RunPoutineOnDirectory runs poutine security scanner once on a directory -// Poutine scans all workflows in a directory, so it only needs to run once -func RunPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error { - return runPoutineOnDirectory(workflowDir, verbose, strict) -} - // CompileWorkflowWithValidation compiles a workflow with always-on YAML validation for CLI usage func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error { compileValidationLog.Printf("Compiling workflow with validation: file=%s, strict=%v, validateSHAs=%v", filePath, strict, validateActionSHAs) diff --git a/pkg/cli/mcp_error.go b/pkg/cli/mcp_error.go new file mode 100644 index 00000000000..6eb1ebc95a7 --- /dev/null +++ b/pkg/cli/mcp_error.go @@ -0,0 +1,28 @@ +package cli + +import ( + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +// newMCPError creates a jsonrpc.Error with the given code, message, and optional data. +// The data value is marshaled via mcpErrorData. +func newMCPError(code int64, msg string, data any) error { + return &jsonrpc.Error{Code: code, Message: msg, Data: mcpErrorData(data)} +} + +// mcpErrorData marshals data to JSON for use in jsonrpc.Error.Data field. +// Returns nil if marshaling fails to avoid errors in error handling. +func mcpErrorData(v any) json.RawMessage { + if v == nil { + return nil + } + data, err := json.Marshal(v) + if err != nil { + // Log the error but return nil to avoid breaking error handling + mcpLog.Printf("Failed to marshal error data: %v", err) + return nil + } + return data +} diff --git a/pkg/cli/mcp_server_helpers.go b/pkg/cli/mcp_permissions.go similarity index 78% rename from pkg/cli/mcp_server_helpers.go rename to pkg/cli/mcp_permissions.go index 03a3f3c3b81..ebbcd0a49ee 100644 --- a/pkg/cli/mcp_server_helpers.go +++ b/pkg/cli/mcp_permissions.go @@ -2,10 +2,8 @@ package cli import ( "context" - "encoding/json" "errors" "fmt" - "os" "strings" "time" @@ -14,67 +12,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) -// newMCPError creates a jsonrpc.Error with the given code, message, and optional data. -// The data value is marshaled via mcpErrorData. -func newMCPError(code int64, msg string, data any) error { - return &jsonrpc.Error{Code: code, Message: msg, Data: mcpErrorData(data)} -} - -// mcpErrorData marshals data to JSON for use in jsonrpc.Error.Data field. -// Returns nil if marshaling fails to avoid errors in error handling. -func mcpErrorData(v any) json.RawMessage { - if v == nil { - return nil - } - data, err := json.Marshal(v) - if err != nil { - // Log the error but return nil to avoid breaking error handling - mcpLog.Printf("Failed to marshal error data: %v", err) - return nil - } - return data -} - -// boolPtr returns a pointer to the given bool value, used for optional *bool fields. -func boolPtr(b bool) *bool { return &b } - -// getRepository retrieves the current repository name (owner/repo format). -// Results are cached for 1 hour to avoid repeated queries. -// Checks GITHUB_REPOSITORY environment variable first, then falls back to gh repo view. -func getRepository() (string, error) { - // Check cache first - if repo, ok := mcpCache.GetRepo(); ok { - mcpLog.Printf("Using cached repository: %s", repo) - return repo, nil - } - - // Try GITHUB_REPOSITORY environment variable first - repo := os.Getenv("GITHUB_REPOSITORY") - if repo != "" { - mcpLog.Printf("Got repository from GITHUB_REPOSITORY: %s", repo) - mcpCache.SetRepo(repo) - return repo, nil - } - - // Fall back to gh repo view - mcpLog.Print("Querying repository using gh repo view") - cmd := workflow.ExecGH("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") - output, err := cmd.Output() - if err != nil { - mcpLog.Printf("Failed to get repository: %v", err) - return "", fmt.Errorf("failed to get repository: %w", err) - } - - repo = strings.TrimSpace(string(output)) - if repo == "" { - return "", errors.New("repository not found") - } - - mcpLog.Printf("Got repository from gh repo view: %s", repo) - mcpCache.SetRepo(repo) - return repo, nil -} - // queryActorRole queries the GitHub API to determine the actor's role in the repository. // Returns the permission level (admin, maintain, write, triage, read) or an error. // Results are cached for 1 hour to avoid excessive API calls. diff --git a/pkg/cli/mcp_repository.go b/pkg/cli/mcp_repository.go new file mode 100644 index 00000000000..79812ceb99e --- /dev/null +++ b/pkg/cli/mcp_repository.go @@ -0,0 +1,47 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/github/gh-aw/pkg/workflow" +) + +// getRepository retrieves the current repository name (owner/repo format). +// Results are cached for 1 hour to avoid repeated queries. +// Checks GITHUB_REPOSITORY environment variable first, then falls back to gh repo view. +func getRepository() (string, error) { + // Check cache first + if repo, ok := mcpCache.GetRepo(); ok { + mcpLog.Printf("Using cached repository: %s", repo) + return repo, nil + } + + // Try GITHUB_REPOSITORY environment variable first + repo := os.Getenv("GITHUB_REPOSITORY") + if repo != "" { + mcpLog.Printf("Got repository from GITHUB_REPOSITORY: %s", repo) + mcpCache.SetRepo(repo) + return repo, nil + } + + // Fall back to gh repo view + mcpLog.Print("Querying repository using gh repo view") + cmd := workflow.ExecGH("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") + output, err := cmd.Output() + if err != nil { + mcpLog.Printf("Failed to get repository: %v", err) + return "", fmt.Errorf("failed to get repository: %w", err) + } + + repo = strings.TrimSpace(string(output)) + if repo == "" { + return "", errors.New("repository not found") + } + + mcpLog.Printf("Got repository from gh repo view: %s", repo) + mcpCache.SetRepo(repo) + return repo, nil +} diff --git a/pkg/cli/token_usage.go b/pkg/cli/token_usage.go index f79ec64b96a..841a91f44ef 100644 --- a/pkg/cli/token_usage.go +++ b/pkg/cli/token_usage.go @@ -4,13 +4,13 @@ import ( "bufio" "encoding/json" "fmt" - "math" "os" "path/filepath" "sort" "strings" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/timeutil" ) var tokenUsageLog = logger.New("cli:token_usage") @@ -236,18 +236,10 @@ func (s *TokenUsageSummary) AvgDurationMs() int { return s.TotalDurationMs / s.TotalRequests } -// FormatDurationMs formats milliseconds as a human-readable string +// FormatDurationMs formats milliseconds as a human-readable string. +// Deprecated: Use timeutil.FormatDurationMs instead. func FormatDurationMs(ms int) string { - if ms < 1000 { - return fmt.Sprintf("%dms", ms) - } - seconds := float64(ms) / 1000.0 - if seconds < 60 { - return fmt.Sprintf("%.1fs", seconds) - } - minutes := int(seconds) / 60 - secs := math.Mod(seconds, 60) - return fmt.Sprintf("%dm%.0fs", minutes, secs) + return timeutil.FormatDurationMs(ms) } // ModelRows returns the by-model data as sorted rows for console rendering diff --git a/pkg/cli/util.go b/pkg/cli/util.go new file mode 100644 index 00000000000..4635d2287d5 --- /dev/null +++ b/pkg/cli/util.go @@ -0,0 +1,4 @@ +package cli + +// boolPtr returns a pointer to the given bool value, used for optional *bool fields. +func boolPtr(b bool) *bool { return &b } diff --git a/pkg/timeutil/format.go b/pkg/timeutil/format.go index f8f1314f9e7..00b88194ecd 100644 --- a/pkg/timeutil/format.go +++ b/pkg/timeutil/format.go @@ -2,6 +2,7 @@ package timeutil import ( "fmt" + "math" "time" ) @@ -25,3 +26,28 @@ func FormatDuration(d time.Duration) string { } return fmt.Sprintf("%.1fh", d.Hours()) } + +// FormatDurationMs formats a duration given in milliseconds as a human-readable string. +// Examples: 500 -> "500ms", 1500 -> "1.5s", 90000 -> "1m30s" +func FormatDurationMs(ms int) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + seconds := float64(ms) / 1000.0 + if seconds < 60 { + return fmt.Sprintf("%.1fs", seconds) + } + minutes := int(seconds) / 60 + secs := math.Mod(seconds, 60) + return fmt.Sprintf("%dm%.0fs", minutes, secs) +} + +// FormatDurationNs formats a duration given in nanoseconds as a human-readable string. +// Returns "—" for zero or negative values. Uses Go's standard duration rounding to seconds. +func FormatDurationNs(ns int64) string { + if ns <= 0 { + return "—" + } + d := time.Duration(ns) + return d.Round(time.Second).String() +} diff --git a/pkg/workflow/compiler_yaml_helpers.go b/pkg/workflow/compiler_yaml_helpers.go index 5a7c6c2239a..541cd841d04 100644 --- a/pkg/workflow/compiler_yaml_helpers.go +++ b/pkg/workflow/compiler_yaml_helpers.go @@ -1,15 +1,10 @@ package workflow import ( - "fmt" "path/filepath" - "regexp" - "slices" "strings" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/goccy/go-yaml" ) var compilerYamlHelpersLog = logger.New("workflow:compiler_yaml_helpers") @@ -45,395 +40,6 @@ func GetWorkflowIDFromPath(markdownPath string) string { return strings.TrimSuffix(filepath.Base(markdownPath), ".md") } -// ConvertStepToYAML converts a step map to YAML string with proper indentation. -// This is a shared utility function used by all engines and the compiler. -func ConvertStepToYAML(stepMap map[string]any) (string, error) { - // Use OrderMapFields to get ordered MapSlice - orderedStep := OrderMapFields(stepMap, constants.PriorityStepFields) - - // Wrap in array for step list format and marshal with proper options - yamlBytes, err := yaml.MarshalWithOptions([]yaml.MapSlice{orderedStep}, DefaultMarshalOptions...) - if err != nil { - return "", fmt.Errorf("failed to marshal step to YAML: %w", err) - } - - // Convert to string and adjust base indentation to match GitHub Actions format - yamlStr := string(yamlBytes) - - // Post-process to move version comments outside of quoted uses values - // This handles cases like: uses: "slug@sha # v1" -> uses: slug@sha # v1 - yamlStr = unquoteUsesWithComments(yamlStr) - - // Add 6 spaces to the beginning of each line to match GitHub Actions step indentation - lines := strings.Split(strings.TrimSpace(yamlStr), "\n") - var result strings.Builder - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - result.WriteString("\n") - } else { - result.WriteString(" " + line + "\n") - } - } - - return result.String(), nil -} - -// unquoteUsesWithComments removes quotes from uses values that contain version comments. -// Transforms: uses: "slug@sha # v1" -> uses: slug@sha # v1 -// This is needed because the YAML marshaller quotes strings containing #, but GitHub Actions -// expects unquoted uses values with inline comments. -func unquoteUsesWithComments(yamlStr string) string { - lines := strings.Split(yamlStr, "\n") - for i, line := range lines { - // Look for uses: followed by a quoted string containing a # comment - // This handles various indentation levels and formats - trimmed := strings.TrimSpace(line) - - // Check if line contains uses: with a quoted value - if !strings.Contains(trimmed, "uses: \"") { - continue - } - - // Check if the quoted value contains a version comment - if !strings.Contains(trimmed, " # ") { - continue - } - - // Find the position of uses: " in the original line - usesIdx := strings.Index(line, "uses: \"") - if usesIdx == -1 { - continue - } - - // Extract the part before uses: (indentation) - prefix := line[:usesIdx] - - // Find the opening and closing quotes - quoteStart := usesIdx + 7 // len("uses: \"") - quoteEnd := strings.Index(line[quoteStart:], "\"") - if quoteEnd == -1 { - continue - } - quoteEnd += quoteStart - - // Extract the quoted content - quotedContent := line[quoteStart:quoteEnd] - - // Extract any content after the closing quote - suffix := line[quoteEnd+1:] - - // Reconstruct the line without quotes - lines[i] = prefix + "uses: " + quotedContent + suffix - } - return strings.Join(lines, "\n") -} - -// getInstallationVersion returns the version that will be installed for the given engine. -// This matches the logic in BuildStandardNpmEngineInstallSteps. -func getInstallationVersion(data *WorkflowData, engine CodingAgentEngine) string { - engineID := engine.GetID() - compilerYamlHelpersLog.Printf("Getting installation version for engine: %s", engineID) - - // If version is specified in engine config, use it - if data.EngineConfig != nil && data.EngineConfig.Version != "" { - compilerYamlHelpersLog.Printf("Using engine config version: %s", data.EngineConfig.Version) - return data.EngineConfig.Version - } - - // Otherwise, use the default version for the engine - switch engineID { - case "copilot": - return string(constants.DefaultCopilotVersion) - case "claude": - return string(constants.DefaultClaudeCodeVersion) - case "codex": - return string(constants.DefaultCodexVersion) - default: - // Custom or unknown engines don't have a default version - compilerYamlHelpersLog.Printf("No default version for custom engine: %s", engineID) - return "" - } -} - -// getDefaultAgentModel returns the model display value to use when no explicit model is configured. -// Returns "auto" for known engines whose model is dynamically determined by the AI provider -// (i.e. the provider chooses the model automatically), or empty string for custom/unknown engines. -func getDefaultAgentModel(engineID string) string { - switch engineID { - case "copilot", "claude", "codex", "gemini": - return "auto" - default: - return "" - } -} - -// versionToGitRef converts a compiler version string to a valid git ref for use -// in actions/checkout ref: fields. -// -// The version string is typically produced by `git describe --tags --always --dirty` -// and may contain suffixes that are not valid git refs. This function normalises it: -// - "dev" or empty → "" (no ref, checkout will use the repository default branch) -// - "v1.2.3-60-ge284d1e" → "e284d1e" (extract SHA from git-describe output) -// - "v1.2.3-60-ge284d1e-dirty" → "e284d1e" (strip -dirty, then extract SHA) -// - "v1.2.3-dirty" → "v1.2.3" (strip -dirty, valid tag) -// - "v1.2.3" → "v1.2.3" (valid tag, used as-is) -// - "e284d1e" → "e284d1e" (plain short SHA, used as-is) -func versionToGitRef(version string) string { - compilerYamlHelpersLog.Printf("Converting version to git ref: %s", version) - if version == "" || version == "dev" { - return "" - } - // Strip optional -dirty suffix (appended by `git describe --dirty`) - clean := strings.TrimSuffix(version, "-dirty") - // If the version looks like `git describe` output with -N-gSHA, extract the SHA. - // Pattern: anything ending with --g - shaRe := regexp.MustCompile(`-\d+-g([0-9a-f]+)$`) - if m := shaRe.FindStringSubmatch(clean); m != nil { - compilerYamlHelpersLog.Printf("Extracted SHA from git-describe version: %s -> %s", version, m[1]) - return m[1] - } - compilerYamlHelpersLog.Printf("Using version as git ref: %s -> %s", version, clean) - return clean -} - -// generateCheckoutActionsFolder generates the checkout step for the actions folder -// when running in dev mode and not using the action-tag feature. This is used to -// checkout the local actions before running the setup action. -// -// Returns a slice of strings that can be appended to a steps array, where each -// string represents a line of YAML for the checkout step. Returns nil if: -// - Not in dev or script mode -// - action-tag feature is specified (uses remote actions instead) -func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string { - compilerYamlHelpersLog.Printf("Generating checkout actions folder step: actionMode=%s, version=%s", c.actionMode, c.version) - // Check if action-tag is specified - if so, we're using remote actions - if data != nil && data.Features != nil { - if actionTagVal, exists := data.Features["action-tag"]; exists { - if actionTagStr, ok := actionTagVal.(string); ok && actionTagStr != "" { - // action-tag is set, use remote actions - no checkout needed - return nil - } - } - } - - // Derive a clean git ref from the compiler's version string. - // Required so that cross-repo callers checkout github/gh-aw at the correct - // commit rather than the default branch, which may be missing JS modules - // that were added after the latest tag. - ref := versionToGitRef(c.version) - - // Script mode: checkout .github folder from github/gh-aw to /tmp/gh-aw/actions-source/ - if c.actionMode.IsScript() { - lines := []string{ - " - name: Checkout actions folder\n", - fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), - " with:\n", - " repository: github/gh-aw\n", - } - if ref != "" { - lines = append(lines, fmt.Sprintf(" ref: %s\n", ref)) - } - lines = append(lines, - " sparse-checkout: |\n", - " actions\n", - " path: /tmp/gh-aw/actions-source\n", - " fetch-depth: 1\n", - " persist-credentials: false\n", - ) - return lines - } - - // Dev mode: checkout actions folder from github/gh-aw so that cross-repo - // callers (e.g. event-driven relays) can find the actions/ directory. - // Without repository: the runner defaults to the caller's repo, which has - // no actions/ directory, causing Setup Scripts to fail immediately. - if c.actionMode.IsDev() { - lines := []string{ - " - name: Checkout actions folder\n", - fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), - " with:\n", - " repository: github/gh-aw\n", - " sparse-checkout: |\n", - " actions\n", - " persist-credentials: false\n", - } - return lines - } - - // Release mode or other modes: no checkout needed - return nil -} - -// generateRestoreActionsSetupStep generates a single "Restore actions folder" step that -// re-checks out only the actions/setup subfolder from github/gh-aw. This is used in dev mode -// after a job step has checked out a different repository (or a different git branch) and -// replaced the workspace content, removing the actions/setup directory. Without restoring it, -// the GitHub Actions runner's post-step for "Setup Scripts" would fail with -// "Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under .../actions/setup". -// -// The step is guarded by `if: always()` so it runs even if prior steps fail, ensuring -// the post-step cleanup can always complete. -// -// Returns the YAML for the step as a single string (for inclusion in a []string steps slice). -func (c *Compiler) generateRestoreActionsSetupStep() string { - var step strings.Builder - step.WriteString(" - name: Restore actions folder\n") - step.WriteString(" if: always()\n") - fmt.Fprintf(&step, " uses: %s\n", GetActionPin("actions/checkout")) - step.WriteString(" with:\n") - step.WriteString(" repository: github/gh-aw\n") - step.WriteString(" sparse-checkout: |\n") - step.WriteString(" actions/setup\n") - step.WriteString(" sparse-checkout-cone-mode: true\n") - step.WriteString(" persist-credentials: false\n") - return step.String() -} - -// generateCheckoutGitHubFolder generates the checkout step for the .github and .agents folders -// for the agent job. This ensures workflows have access to workflow configurations, -// runtime imports, and skills even when they don't do a full repository checkout. -// -// This checkout works in all modes (dev, script, release) and uses shallow clone -// for minimal overhead. It should only be called in the main agent job. -// -// Returns a slice of strings that can be appended to a steps array, where each -// string represents a line of YAML for the checkout step. Returns nil if: - // generateGitHubScriptWithRequire is implemented in compiler_github_actions_steps.go // generateInlineGitHubScriptStep is implemented in compiler_github_actions_steps.go - -// generateSetupStep generates the setup step based on the action mode. -// In script mode, it runs the setup.sh script directly from the checked-out source. -// In other modes (dev/release), it uses the setup action. -// -// Parameters: -// - setupActionRef: The action reference for setup action (e.g., "./actions/setup" or "github/gh-aw/actions/setup@sha") -// - destination: The destination path where files should be copied (e.g., SetupActionDestination) -// - enableCustomTokens: Whether to enable custom-token support (installs @actions/github so handler_auth.cjs can create per-handler Octokit clients) -// -// Returns a slice of strings representing the YAML lines for the setup step. -func (c *Compiler) generateSetupStep(setupActionRef string, destination string, enableCustomTokens bool) []string { - // Script mode: run the setup.sh script directly - if c.actionMode.IsScript() { - lines := []string{ - " - name: Setup Scripts\n", - " run: |\n", - " bash /tmp/gh-aw/actions-source/actions/setup/setup.sh\n", - " env:\n", - fmt.Sprintf(" INPUT_DESTINATION: %s\n", destination), - } - if enableCustomTokens { - lines = append(lines, " INPUT_SAFE_OUTPUT_CUSTOM_TOKENS: 'true'\n") - } - return lines - } - - // Dev/Release mode: use the setup action - lines := []string{ - " - name: Setup Scripts\n", - fmt.Sprintf(" uses: %s\n", setupActionRef), - " with:\n", - fmt.Sprintf(" destination: %s\n", destination), - } - if enableCustomTokens { - lines = append(lines, " safe-output-custom-tokens: 'true'\n") - } - return lines -} - -// generateSetRuntimePathsStep generates a step that sets RUNNER_TEMP-based env vars -// via $GITHUB_OUTPUT. These cannot be set in job-level env: because the runner context -// is not available there (only in step-level env: and run: blocks). -// The step ID "set-runtime-paths" is referenced by downstream steps that consume these outputs. -func (c *Compiler) generateSetRuntimePathsStep() []string { - return []string{ - " - name: Set runtime paths\n", - " id: set-runtime-paths\n", - " run: |\n", - " echo \"GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl\" >> \"$GITHUB_OUTPUT\"\n", - " echo \"GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json\" >> \"$GITHUB_OUTPUT\"\n", - " echo \"GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json\" >> \"$GITHUB_OUTPUT\"\n", - } -} - -// renderStepFromMap renders a GitHub Actions step from a map to YAML -func (c *Compiler) renderStepFromMap(yaml *strings.Builder, step map[string]any, data *WorkflowData, indent string) { - // Start the step with a dash - yaml.WriteString(indent + "- ") - - // Track if we've written the first line - firstField := true - - // Order of fields to write (matches GitHub Actions convention) - fieldOrder := []string{"name", "id", "if", "uses", "with", "run", "env", "working-directory", "continue-on-error", "timeout-minutes", "shell"} - - for _, field := range fieldOrder { - if value, exists := step[field]; exists { - // Add proper indentation for non-first fields - if !firstField { - yaml.WriteString(indent + " ") - } - firstField = false - - // Render the field based on its type - switch v := value.(type) { - case string: - // Handle multi-line strings (especially for 'run' field) - if field == "run" && strings.Contains(v, "\n") { - fmt.Fprintf(yaml, "%s: |\n", field) - lines := strings.SplitSeq(v, "\n") - for line := range lines { - fmt.Fprintf(yaml, "%s %s\n", indent, line) - } - } else { - fmt.Fprintf(yaml, "%s: %s\n", field, v) - } - case map[string]any: - // For complex fields like "with" or "env" - fmt.Fprintf(yaml, "%s:\n", field) - for key, val := range v { - fmt.Fprintf(yaml, "%s %s: %v\n", indent, key, val) - } - default: - fmt.Fprintf(yaml, "%s: %v\n", field, v) - } - } - } - - // Add any remaining fields not in the predefined order - for field, value := range step { - // Skip fields we've already processed - skip := slices.Contains(fieldOrder, field) - if skip { - continue - } - - if !firstField { - yaml.WriteString(indent + " ") - } - firstField = false - - switch v := value.(type) { - case string: - // Handle multi-line strings - if strings.Contains(v, "\n") { - fmt.Fprintf(yaml, "%s: |\n", field) - lines := strings.SplitSeq(v, "\n") - for line := range lines { - fmt.Fprintf(yaml, "%s %s\n", indent, line) - } - } else { - fmt.Fprintf(yaml, "%s: %s\n", field, v) - } - case map[string]any: - fmt.Fprintf(yaml, "%s:\n", field) - for key, val := range v { - fmt.Fprintf(yaml, "%s %s: %v\n", indent, key, val) - } - default: - fmt.Fprintf(yaml, "%s: %v\n", field, v) - } - } -} diff --git a/pkg/workflow/compiler_yaml_lookups.go b/pkg/workflow/compiler_yaml_lookups.go new file mode 100644 index 00000000000..0c9962f0ae2 --- /dev/null +++ b/pkg/workflow/compiler_yaml_lookups.go @@ -0,0 +1,79 @@ +package workflow + +import ( + "regexp" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" +) + +var compilerYamlLookupsLog = logger.New("workflow:compiler_yaml_lookups") + +// getInstallationVersion returns the version that will be installed for the given engine. +// This matches the logic in BuildStandardNpmEngineInstallSteps. +func getInstallationVersion(data *WorkflowData, engine CodingAgentEngine) string { + engineID := engine.GetID() + compilerYamlLookupsLog.Printf("Getting installation version for engine: %s", engineID) + + // If version is specified in engine config, use it + if data.EngineConfig != nil && data.EngineConfig.Version != "" { + compilerYamlLookupsLog.Printf("Using engine config version: %s", data.EngineConfig.Version) + return data.EngineConfig.Version + } + + // Otherwise, use the default version for the engine + switch engineID { + case "copilot": + return string(constants.DefaultCopilotVersion) + case "claude": + return string(constants.DefaultClaudeCodeVersion) + case "codex": + return string(constants.DefaultCodexVersion) + default: + // Custom or unknown engines don't have a default version + compilerYamlLookupsLog.Printf("No default version for custom engine: %s", engineID) + return "" + } +} + +// getDefaultAgentModel returns the model display value to use when no explicit model is configured. +// Returns "auto" for known engines whose model is dynamically determined by the AI provider +// (i.e. the provider chooses the model automatically), or empty string for custom/unknown engines. +func getDefaultAgentModel(engineID string) string { + switch engineID { + case "copilot", "claude", "codex", "gemini": + return "auto" + default: + return "" + } +} + +// versionToGitRef converts a compiler version string to a valid git ref for use +// in actions/checkout ref: fields. +// +// The version string is typically produced by `git describe --tags --always --dirty` +// and may contain suffixes that are not valid git refs. This function normalises it: +// - "dev" or empty → "" (no ref, checkout will use the repository default branch) +// - "v1.2.3-60-ge284d1e" → "e284d1e" (extract SHA from git-describe output) +// - "v1.2.3-60-ge284d1e-dirty" → "e284d1e" (strip -dirty, then extract SHA) +// - "v1.2.3-dirty" → "v1.2.3" (strip -dirty, valid tag) +// - "v1.2.3" → "v1.2.3" (valid tag, used as-is) +// - "e284d1e" → "e284d1e" (plain short SHA, used as-is) +func versionToGitRef(version string) string { + compilerYamlLookupsLog.Printf("Converting version to git ref: %s", version) + if version == "" || version == "dev" { + return "" + } + // Strip optional -dirty suffix (appended by `git describe --dirty`) + clean := strings.TrimSuffix(version, "-dirty") + // If the version looks like `git describe` output with -N-gSHA, extract the SHA. + // Pattern: anything ending with --g + shaRe := regexp.MustCompile(`-\d+-g([0-9a-f]+)$`) + if m := shaRe.FindStringSubmatch(clean); m != nil { + compilerYamlLookupsLog.Printf("Extracted SHA from git-describe version: %s -> %s", version, m[1]) + return m[1] + } + compilerYamlLookupsLog.Printf("Using version as git ref: %s -> %s", version, clean) + return clean +} diff --git a/pkg/workflow/compiler_yaml_step_conversion.go b/pkg/workflow/compiler_yaml_step_conversion.go new file mode 100644 index 00000000000..e075417876f --- /dev/null +++ b/pkg/workflow/compiler_yaml_step_conversion.go @@ -0,0 +1,174 @@ +package workflow + +import ( + "fmt" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/goccy/go-yaml" +) + +// ConvertStepToYAML converts a step map to YAML string with proper indentation. +// This is a shared utility function used by all engines and the compiler. +func ConvertStepToYAML(stepMap map[string]any) (string, error) { + // Use OrderMapFields to get ordered MapSlice + orderedStep := OrderMapFields(stepMap, constants.PriorityStepFields) + + // Wrap in array for step list format and marshal with proper options + yamlBytes, err := yaml.MarshalWithOptions([]yaml.MapSlice{orderedStep}, DefaultMarshalOptions...) + if err != nil { + return "", fmt.Errorf("failed to marshal step to YAML: %w", err) + } + + // Convert to string and adjust base indentation to match GitHub Actions format + yamlStr := string(yamlBytes) + + // Post-process to move version comments outside of quoted uses values + // This handles cases like: uses: "slug@sha # v1" -> uses: slug@sha # v1 + yamlStr = unquoteUsesWithComments(yamlStr) + + // Add 6 spaces to the beginning of each line to match GitHub Actions step indentation + lines := strings.Split(strings.TrimSpace(yamlStr), "\n") + var result strings.Builder + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + result.WriteString("\n") + } else { + result.WriteString(" " + line + "\n") + } + } + + return result.String(), nil +} + +// unquoteUsesWithComments removes quotes from uses values that contain version comments. +// Transforms: uses: "slug@sha # v1" -> uses: slug@sha # v1 +// This is needed because the YAML marshaller quotes strings containing #, but GitHub Actions +// expects unquoted uses values with inline comments. +func unquoteUsesWithComments(yamlStr string) string { + lines := strings.Split(yamlStr, "\n") + for i, line := range lines { + // Look for uses: followed by a quoted string containing a # comment + // This handles various indentation levels and formats + trimmed := strings.TrimSpace(line) + + // Check if line contains uses: with a quoted value + if !strings.Contains(trimmed, "uses: \"") { + continue + } + + // Check if the quoted value contains a version comment + if !strings.Contains(trimmed, " # ") { + continue + } + + // Find the position of uses: " in the original line + usesIdx := strings.Index(line, "uses: \"") + if usesIdx == -1 { + continue + } + + // Extract the part before uses: (indentation) + prefix := line[:usesIdx] + + // Find the opening and closing quotes + quoteStart := usesIdx + 7 // len("uses: \"") + quoteEnd := strings.Index(line[quoteStart:], "\"") + if quoteEnd == -1 { + continue + } + quoteEnd += quoteStart + + // Extract the quoted content + quotedContent := line[quoteStart:quoteEnd] + + // Extract any content after the closing quote + suffix := line[quoteEnd+1:] + + // Reconstruct the line without quotes + lines[i] = prefix + "uses: " + quotedContent + suffix + } + return strings.Join(lines, "\n") +} + +// renderStepFromMap renders a GitHub Actions step from a map to YAML +func (c *Compiler) renderStepFromMap(yaml *strings.Builder, step map[string]any, data *WorkflowData, indent string) { + // Start the step with a dash + yaml.WriteString(indent + "- ") + + // Track if we've written the first line + firstField := true + + // Order of fields to write (matches GitHub Actions convention) + fieldOrder := []string{"name", "id", "if", "uses", "with", "run", "env", "working-directory", "continue-on-error", "timeout-minutes", "shell"} + + for _, field := range fieldOrder { + if value, exists := step[field]; exists { + // Add proper indentation for non-first fields + if !firstField { + yaml.WriteString(indent + " ") + } + firstField = false + + // Render the field based on its type + switch v := value.(type) { + case string: + // Handle multi-line strings (especially for 'run' field) + if field == "run" && strings.Contains(v, "\n") { + fmt.Fprintf(yaml, "%s: |\n", field) + lines := strings.SplitSeq(v, "\n") + for line := range lines { + fmt.Fprintf(yaml, "%s %s\n", indent, line) + } + } else { + fmt.Fprintf(yaml, "%s: %s\n", field, v) + } + case map[string]any: + // For complex fields like "with" or "env" + fmt.Fprintf(yaml, "%s:\n", field) + for key, val := range v { + fmt.Fprintf(yaml, "%s %s: %v\n", indent, key, val) + } + default: + fmt.Fprintf(yaml, "%s: %v\n", field, v) + } + } + } + + // Add any remaining fields not in the predefined order + for field, value := range step { + // Skip fields we've already processed + skip := slices.Contains(fieldOrder, field) + if skip { + continue + } + + if !firstField { + yaml.WriteString(indent + " ") + } + firstField = false + + switch v := value.(type) { + case string: + // Handle multi-line strings + if strings.Contains(v, "\n") { + fmt.Fprintf(yaml, "%s: |\n", field) + lines := strings.SplitSeq(v, "\n") + for line := range lines { + fmt.Fprintf(yaml, "%s %s\n", indent, line) + } + } else { + fmt.Fprintf(yaml, "%s: %s\n", field, v) + } + case map[string]any: + fmt.Fprintf(yaml, "%s:\n", field) + for key, val := range v { + fmt.Fprintf(yaml, "%s %s: %v\n", indent, key, val) + } + default: + fmt.Fprintf(yaml, "%s: %v\n", field, v) + } + } +} diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go new file mode 100644 index 00000000000..5774a8120b2 --- /dev/null +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -0,0 +1,157 @@ +package workflow + +import ( + "fmt" + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var compilerYamlStepGenerationLog = logger.New("workflow:compiler_yaml_step_generation") + +// generateCheckoutActionsFolder generates the checkout step for the actions folder +// when running in dev mode and not using the action-tag feature. This is used to +// checkout the local actions before running the setup action. +// +// Returns a slice of strings that can be appended to a steps array, where each +// string represents a line of YAML for the checkout step. Returns nil if: +// - Not in dev or script mode +// - action-tag feature is specified (uses remote actions instead) +func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string { + compilerYamlStepGenerationLog.Printf("Generating checkout actions folder step: actionMode=%s, version=%s", c.actionMode, c.version) + // Check if action-tag is specified - if so, we're using remote actions + if data != nil && data.Features != nil { + if actionTagVal, exists := data.Features["action-tag"]; exists { + if actionTagStr, ok := actionTagVal.(string); ok && actionTagStr != "" { + // action-tag is set, use remote actions - no checkout needed + return nil + } + } + } + + // Derive a clean git ref from the compiler's version string. + // Required so that cross-repo callers checkout github/gh-aw at the correct + // commit rather than the default branch, which may be missing JS modules + // that were added after the latest tag. + ref := versionToGitRef(c.version) + + // Script mode: checkout .github folder from github/gh-aw to /tmp/gh-aw/actions-source/ + if c.actionMode.IsScript() { + lines := []string{ + " - name: Checkout actions folder\n", + fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), + " with:\n", + " repository: github/gh-aw\n", + } + if ref != "" { + lines = append(lines, fmt.Sprintf(" ref: %s\n", ref)) + } + lines = append(lines, + " sparse-checkout: |\n", + " actions\n", + " path: /tmp/gh-aw/actions-source\n", + " fetch-depth: 1\n", + " persist-credentials: false\n", + ) + return lines + } + + // Dev mode: checkout actions folder from github/gh-aw so that cross-repo + // callers (e.g. event-driven relays) can find the actions/ directory. + // Without repository: the runner defaults to the caller's repo, which has + // no actions/ directory, causing Setup Scripts to fail immediately. + if c.actionMode.IsDev() { + lines := []string{ + " - name: Checkout actions folder\n", + fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), + " with:\n", + " repository: github/gh-aw\n", + " sparse-checkout: |\n", + " actions\n", + " persist-credentials: false\n", + } + return lines + } + + // Release mode or other modes: no checkout needed + return nil +} + +// generateRestoreActionsSetupStep generates a single "Restore actions folder" step that +// re-checks out only the actions/setup subfolder from github/gh-aw. This is used in dev mode +// after a job step has checked out a different repository (or a different git branch) and +// replaced the workspace content, removing the actions/setup directory. Without restoring it, +// the GitHub Actions runner's post-step for "Setup Scripts" would fail with +// "Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under .../actions/setup". +// +// The step is guarded by `if: always()` so it runs even if prior steps fail, ensuring +// the post-step cleanup can always complete. +// +// Returns the YAML for the step as a single string (for inclusion in a []string steps slice). +func (c *Compiler) generateRestoreActionsSetupStep() string { + var step strings.Builder + step.WriteString(" - name: Restore actions folder\n") + step.WriteString(" if: always()\n") + fmt.Fprintf(&step, " uses: %s\n", GetActionPin("actions/checkout")) + step.WriteString(" with:\n") + step.WriteString(" repository: github/gh-aw\n") + step.WriteString(" sparse-checkout: |\n") + step.WriteString(" actions/setup\n") + step.WriteString(" sparse-checkout-cone-mode: true\n") + step.WriteString(" persist-credentials: false\n") + return step.String() +} + +// generateSetupStep generates the setup step based on the action mode. +// In script mode, it runs the setup.sh script directly from the checked-out source. +// In other modes (dev/release), it uses the setup action. +// +// Parameters: +// - setupActionRef: The action reference for setup action (e.g., "./actions/setup" or "github/gh-aw/actions/setup@sha") +// - destination: The destination path where files should be copied (e.g., SetupActionDestination) +// - enableCustomTokens: Whether to enable custom-token support (installs @actions/github so handler_auth.cjs can create per-handler Octokit clients) +// +// Returns a slice of strings representing the YAML lines for the setup step. +func (c *Compiler) generateSetupStep(setupActionRef string, destination string, enableCustomTokens bool) []string { + // Script mode: run the setup.sh script directly + if c.actionMode.IsScript() { + lines := []string{ + " - name: Setup Scripts\n", + " run: |\n", + " bash /tmp/gh-aw/actions-source/actions/setup/setup.sh\n", + " env:\n", + fmt.Sprintf(" INPUT_DESTINATION: %s\n", destination), + } + if enableCustomTokens { + lines = append(lines, " INPUT_SAFE_OUTPUT_CUSTOM_TOKENS: 'true'\n") + } + return lines + } + + // Dev/Release mode: use the setup action + lines := []string{ + " - name: Setup Scripts\n", + fmt.Sprintf(" uses: %s\n", setupActionRef), + " with:\n", + fmt.Sprintf(" destination: %s\n", destination), + } + if enableCustomTokens { + lines = append(lines, " safe-output-custom-tokens: 'true'\n") + } + return lines +} + +// generateSetRuntimePathsStep generates a step that sets RUNNER_TEMP-based env vars +// via $GITHUB_OUTPUT. These cannot be set in job-level env: because the runner context +// is not available there (only in step-level env: and run: blocks). +// The step ID "set-runtime-paths" is referenced by downstream steps that consume these outputs. +func (c *Compiler) generateSetRuntimePathsStep() []string { + return []string{ + " - name: Set runtime paths\n", + " id: set-runtime-paths\n", + " run: |\n", + " echo \"GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl\" >> \"$GITHUB_OUTPUT\"\n", + " echo \"GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json\" >> \"$GITHUB_OUTPUT\"\n", + " echo \"GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json\" >> \"$GITHUB_OUTPUT\"\n", + } +}