Skip to content

Commit 208a6d9

Browse files
authored
feat: parameterize safe-output boolean controls for reusable workflows (#29235)
1 parent f390946 commit 208a6d9

10 files changed

Lines changed: 202 additions & 50 deletions

actions/setup/js/missing_issue_helpers.cjs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
55
const { renderTemplateFromFile } = require("./messages_core.cjs");
66
const { generateFooterWithExpiration } = require("./ephemerals.cjs");
77
const { sanitizeContent } = require("./sanitize_content.cjs");
8+
const { parseBoolTemplatable } = require("./templatable.cjs");
89

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

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

164171
/**
165-
* Message handler function that processes a single missing-issue message
172+
* Message handler function that processes a single missing-issue message.
173+
* Accepts the same two-argument signature as all other handler types so the
174+
* handler manager can call it uniformly; resolvedTemporaryIds is unused here.
166175
* @param {Object} message - The message to process
176+
* @param {Object} _resolvedTemporaryIds - Temporary ID map (unused for missing-issue handlers)
167177
* @returns {Promise<Object>} Result with success/error status and issue details
168178
*/
169-
return async function handleMissingIssue(message) {
179+
return async function handleMissingIssue(message, _resolvedTemporaryIds) {
180+
// When create-issue is disabled (e.g. via a resolved GitHub Actions expression),
181+
// skip issue creation without recording a failure.
182+
if (!createIssue) {
183+
core.info(`${handlerType}: create-issue is disabled, skipping issue creation`);
184+
return { success: true, skipped: true, reason: "create-issue disabled" };
185+
}
186+
170187
// Check if we've hit the max limit
171188
if (processedCount >= maxCount) {
172189
core.warning(`Skipping ${handlerType}: max count of ${maxCount} reached`);

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5712,8 +5712,8 @@
57125712
]
57135713
},
57145714
"hide-older-comments": {
5715-
"type": "boolean",
5716-
"description": "When true, minimizes/hides all previous comments from the same agentic workflow (identified by tracker-id) before creating the new comment. Default: false."
5715+
"$ref": "#/$defs/templatable_boolean",
5716+
"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."
57175717
},
57185718
"allowed-reasons": {
57195719
"type": "array",
@@ -7771,8 +7771,8 @@
77717771
]
77727772
},
77737773
"create-issue": {
7774-
"type": "boolean",
7775-
"description": "Whether to create or update GitHub issues when tools are missing (default: true)",
7774+
"$ref": "#/$defs/templatable_boolean",
7775+
"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 }}').",
77767776
"default": true
77777777
},
77787778
"title-prefix": {
@@ -7833,8 +7833,8 @@
78337833
]
78347834
},
78357835
"create-issue": {
7836-
"type": "boolean",
7837-
"description": "Whether to create or update GitHub issues when data is missing (default: true)",
7836+
"$ref": "#/$defs/templatable_boolean",
7837+
"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 }}').",
78387838
"default": true
78397839
},
78407840
"title-prefix": {
@@ -8785,8 +8785,8 @@
87858785
]
87868786
},
87878787
"create-issue": {
8788-
"type": "boolean",
8789-
"description": "Whether to create or update GitHub issues when the task was incomplete (default: true)",
8788+
"$ref": "#/$defs/templatable_boolean",
8789+
"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 }}').",
87908790
"default": true
87918791
},
87928792
"title-prefix": {

pkg/workflow/compiler_safe_outputs_config_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2690,6 +2690,83 @@ func TestNoProtectedDotFolderExcludesWhenNoneDotFolderExcluded(t *testing.T) {
26902690
assert.False(t, exists, "protected_dot_folder_excludes should be absent when no dot-folders excluded")
26912691
}
26922692

2693+
// TestCreateReportIncompleteIssueTemplatableBool tests that create-issue in report-incomplete
2694+
// correctly handles literal booleans and GitHub Actions expressions.
2695+
func TestCreateReportIncompleteIssueTemplatableBool(t *testing.T) {
2696+
compiler := NewCompiler()
2697+
2698+
extractHandlerConfig := func(t *testing.T, safeOutputs *SafeOutputsConfig) map[string]any {
2699+
t.Helper()
2700+
workflowData := &WorkflowData{Name: "Test", SafeOutputs: safeOutputs}
2701+
var steps []string
2702+
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)
2703+
for _, step := range steps {
2704+
if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") {
2705+
parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ")
2706+
if len(parts) == 2 {
2707+
jsonStr := strings.TrimSpace(parts[1])
2708+
jsonStr = strings.Trim(jsonStr, "\"")
2709+
jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"")
2710+
var config map[string]any
2711+
require.NoError(t, json.Unmarshal([]byte(jsonStr), &config), "config JSON should be valid")
2712+
return config
2713+
}
2714+
}
2715+
}
2716+
return nil
2717+
}
2718+
2719+
t.Run("create-issue nil (default) includes handler", func(t *testing.T) {
2720+
config := extractHandlerConfig(t, &SafeOutputsConfig{
2721+
ReportIncomplete: &ReportIncompleteConfig{},
2722+
})
2723+
require.NotNil(t, config)
2724+
_, hasHandler := config["create_report_incomplete_issue"]
2725+
assert.True(t, hasHandler, "create_report_incomplete_issue should be present when create-issue is nil (default)")
2726+
})
2727+
2728+
t.Run("create-issue true includes handler without create-issue field", func(t *testing.T) {
2729+
trueVal := "true"
2730+
config := extractHandlerConfig(t, &SafeOutputsConfig{
2731+
ReportIncomplete: &ReportIncompleteConfig{CreateIssue: &trueVal},
2732+
})
2733+
require.NotNil(t, config)
2734+
handlerCfg, hasHandler := config["create_report_incomplete_issue"]
2735+
require.True(t, hasHandler, "create_report_incomplete_issue should be present when create-issue is true")
2736+
handlerMap, ok := handlerCfg.(map[string]any)
2737+
require.True(t, ok)
2738+
_, hasCreateIssueField := handlerMap["create-issue"]
2739+
assert.False(t, hasCreateIssueField, "create-issue field should not be in handler config for literal true")
2740+
})
2741+
2742+
t.Run("create-issue false excludes handler", func(t *testing.T) {
2743+
falseVal := "false"
2744+
config := extractHandlerConfig(t, &SafeOutputsConfig{
2745+
ReportIncomplete: &ReportIncompleteConfig{CreateIssue: &falseVal},
2746+
})
2747+
require.NotNil(t, config)
2748+
_, hasHandler := config["create_report_incomplete_issue"]
2749+
assert.False(t, hasHandler, "create_report_incomplete_issue should be absent when create-issue is false")
2750+
})
2751+
2752+
t.Run("create-issue expression includes handler with create-issue expression field", func(t *testing.T) {
2753+
expr := "${{ inputs.create-incomplete-issue }}"
2754+
config := extractHandlerConfig(t, &SafeOutputsConfig{
2755+
ReportIncomplete: &ReportIncompleteConfig{CreateIssue: &expr},
2756+
})
2757+
require.NotNil(t, config)
2758+
handlerCfg, hasHandler := config["create_report_incomplete_issue"]
2759+
require.True(t, hasHandler, "create_report_incomplete_issue should be present when create-issue is an expression")
2760+
handlerMap, ok := handlerCfg.(map[string]any)
2761+
require.True(t, ok)
2762+
// Note: the JSON key is "create-issue" (hyphen); the JS handler manager normalises
2763+
// hyphens to underscores at runtime, so handlers see "create_issue".
2764+
createIssueVal, hasCreateIssueField := handlerMap["create-issue"]
2765+
assert.True(t, hasCreateIssueField, "create-issue field should be in handler config for expression")
2766+
assert.Equal(t, expr, createIssueVal, "create-issue field should carry the expression string")
2767+
})
2768+
}
2769+
26932770
// TestPRPolicyFieldsExpressionsPassThrough verifies that GitHub Actions expression strings
26942771
// set on protected-files and patch-format are emitted verbatim into the handler config.
26952772
// This enables reusable workflow_call workflows to parameterise these policy fields per caller.

pkg/workflow/compiler_safe_outputs_handlers.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -629,16 +629,25 @@ var handlerRegistry = map[string]handlerBuilder{
629629
return nil
630630
}
631631
c := cfg.ReportIncomplete
632-
if !c.CreateIssue {
632+
// If create-issue is explicitly false, skip generating the issue handler.
633+
// For nil (default) or "true", always include; for expressions, include
634+
// the handler and embed the expression so it is evaluated at runtime.
635+
if c.CreateIssue != nil && *c.CreateIssue == "false" {
633636
return nil
634637
}
635-
return newHandlerConfigBuilder().
638+
builder := newHandlerConfigBuilder().
636639
AddTemplatableInt("max", c.Max).
637640
AddIfNotEmpty("title-prefix", c.TitlePrefix).
638641
AddStringSlice("labels", c.Labels).
639642
AddIfNotEmpty("github-token", c.GitHubToken).
640-
AddIfTrue("staged", c.Staged).
641-
Build()
643+
AddIfTrue("staged", c.Staged)
644+
// When create-issue is a GitHub Actions expression, embed it in the handler config.
645+
// GitHub Actions evaluates the expression before the handler runs; the JavaScript
646+
// handler then parses the resolved value via parseBoolTemplatable at runtime.
647+
if c.CreateIssue != nil && isExpression(*c.CreateIssue) {
648+
builder = builder.AddTemplatableBool("create-issue", c.CreateIssue)
649+
}
650+
return builder.Build()
642651
},
643652
"assign_to_agent": func(cfg *SafeOutputsConfig) map[string]any {
644653
if cfg.AssignToAgent == nil {

pkg/workflow/missing_data_test.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
121121
configData map[string]any
122122
expectNil bool
123123
expectMax int
124-
expectIssue bool
124+
expectIssue *string
125125
expectTitle string
126126
expectLabels []string
127127
}{
@@ -132,7 +132,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
132132
},
133133
expectNil: false,
134134
expectMax: 0,
135-
expectIssue: true,
135+
expectIssue: strPtr("true"),
136136
expectTitle: "[missing data]",
137137
expectLabels: []string{},
138138
},
@@ -145,7 +145,20 @@ func TestMissingDataConfigParsing(t *testing.T) {
145145
},
146146
expectNil: false,
147147
expectMax: 0,
148-
expectIssue: false,
148+
expectIssue: strPtr("false"),
149+
expectTitle: "[missing data]",
150+
expectLabels: []string{},
151+
},
152+
{
153+
name: "Config with create-issue as expression",
154+
configData: map[string]any{
155+
"missing-data": map[string]any{
156+
"create-issue": "${{ inputs.create-missing-data-issue }}",
157+
},
158+
},
159+
expectNil: false,
160+
expectMax: 0,
161+
expectIssue: strPtr("${{ inputs.create-missing-data-issue }}"),
149162
expectTitle: "[missing data]",
150163
expectLabels: []string{},
151164
},
@@ -160,7 +173,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
160173
},
161174
expectNil: false,
162175
expectMax: 10,
163-
expectIssue: true,
176+
expectIssue: strPtr("true"),
164177
expectTitle: "[data needed]",
165178
expectLabels: []string{"data", "blocked"},
166179
},
@@ -171,7 +184,7 @@ func TestMissingDataConfigParsing(t *testing.T) {
171184
},
172185
expectNil: true,
173186
expectMax: 0,
174-
expectIssue: false,
187+
expectIssue: nil,
175188
expectTitle: "",
176189
expectLabels: nil,
177190
},
@@ -197,8 +210,16 @@ func TestMissingDataConfigParsing(t *testing.T) {
197210
t.Errorf("Expected Max=%d, got Max=%v", tt.expectMax, config.Max)
198211
}
199212

200-
if config.CreateIssue != tt.expectIssue {
201-
t.Errorf("Expected CreateIssue=%v, got CreateIssue=%v", tt.expectIssue, config.CreateIssue)
213+
if tt.expectIssue == nil {
214+
if config.CreateIssue != nil {
215+
t.Errorf("Expected CreateIssue=nil, got CreateIssue=%q", *config.CreateIssue)
216+
}
217+
} else {
218+
if config.CreateIssue == nil {
219+
t.Errorf("Expected CreateIssue=%q, got CreateIssue=nil", *tt.expectIssue)
220+
} else if *config.CreateIssue != *tt.expectIssue {
221+
t.Errorf("Expected CreateIssue=%q, got CreateIssue=%q", *tt.expectIssue, *config.CreateIssue)
222+
}
202223
}
203224

204225
if config.TitlePrefix != tt.expectTitle {

pkg/workflow/missing_issue_reporting.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ var reportIncompleteLog = logger.New("workflow:report_incomplete")
1313
// parent struct fields give them their distinct YAML keys.
1414
type IssueReportingConfig struct {
1515
BaseSafeOutputConfig `yaml:",inline"`
16-
CreateIssue bool `yaml:"create-issue,omitempty"` // Whether to create/update issues (default: true)
16+
CreateIssue *string `yaml:"create-issue,omitempty"` // Whether to create/update issues (default: true). Supports literal bool or GitHub Actions expression.
1717
TitlePrefix string `yaml:"title-prefix,omitempty"` // Prefix for issue titles
1818
Labels []string `yaml:"labels,omitempty"` // Labels to add to created issues
1919
}
@@ -68,7 +68,8 @@ func (c *Compiler) parseIssueReportingConfig(outputMap map[string]any, yamlKey,
6868
// Enabled with no value: missing-data: (nil)
6969
if configData == nil {
7070
log.Printf("%s configuration enabled with defaults", yamlKey)
71-
cfg.CreateIssue = true
71+
trueVal := "true"
72+
cfg.CreateIssue = &trueVal
7273
cfg.TitlePrefix = defaultTitle
7374
cfg.Labels = []string{}
7475
return cfg
@@ -78,13 +79,20 @@ func (c *Compiler) parseIssueReportingConfig(outputMap map[string]any, yamlKey,
7879
log.Printf("Parsing %s configuration from map", yamlKey)
7980
c.parseBaseSafeOutputConfig(configMap, &cfg.BaseSafeOutputConfig, 0)
8081

81-
if createIssue, exists := configMap["create-issue"]; exists {
82-
if createIssueBool, ok := createIssue.(bool); ok {
83-
cfg.CreateIssue = createIssueBool
84-
log.Printf("create-issue: %v", createIssueBool)
82+
// Pre-process create-issue to support literal booleans and GitHub Actions expressions.
83+
if err := preprocessBoolFieldAsString(configMap, "create-issue", log); err != nil {
84+
log.Printf("Invalid create-issue value for %s: %v", yamlKey, err)
85+
return nil
86+
}
87+
88+
if createIssueVal, exists := configMap["create-issue"]; exists {
89+
if createIssueStr, ok := createIssueVal.(string); ok {
90+
cfg.CreateIssue = &createIssueStr
91+
log.Printf("create-issue: %s", createIssueStr)
8592
}
8693
} else {
87-
cfg.CreateIssue = true
94+
trueVal := "true"
95+
cfg.CreateIssue = &trueVal
8896
}
8997

9098
if titlePrefix, exists := configMap["title-prefix"]; exists {

0 commit comments

Comments
 (0)