Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
21 changes: 19 additions & 2 deletions actions/setup/js/missing_issue_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { renderTemplateFromFile } = require("./messages_core.cjs");
const { generateFooterWithExpiration } = require("./ephemerals.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
Expand Down Expand Up @@ -32,6 +33,12 @@ function buildMissingIssueHandler(options) {

return async function main(config = {}) {
// Extract configuration
// create_issue: templatable boolean — default true.
// Accepts: literal boolean (true/false), string 'true'/'false', or a GitHub Actions
// expression (e.g. '${{ inputs.create-incomplete-issue }}'). Expressions are evaluated
// by GitHub Actions before this handler runs, so config.create_issue holds the
// resolved boolean or string value when the handler executes.
const createIssue = parseBoolTemplatable(config.create_issue, true);
const titlePrefix = config.title_prefix || defaultTitlePrefix;
const userLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
const envLabels = [...new Set([...defaultLabels, ...userLabels])];
Expand Down Expand Up @@ -162,11 +169,21 @@ function buildMissingIssueHandler(options) {
}

/**
* Message handler function that processes a single missing-issue message
* Message handler function that processes a single missing-issue message.
* Accepts the same two-argument signature as all other handler types so the
* handler manager can call it uniformly; resolvedTemporaryIds is unused here.
* @param {Object} message - The message to process
* @param {Object} _resolvedTemporaryIds - Temporary ID map (unused for missing-issue handlers)
* @returns {Promise<Object>} Result with success/error status and issue details
*/
return async function handleMissingIssue(message) {
return async function handleMissingIssue(message, _resolvedTemporaryIds) {
// When create-issue is disabled (e.g. via a resolved GitHub Actions expression),
// skip issue creation without recording a failure.
if (!createIssue) {
core.info(`${handlerType}: create-issue is disabled, skipping issue creation`);
return { success: true, skipped: true, reason: "create-issue disabled" };
}

// Check if we've hit the max limit
if (processedCount >= maxCount) {
core.warning(`Skipping ${handlerType}: max count of ${maxCount} reached`);
Expand Down
16 changes: 8 additions & 8 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5712,8 +5712,8 @@
]
},
"hide-older-comments": {
"type": "boolean",
"description": "When true, minimizes/hides all previous comments from the same agentic workflow (identified by tracker-id) before creating the new comment. Default: false."
"$ref": "#/$defs/templatable_boolean",
"description": "When true, minimizes/hides all previous comments from the same agentic workflow (identified by tracker-id) before creating the new comment. Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.hide-older-comments }}'). Default: false."
},
"allowed-reasons": {
"type": "array",
Expand Down Expand Up @@ -7723,8 +7723,8 @@
]
},
"create-issue": {
"type": "boolean",
"description": "Whether to create or update GitHub issues when tools are missing (default: true)",
"$ref": "#/$defs/templatable_boolean",
"description": "Whether to create or update GitHub issues when tools are missing (default: true). Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.create-incomplete-issue }}').",

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated description for missing-tool.create-issue uses an example expression ${{ inputs.create-incomplete-issue }}, which appears unrelated to missing-tool and could confuse users wiring workflow_call inputs. Consider updating the example to reference a missing-tool-specific input name (or a generic ${{ inputs.create-issue }}) to match the field being documented.

Suggested change
"description": "Whether to create or update GitHub issues when tools are missing (default: true). Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.create-incomplete-issue }}').",
"description": "Whether to create or update GitHub issues when tools are missing (default: true). Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.create-issue }}').",

Copilot uses AI. Check for mistakes.
"default": true
},
"title-prefix": {
Expand Down Expand Up @@ -7785,8 +7785,8 @@
]
},
"create-issue": {
"type": "boolean",
"description": "Whether to create or update GitHub issues when data is missing (default: true)",
"$ref": "#/$defs/templatable_boolean",
"description": "Whether to create or update GitHub issues when data is missing (default: true). Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.create-missing-data-issue }}').",
"default": true
},
"title-prefix": {
Expand Down Expand Up @@ -8737,8 +8737,8 @@
]
},
"create-issue": {
"type": "boolean",
"description": "Whether to create or update GitHub issues when the task was incomplete (default: true)",
"$ref": "#/$defs/templatable_boolean",
"description": "Whether to create or update GitHub issues when the task was incomplete (default: true). Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.create-incomplete-issue }}').",
"default": true
},
"title-prefix": {
Expand Down
77 changes: 77 additions & 0 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2689,3 +2689,80 @@ func TestNoProtectedDotFolderExcludesWhenNoneDotFolderExcluded(t *testing.T) {
_, exists := prConfig["protected_dot_folder_excludes"]
assert.False(t, exists, "protected_dot_folder_excludes should be absent when no dot-folders excluded")
}

// TestCreateReportIncompleteIssueTemplatableBool tests that create-issue in report-incomplete
// correctly handles literal booleans and GitHub Actions expressions.
func TestCreateReportIncompleteIssueTemplatableBool(t *testing.T) {
compiler := NewCompiler()

extractHandlerConfig := func(t *testing.T, safeOutputs *SafeOutputsConfig) map[string]any {
t.Helper()
workflowData := &WorkflowData{Name: "Test", SafeOutputs: safeOutputs}
var steps []string
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)
for _, step := range steps {
if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") {
parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ")
if len(parts) == 2 {
jsonStr := strings.TrimSpace(parts[1])
jsonStr = strings.Trim(jsonStr, "\"")
jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"")
var config map[string]any
require.NoError(t, json.Unmarshal([]byte(jsonStr), &config), "config JSON should be valid")
return config
}
}
}
return nil
}

t.Run("create-issue nil (default) includes handler", func(t *testing.T) {
config := extractHandlerConfig(t, &SafeOutputsConfig{
ReportIncomplete: &ReportIncompleteConfig{},
})
require.NotNil(t, config)
_, hasHandler := config["create_report_incomplete_issue"]
assert.True(t, hasHandler, "create_report_incomplete_issue should be present when create-issue is nil (default)")
})

t.Run("create-issue true includes handler without create-issue field", func(t *testing.T) {
trueVal := "true"
config := extractHandlerConfig(t, &SafeOutputsConfig{
ReportIncomplete: &ReportIncompleteConfig{CreateIssue: &trueVal},
})
require.NotNil(t, config)
handlerCfg, hasHandler := config["create_report_incomplete_issue"]
require.True(t, hasHandler, "create_report_incomplete_issue should be present when create-issue is true")
handlerMap, ok := handlerCfg.(map[string]any)
require.True(t, ok)
_, hasCreateIssueField := handlerMap["create-issue"]
assert.False(t, hasCreateIssueField, "create-issue field should not be in handler config for literal true")
})

t.Run("create-issue false excludes handler", func(t *testing.T) {
falseVal := "false"
config := extractHandlerConfig(t, &SafeOutputsConfig{
ReportIncomplete: &ReportIncompleteConfig{CreateIssue: &falseVal},
})
require.NotNil(t, config)
_, hasHandler := config["create_report_incomplete_issue"]
assert.False(t, hasHandler, "create_report_incomplete_issue should be absent when create-issue is false")
})

t.Run("create-issue expression includes handler with create-issue expression field", func(t *testing.T) {
expr := "${{ inputs.create-incomplete-issue }}"
config := extractHandlerConfig(t, &SafeOutputsConfig{
ReportIncomplete: &ReportIncompleteConfig{CreateIssue: &expr},
})
require.NotNil(t, config)
handlerCfg, hasHandler := config["create_report_incomplete_issue"]
require.True(t, hasHandler, "create_report_incomplete_issue should be present when create-issue is an expression")
handlerMap, ok := handlerCfg.(map[string]any)
require.True(t, ok)
// Note: the JSON key is "create-issue" (hyphen); the JS handler manager normalises
// hyphens to underscores at runtime, so handlers see "create_issue".
createIssueVal, hasCreateIssueField := handlerMap["create-issue"]
assert.True(t, hasCreateIssueField, "create-issue field should be in handler config for expression")
assert.Equal(t, expr, createIssueVal, "create-issue field should carry the expression string")
})
}
17 changes: 13 additions & 4 deletions pkg/workflow/compiler_safe_outputs_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,16 +629,25 @@ var handlerRegistry = map[string]handlerBuilder{
return nil
}
c := cfg.ReportIncomplete
if !c.CreateIssue {
// If create-issue is explicitly false, skip generating the issue handler.
// For nil (default) or "true", always include; for expressions, include
// the handler and embed the expression so it is evaluated at runtime.
if c.CreateIssue != nil && *c.CreateIssue == "false" {
return nil
}
return newHandlerConfigBuilder().
builder := newHandlerConfigBuilder().
AddTemplatableInt("max", c.Max).
AddIfNotEmpty("title-prefix", c.TitlePrefix).
AddStringSlice("labels", c.Labels).
AddIfNotEmpty("github-token", c.GitHubToken).
AddIfTrue("staged", c.Staged).
Build()
AddIfTrue("staged", c.Staged)
// When create-issue is a GitHub Actions expression, embed it in the handler config.
// GitHub Actions evaluates the expression before the handler runs; the JavaScript
// handler then parses the resolved value via parseBoolTemplatable at runtime.
if c.CreateIssue != nil && isExpression(*c.CreateIssue) {
builder = builder.AddTemplatableBool("create-issue", c.CreateIssue)
}
return builder.Build()
},
"assign_to_agent": func(cfg *SafeOutputsConfig) map[string]any {
if cfg.AssignToAgent == nil {
Expand Down
35 changes: 28 additions & 7 deletions pkg/workflow/missing_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
configData map[string]any
expectNil bool
expectMax int
expectIssue bool
expectIssue *string
expectTitle string
expectLabels []string
}{
Expand All @@ -132,7 +132,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
},
expectNil: false,
expectMax: 0,
expectIssue: true,
expectIssue: strPtr("true"),
expectTitle: "[missing data]",
expectLabels: []string{},
},
Expand All @@ -145,7 +145,20 @@ func TestMissingDataConfigParsing(t *testing.T) {
},
expectNil: false,
expectMax: 0,
expectIssue: false,
expectIssue: strPtr("false"),
expectTitle: "[missing data]",
expectLabels: []string{},
},
{
name: "Config with create-issue as expression",
configData: map[string]any{
"missing-data": map[string]any{
"create-issue": "${{ inputs.create-missing-data-issue }}",
},
},
expectNil: false,
expectMax: 0,
expectIssue: strPtr("${{ inputs.create-missing-data-issue }}"),
expectTitle: "[missing data]",
expectLabels: []string{},
},
Expand All @@ -160,7 +173,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
},
expectNil: false,
expectMax: 10,
expectIssue: true,
expectIssue: strPtr("true"),
expectTitle: "[data needed]",
expectLabels: []string{"data", "blocked"},
},
Expand All @@ -171,7 +184,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
},
expectNil: true,
expectMax: 0,
expectIssue: false,
expectIssue: nil,
expectTitle: "",
expectLabels: nil,
},
Expand All @@ -197,8 +210,16 @@ func TestMissingDataConfigParsing(t *testing.T) {
t.Errorf("Expected Max=%d, got Max=%v", tt.expectMax, config.Max)
}

if config.CreateIssue != tt.expectIssue {
t.Errorf("Expected CreateIssue=%v, got CreateIssue=%v", tt.expectIssue, config.CreateIssue)
if tt.expectIssue == nil {
if config.CreateIssue != nil {
t.Errorf("Expected CreateIssue=nil, got CreateIssue=%q", *config.CreateIssue)
}
} else {
if config.CreateIssue == nil {
t.Errorf("Expected CreateIssue=%q, got CreateIssue=nil", *tt.expectIssue)
} else if *config.CreateIssue != *tt.expectIssue {
t.Errorf("Expected CreateIssue=%q, got CreateIssue=%q", *tt.expectIssue, *config.CreateIssue)
}
}

if config.TitlePrefix != tt.expectTitle {
Expand Down
22 changes: 15 additions & 7 deletions pkg/workflow/missing_issue_reporting.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var reportIncompleteLog = logger.New("workflow:report_incomplete")
// parent struct fields give them their distinct YAML keys.
type IssueReportingConfig struct {
BaseSafeOutputConfig `yaml:",inline"`
CreateIssue bool `yaml:"create-issue,omitempty"` // Whether to create/update issues (default: true)
CreateIssue *string `yaml:"create-issue,omitempty"` // Whether to create/update issues (default: true). Supports literal bool or GitHub Actions expression.
TitlePrefix string `yaml:"title-prefix,omitempty"` // Prefix for issue titles
Labels []string `yaml:"labels,omitempty"` // Labels to add to created issues
}
Expand Down Expand Up @@ -68,7 +68,8 @@ func (c *Compiler) parseIssueReportingConfig(outputMap map[string]any, yamlKey,
// Enabled with no value: missing-data: (nil)
if configData == nil {
log.Printf("%s configuration enabled with defaults", yamlKey)
cfg.CreateIssue = true
trueVal := "true"
cfg.CreateIssue = &trueVal
cfg.TitlePrefix = defaultTitle
cfg.Labels = []string{}
return cfg
Expand All @@ -78,13 +79,20 @@ func (c *Compiler) parseIssueReportingConfig(outputMap map[string]any, yamlKey,
log.Printf("Parsing %s configuration from map", yamlKey)
c.parseBaseSafeOutputConfig(configMap, &cfg.BaseSafeOutputConfig, 0)

if createIssue, exists := configMap["create-issue"]; exists {
if createIssueBool, ok := createIssue.(bool); ok {
cfg.CreateIssue = createIssueBool
log.Printf("create-issue: %v", createIssueBool)
// Pre-process create-issue to support literal booleans and GitHub Actions expressions.
if err := preprocessBoolFieldAsString(configMap, "create-issue", log); err != nil {
log.Printf("Invalid create-issue value for %s: %v", yamlKey, err)
return nil
}

if createIssueVal, exists := configMap["create-issue"]; exists {
if createIssueStr, ok := createIssueVal.(string); ok {
cfg.CreateIssue = &createIssueStr
log.Printf("create-issue: %s", createIssueStr)
}
} else {
cfg.CreateIssue = true
trueVal := "true"
cfg.CreateIssue = &trueVal
}

if titlePrefix, exists := configMap["title-prefix"]; exists {
Expand Down
Loading
Loading