diff --git a/pkg/cli/copilot_agent.go b/pkg/cli/copilot_agent.go index 81f16c58132..7d867aa4392 100644 --- a/pkg/cli/copilot_agent.go +++ b/pkg/cli/copilot_agent.go @@ -9,7 +9,6 @@ import ( "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/workflow" ) var copilotCodingAgentLog = logger.New("cli:copilot_agent") @@ -179,109 +178,3 @@ func readLogHeader(path string, maxBytes int) (string, error) { return string(buffer[:n]), nil } - -// ParseCopilotCodingAgentLogMetrics extracts metrics from GitHub Copilot coding agent logs -// This is different from Copilot CLI logs and requires specialized parsing -func ParseCopilotCodingAgentLogMetrics(logContent string, verbose bool) workflow.LogMetrics { - copilotCodingAgentLog.Printf("Parsing GitHub Copilot coding agent log metrics: %d bytes", len(logContent)) - - var metrics workflow.LogMetrics - var maxTokenUsage int - - lines := strings.Split(logContent, "\n") - toolCallMap := make(map[string]*workflow.ToolCallInfo) - var currentSequence []string - turns := 0 - - // GitHub Copilot coding agent log patterns - // These patterns are designed to match the specific log format of the agent - turnPattern := regexp.MustCompile(`(?i)task.*iteration|agent.*turn|step.*\d+`) - toolCallPattern := regexp.MustCompile(`(?i)tool.*call|executing.*tool|calling.*(\w+)`) - - for _, line := range lines { - // Skip empty lines - if strings.TrimSpace(line) == "" { - continue - } - - // Count turns based on agent iteration patterns - if turnPattern.MatchString(line) { - turns++ - // Start of a new turn, save previous sequence if any - if len(currentSequence) > 0 { - metrics.ToolSequences = append(metrics.ToolSequences, currentSequence) - currentSequence = []string{} - } - } - - // Extract tool calls from agent logs - if matches := toolCallPattern.FindStringSubmatch(line); len(matches) > 1 { - toolName := extractToolName(line) - if toolName != "" { - // Track tool call - if _, exists := toolCallMap[toolName]; !exists { - toolCallMap[toolName] = &workflow.ToolCallInfo{ - Name: toolName, - CallCount: 0, - } - } - toolCallMap[toolName].CallCount++ - - // Add to current sequence - currentSequence = append(currentSequence, toolName) - - if verbose { - copilotCodingAgentLog.Printf("Found tool call: %s", toolName) - } - } - } - - // Try to extract token usage from JSON format if available - jsonMetrics := workflow.ExtractJSONMetrics(line, verbose) - if jsonMetrics.TokenUsage > 0 || jsonMetrics.EstimatedCost > 0 { - if jsonMetrics.TokenUsage > maxTokenUsage { - maxTokenUsage = jsonMetrics.TokenUsage - } - if jsonMetrics.EstimatedCost > 0 { - metrics.EstimatedCost += jsonMetrics.EstimatedCost - } - } - } - - // Add final sequence if any - if len(currentSequence) > 0 { - metrics.ToolSequences = append(metrics.ToolSequences, currentSequence) - } - - // Convert tool call map to slice - for _, toolInfo := range toolCallMap { - metrics.ToolCalls = append(metrics.ToolCalls, *toolInfo) - } - - metrics.TokenUsage = maxTokenUsage - metrics.Turns = turns - - copilotCodingAgentLog.Printf("Parsed metrics: tokens=%d, cost=$%.4f, turns=%d", - metrics.TokenUsage, metrics.EstimatedCost, metrics.Turns) - - return metrics -} - -// extractToolName extracts a tool name from a log line -func extractToolName(line string) string { - // Try to extract tool name from various patterns - patterns := []*regexp.Regexp{ - regexp.MustCompile(`(?i)tool[:\s]+([a-zA-Z0-9_-]+)`), - regexp.MustCompile(`(?i)calling[:\s]+([a-zA-Z0-9_-]+)`), - regexp.MustCompile(`(?i)executing[:\s]+([a-zA-Z0-9_-]+)`), - regexp.MustCompile(`(?i)using[:\s]+tool[:\s]+([a-zA-Z0-9_-]+)`), - } - - for _, pattern := range patterns { - if matches := pattern.FindStringSubmatch(line); len(matches) > 1 { - return strings.TrimSpace(matches[1]) - } - } - - return "" -} diff --git a/pkg/cli/copilot_agent_logs.go b/pkg/cli/copilot_agent_logs.go new file mode 100644 index 00000000000..e41d2e00096 --- /dev/null +++ b/pkg/cli/copilot_agent_logs.go @@ -0,0 +1,114 @@ +package cli + +import ( + "regexp" + "strings" + + "github.com/github/gh-aw/pkg/workflow" +) + +// ParseCopilotCodingAgentLogMetrics extracts metrics from GitHub Copilot coding agent logs +// This is different from Copilot CLI logs and requires specialized parsing +func ParseCopilotCodingAgentLogMetrics(logContent string, verbose bool) workflow.LogMetrics { + copilotCodingAgentLog.Printf("Parsing GitHub Copilot coding agent log metrics: %d bytes", len(logContent)) + + var metrics workflow.LogMetrics + var maxTokenUsage int + + lines := strings.Split(logContent, "\n") + toolCallMap := make(map[string]*workflow.ToolCallInfo) + var currentSequence []string + turns := 0 + + // GitHub Copilot coding agent log patterns + // These patterns are designed to match the specific log format of the agent + turnPattern := regexp.MustCompile(`(?i)task.*iteration|agent.*turn|step.*\d+`) + toolCallPattern := regexp.MustCompile(`(?i)tool.*call|executing.*tool|calling.*(\w+)`) + + for _, line := range lines { + // Skip empty lines + if strings.TrimSpace(line) == "" { + continue + } + + // Count turns based on agent iteration patterns + if turnPattern.MatchString(line) { + turns++ + // Start of a new turn, save previous sequence if any + if len(currentSequence) > 0 { + metrics.ToolSequences = append(metrics.ToolSequences, currentSequence) + currentSequence = []string{} + } + } + + // Extract tool calls from agent logs + if matches := toolCallPattern.FindStringSubmatch(line); len(matches) > 1 { + toolName := extractToolName(line) + if toolName != "" { + // Track tool call + if _, exists := toolCallMap[toolName]; !exists { + toolCallMap[toolName] = &workflow.ToolCallInfo{ + Name: toolName, + CallCount: 0, + } + } + toolCallMap[toolName].CallCount++ + + // Add to current sequence + currentSequence = append(currentSequence, toolName) + + if verbose { + copilotCodingAgentLog.Printf("Found tool call: %s", toolName) + } + } + } + + // Try to extract token usage from JSON format if available + jsonMetrics := workflow.ExtractJSONMetrics(line, verbose) + if jsonMetrics.TokenUsage > 0 || jsonMetrics.EstimatedCost > 0 { + if jsonMetrics.TokenUsage > maxTokenUsage { + maxTokenUsage = jsonMetrics.TokenUsage + } + if jsonMetrics.EstimatedCost > 0 { + metrics.EstimatedCost += jsonMetrics.EstimatedCost + } + } + } + + // Add final sequence if any + if len(currentSequence) > 0 { + metrics.ToolSequences = append(metrics.ToolSequences, currentSequence) + } + + // Convert tool call map to slice + for _, toolInfo := range toolCallMap { + metrics.ToolCalls = append(metrics.ToolCalls, *toolInfo) + } + + metrics.TokenUsage = maxTokenUsage + metrics.Turns = turns + + copilotCodingAgentLog.Printf("Parsed metrics: tokens=%d, cost=$%.4f, turns=%d", + metrics.TokenUsage, metrics.EstimatedCost, metrics.Turns) + + return metrics +} + +// extractToolName extracts a tool name from a log line +func extractToolName(line string) string { + // Try to extract tool name from various patterns + patterns := []*regexp.Regexp{ + regexp.MustCompile(`(?i)tool[:\s]+([a-zA-Z0-9_-]+)`), + regexp.MustCompile(`(?i)calling[:\s]+([a-zA-Z0-9_-]+)`), + regexp.MustCompile(`(?i)executing[:\s]+([a-zA-Z0-9_-]+)`), + regexp.MustCompile(`(?i)using[:\s]+tool[:\s]+([a-zA-Z0-9_-]+)`), + } + + for _, pattern := range patterns { + if matches := pattern.FindStringSubmatch(line); len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + } + + return "" +} diff --git a/pkg/cli/update_extension_check.go b/pkg/cli/update_extension_check.go index 72e9833a4f7..67ddc1cc1a0 100644 --- a/pkg/cli/update_extension_check.go +++ b/pkg/cli/update_extension_check.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/workflow" @@ -35,7 +34,7 @@ func ensureLatestExtensionVersion(verbose bool) error { } // Query GitHub API for latest release - latestVersion, err := getLatestReleaseVersion(verbose) + latestVersion, err := getLatestRelease() if err != nil { // Fail silently - don't block upgrade if we can't check for updates updateExtensionCheckLog.Printf("Failed to check for updates (silently ignoring): %v", err) @@ -83,24 +82,3 @@ func ensureLatestExtensionVersion(verbose bool) error { return nil } - -// getLatestReleaseVersion queries GitHub API for the latest release version of gh-aw -func getLatestReleaseVersion(verbose bool) (string, error) { - updateExtensionCheckLog.Print("Querying GitHub API for latest release...") - - // Create GitHub REST client using go-gh - client, err := api.NewRESTClient(api.ClientOptions{}) - if err != nil { - return "", fmt.Errorf("failed to create GitHub client: %w", err) - } - - // Query the latest release - var release Release - err = client.Get("repos/github/gh-aw/releases/latest", &release) - if err != nil { - return "", fmt.Errorf("failed to query latest release: %w", err) - } - - updateExtensionCheckLog.Printf("Latest release: %s", release.TagName) - return release.TagName, nil -} diff --git a/pkg/workflow/mcp_config_builtin.go b/pkg/workflow/mcp_config_builtin.go index bdb9919b701..0b0bcac2673 100644 --- a/pkg/workflow/mcp_config_builtin.go +++ b/pkg/workflow/mcp_config_builtin.go @@ -288,88 +288,3 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo yaml.WriteString(" },\n") } } - -// renderSafeOutputsMCPConfigTOML generates the Safe Outputs MCP server configuration in TOML format for Codex -// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. -// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. -func renderSafeOutputsMCPConfigTOML(yaml *strings.Builder) { - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID.String() + "]\n") - yaml.WriteString(" type = \"http\"\n") - yaml.WriteString(" url = \"http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT\"\n") - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID.String() + ".headers]\n") - yaml.WriteString(" Authorization = \"$GH_AW_SAFE_OUTPUTS_API_KEY\"\n") -} - -// renderAgenticWorkflowsMCPConfigTOML generates the Agentic Workflows MCP server configuration in TOML format for Codex -// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. -// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. -func renderAgenticWorkflowsMCPConfigTOML(yaml *strings.Builder, actionMode ActionMode) { - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.AgenticWorkflowsMCPServerID.String() + "]\n") - - containerImage := constants.DefaultAlpineImage - var entrypoint string - var entrypointArgs []string - var mounts []string - - if actionMode.IsDev() { - // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI - // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server"] - // So we don't need to specify entrypoint or entrypointArgs - containerImage = constants.DevModeGhAwImage - entrypoint = "" // Use container's default ENTRYPOINT - entrypointArgs = nil // Use container's default CMD - // Only mount workspace and temp directory - binary and gh CLI are in the image - mounts = []string{constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} - } else { - // Release mode: Use minimal Alpine image with mounted binaries - // Pass --validate-actor flag to enable role-based access control - entrypoint = "/opt/gh-aw/gh-aw" - entrypointArgs = []string{"mcp-server", "--validate-actor"} - // Mount gh-aw binary, gh CLI binary, workspace, and temp directory - mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} - } - - yaml.WriteString(" container = \"" + containerImage + "\"\n") - - // Only write entrypoint if it's specified (release mode) - // In dev mode, use the container's default ENTRYPOINT - if entrypoint != "" { - yaml.WriteString(" entrypoint = \"" + entrypoint + "\"\n") - } - - // Only write entrypointArgs if specified (release mode) - // In dev mode, use the container's default CMD - if entrypointArgs != nil { - yaml.WriteString(" entrypointArgs = [") - for i, arg := range entrypointArgs { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + arg + "\"") - } - yaml.WriteString("]\n") - } - - // Write mounts - yaml.WriteString(" mounts = [") - for i, mount := range mounts { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + mount + "\"") - } - yaml.WriteString("]\n") - - // Add Docker runtime args: - // - --network host: Enables network access for GitHub API calls (gh CLI needs api.github.com) - // - -w: Sets working directory to workspace for .github/workflows folder resolution - // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection - yaml.WriteString(" args = [\"--network\", \"host\", \"-w\", \"${GITHUB_WORKSPACE}\"]\n") - - // Use env_vars array to reference environment variables instead of embedding secrets - // Include GITHUB_ACTOR for role-based access control and GITHUB_REPOSITORY for repository context - yaml.WriteString(" env_vars = [\"DEBUG\", \"GITHUB_TOKEN\", \"GITHUB_ACTOR\", \"GITHUB_REPOSITORY\"]\n") -} diff --git a/pkg/workflow/mcp_config_refactor_test.go b/pkg/workflow/mcp_config_refactor_test.go index 8e9aa4ce6a6..cfa8bc6df63 100644 --- a/pkg/workflow/mcp_config_refactor_test.go +++ b/pkg/workflow/mcp_config_refactor_test.go @@ -208,11 +208,15 @@ func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) { } // TestRenderPlaywrightMCPConfigTOML verifies the TOML format helper for Codex engine -// TestRenderSafeOutputsMCPConfigTOML verifies the Safe Outputs TOML format helper +// TestRenderSafeOutputsMCPConfigTOML verifies the Safe Outputs TOML format via the production MCPConfigRendererUnified path func TestRenderSafeOutputsMCPConfigTOML(t *testing.T) { var output strings.Builder - renderSafeOutputsMCPConfigTOML(&output) + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "toml", + IsLast: true, + }) + renderer.RenderSafeOutputsMCP(&output, nil) result := output.String() @@ -246,7 +250,57 @@ func TestRenderSafeOutputsMCPConfigTOML(t *testing.T) { } } -// TestRenderAgenticWorkflowsMCPConfigTOML verifies the Agentic Workflows TOML format helper +// TestRenderSafeOutputsMCPConfigTOMLSandboxAware verifies sandbox-aware host selection in the +// production renderSafeOutputsTOML path +func TestRenderSafeOutputsMCPConfigTOMLSandboxAware(t *testing.T) { + tests := []struct { + name string + workflowData *WorkflowData + expectedHost string + }{ + { + name: "nil workflowData uses host.docker.internal", + workflowData: nil, + expectedHost: "host.docker.internal", + }, + { + name: "agent enabled uses host.docker.internal", + workflowData: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{Disabled: false}, + }, + }, + expectedHost: "host.docker.internal", + }, + { + name: "agent disabled uses localhost", + workflowData: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{Disabled: true}, + }, + }, + expectedHost: "localhost", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output strings.Builder + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "toml", + IsLast: true, + }) + renderer.RenderSafeOutputsMCP(&output, tt.workflowData) + result := output.String() + if !strings.Contains(result, tt.expectedHost) { + t.Errorf("Expected host %q not found in output:\n%s", tt.expectedHost, result) + } + }) + } +} + +// TestRenderAgenticWorkflowsMCPConfigTOML verifies the Agentic Workflows TOML format via the +// production MCPConfigRendererUnified path. // Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. func TestRenderAgenticWorkflowsMCPConfigTOML(t *testing.T) { tests := []struct { @@ -297,15 +351,18 @@ func TestRenderAgenticWorkflowsMCPConfigTOML(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var output strings.Builder - renderAgenticWorkflowsMCPConfigTOML(&output, tt.actionMode) + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "toml", + ActionMode: tt.actionMode, + }) + renderer.RenderAgenticWorkflowsMCP(&output) result := output.String() expectedContent := []string{ `[mcp_servers.agenticworkflows]`, tt.expectedContainer, - `args = ["--network", "host", "-w", "${GITHUB_WORKSPACE}"]`, // Network access + working directory - `env_vars = ["DEBUG", "GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY"]`, + `env_vars = ["DEBUG", "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY"]`, } expectedContent = append(expectedContent, tt.expectedMounts...) diff --git a/pkg/workflow/update_discussion.go b/pkg/workflow/update_discussion.go deleted file mode 100644 index e2dca03a2d2..00000000000 --- a/pkg/workflow/update_discussion.go +++ /dev/null @@ -1,42 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var updateDiscussionLog = logger.New("workflow:update_discussion") - -// UpdateDiscussionsConfig holds configuration for updating GitHub discussions from agent output -type UpdateDiscussionsConfig struct { - UpdateEntityConfig `yaml:",inline"` - Title *bool `yaml:"title,omitempty"` // Allow updating discussion title - presence indicates field can be updated - Body *bool `yaml:"body,omitempty"` // Allow updating discussion body - presence indicates field can be updated - Labels *bool `yaml:"labels,omitempty"` // Allow updating discussion labels - presence indicates field can be updated - AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. -} - -// parseUpdateDiscussionsConfig handles update-discussion configuration -func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *UpdateDiscussionsConfig { - return parseUpdateEntityConfigTyped(c, outputMap, - UpdateEntityDiscussion, "update-discussion", updateDiscussionLog, - func(cfg *UpdateDiscussionsConfig) []UpdateEntityFieldSpec { - return []UpdateEntityFieldSpec{ - {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, - {Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body}, - {Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels}, - {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, - } - }, - func(cm map[string]any, cfg *UpdateDiscussionsConfig) { - // Parse allowed-labels using shared helper - cfg.AllowedLabels = parseAllowedLabelsFromConfig(cm) - if len(cfg.AllowedLabels) > 0 { - updateDiscussionLog.Printf("Allowed labels configured: %v", cfg.AllowedLabels) - // If allowed-labels is specified, implicitly enable labels - if cfg.Labels == nil { - cfg.Labels = new(bool) - } - } - }) -} diff --git a/pkg/workflow/update_entity_helpers.go b/pkg/workflow/update_entity_helpers.go index ebb004c670e..31cce299813 100644 --- a/pkg/workflow/update_entity_helpers.go +++ b/pkg/workflow/update_entity_helpers.go @@ -72,6 +72,9 @@ import ( ) var updateEntityHelpersLog = logger.New("workflow:update_entity_helpers") +var updateIssueLog = logger.New("workflow:update_issue") +var updateDiscussionLog = logger.New("workflow:update_discussion") +var updatePullRequestLog = logger.New("workflow:update_pull_request") // UpdateEntityType represents the type of entity being updated type UpdateEntityType string @@ -446,3 +449,95 @@ func parseUpdateEntityConfigTyped[T any]( return cfg } + +// UpdateIssuesConfig holds configuration for updating GitHub issues from agent output +type UpdateIssuesConfig struct { + UpdateEntityConfig `yaml:",inline"` + Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated + Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated + Body *bool `yaml:"body,omitempty"` // Allow updating issue body - boolean value controls permission (defaults to true) + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. + TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for issue validation - only issues with this prefix can be updated +} + +// parseUpdateIssuesConfig handles update-issue configuration +func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssuesConfig { + return parseUpdateEntityConfigTyped(c, outputMap, + UpdateEntityIssue, "update-issue", updateIssueLog, + func(cfg *UpdateIssuesConfig) []UpdateEntityFieldSpec { + return []UpdateEntityFieldSpec{ + {Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status}, + {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, + {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, + {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, + } + }, func(configMap map[string]any, cfg *UpdateIssuesConfig) { + cfg.TitlePrefix = parseTitlePrefixFromConfig(configMap) + }) +} + +// UpdateDiscussionsConfig holds configuration for updating GitHub discussions from agent output +type UpdateDiscussionsConfig struct { + UpdateEntityConfig `yaml:",inline"` + Title *bool `yaml:"title,omitempty"` // Allow updating discussion title - presence indicates field can be updated + Body *bool `yaml:"body,omitempty"` // Allow updating discussion body - presence indicates field can be updated + Labels *bool `yaml:"labels,omitempty"` // Allow updating discussion labels - presence indicates field can be updated + AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. +} + +// parseUpdateDiscussionsConfig handles update-discussion configuration +func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *UpdateDiscussionsConfig { + return parseUpdateEntityConfigTyped(c, outputMap, + UpdateEntityDiscussion, "update-discussion", updateDiscussionLog, + func(cfg *UpdateDiscussionsConfig) []UpdateEntityFieldSpec { + return []UpdateEntityFieldSpec{ + {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, + {Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body}, + {Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels}, + {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, + } + }, + func(cm map[string]any, cfg *UpdateDiscussionsConfig) { + // Parse allowed-labels using shared helper + cfg.AllowedLabels = parseAllowedLabelsFromConfig(cm) + if len(cfg.AllowedLabels) > 0 { + updateDiscussionLog.Printf("Allowed labels configured: %v", cfg.AllowedLabels) + // If allowed-labels is specified, implicitly enable labels + if cfg.Labels == nil { + cfg.Labels = new(bool) + } + } + }) +} + +// UpdatePullRequestsConfig holds configuration for updating GitHub pull requests from agent output +type UpdatePullRequestsConfig struct { + UpdateEntityConfig `yaml:",inline"` + Title *bool `yaml:"title,omitempty"` // Allow updating PR title - defaults to true, set to false to disable + Body *bool `yaml:"body,omitempty"` // Allow updating PR body - defaults to true, set to false to disable + Operation *string `yaml:"operation,omitempty"` // Default operation for body updates: "append", "prepend", or "replace" (defaults to "replace") + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted. +} + +// parseUpdatePullRequestsConfig handles update-pull-request configuration +func (c *Compiler) parseUpdatePullRequestsConfig(outputMap map[string]any) *UpdatePullRequestsConfig { + updatePullRequestLog.Print("Parsing update pull request configuration") + + return parseUpdateEntityConfigTyped(c, outputMap, + UpdateEntityPullRequest, "update-pull-request", updatePullRequestLog, + func(cfg *UpdatePullRequestsConfig) []UpdateEntityFieldSpec { + return []UpdateEntityFieldSpec{ + {Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title}, + {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, + {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, + } + }, func(configMap map[string]any, cfg *UpdatePullRequestsConfig) { + // Parse operation field + if operationVal, exists := configMap["operation"]; exists { + if operationStr, ok := operationVal.(string); ok { + cfg.Operation = &operationStr + } + } + }) +} diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go deleted file mode 100644 index 8e585c2ebdb..00000000000 --- a/pkg/workflow/update_issue.go +++ /dev/null @@ -1,33 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var updateIssueLog = logger.New("workflow:update_issue") - -// UpdateIssuesConfig holds configuration for updating GitHub issues from agent output -type UpdateIssuesConfig struct { - UpdateEntityConfig `yaml:",inline"` - Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated - Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated - Body *bool `yaml:"body,omitempty"` // Allow updating issue body - boolean value controls permission (defaults to true) - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. - TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for issue validation - only issues with this prefix can be updated -} - -// parseUpdateIssuesConfig handles update-issue configuration -func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssuesConfig { - return parseUpdateEntityConfigTyped(c, outputMap, - UpdateEntityIssue, "update-issue", updateIssueLog, - func(cfg *UpdateIssuesConfig) []UpdateEntityFieldSpec { - return []UpdateEntityFieldSpec{ - {Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status}, - {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, - {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, - {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, - } - }, func(configMap map[string]any, cfg *UpdateIssuesConfig) { - cfg.TitlePrefix = parseTitlePrefixFromConfig(configMap) - }) -} diff --git a/pkg/workflow/update_pull_request.go b/pkg/workflow/update_pull_request.go deleted file mode 100644 index 4ec772b9665..00000000000 --- a/pkg/workflow/update_pull_request.go +++ /dev/null @@ -1,38 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var updatePullRequestLog = logger.New("workflow:update_pull_request") - -// UpdatePullRequestsConfig holds configuration for updating GitHub pull requests from agent output -type UpdatePullRequestsConfig struct { - UpdateEntityConfig `yaml:",inline"` - Title *bool `yaml:"title,omitempty"` // Allow updating PR title - defaults to true, set to false to disable - Body *bool `yaml:"body,omitempty"` // Allow updating PR body - defaults to true, set to false to disable - Operation *string `yaml:"operation,omitempty"` // Default operation for body updates: "append", "prepend", or "replace" (defaults to "replace") - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted. -} - -// parseUpdatePullRequestsConfig handles update-pull-request configuration -func (c *Compiler) parseUpdatePullRequestsConfig(outputMap map[string]any) *UpdatePullRequestsConfig { - updatePullRequestLog.Print("Parsing update pull request configuration") - - return parseUpdateEntityConfigTyped(c, outputMap, - UpdateEntityPullRequest, "update-pull-request", updatePullRequestLog, - func(cfg *UpdatePullRequestsConfig) []UpdateEntityFieldSpec { - return []UpdateEntityFieldSpec{ - {Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title}, - {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, - {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, - } - }, func(configMap map[string]any, cfg *UpdatePullRequestsConfig) { - // Parse operation field - if operationVal, exists := configMap["operation"]; exists { - if operationStr, ok := operationVal.(string); ok { - cfg.Operation = &operationStr - } - } - }) -}