Skip to content
Merged
3 changes: 2 additions & 1 deletion actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { replaceTemporaryIdReferences, isTemporaryId } = require("./temporary_id.
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { addExpirationToFooter } = require("./ephemerals.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { generateFooterWithMessages } = require("./messages_footer.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");

Expand Down Expand Up @@ -92,7 +93,7 @@ async function main(config = {}) {
// Extract configuration
const titlePrefix = config.title_prefix || "";
const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
const draftDefault = config.draft !== undefined ? config.draft : true;
const draftDefault = parseBoolTemplatable(config.draft, true);
const ifNoChanges = config.if_no_changes || "warn";
const allowEmpty = config.allow_empty || false;
const autoMerge = config.auto_merge || false;
Expand Down
38 changes: 38 additions & 0 deletions actions/setup/js/templatable.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @ts-check

/**
* Helpers for "templatable" safe-output config fields.
*
* A templatable field is one that:
* - Does NOT affect the generated .lock.yml (no compile-time structural
* impact).
* - Can be supplied as a literal boolean/string value OR as a GitHub
* Actions expression ("${{ inputs.foo }}") that is resolved at runtime
* when the env-var containing the handler-config JSON is expanded.
*
* The Go counterpart lives in pkg/workflow/templatables.go.
*/

/**
* Parses a templatable boolean config value.
*
* Handles all representations that can arrive in a handler config:
* - `undefined` / `null` → `defaultValue`
* - boolean `true` → `true`
* - boolean `false` → `false`
* - string `"true"` → `true`
* - string `"false"` → `false`
* - any other string (e.g. a resolved GitHub Actions expression value
* that was not "false") → `true`
*
* @param {any} value - The config field value to parse.
* @param {boolean} [defaultValue=true] - Value returned when `value` is
* `undefined` or `null`.
* @returns {boolean}
*/
function parseBoolTemplatable(value, defaultValue = true) {
if (value === undefined || value === null) return defaultValue;
return String(value) !== "false";
}

module.exports = { parseBoolTemplatable };
47 changes: 47 additions & 0 deletions actions/setup/js/templatable.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";

const { parseBoolTemplatable } = require("./templatable.cjs");

describe("templatable.cjs", () => {
describe("parseBoolTemplatable", () => {
it("returns defaultValue (true) for undefined", () => {
expect(parseBoolTemplatable(undefined)).toBe(true);
});

it("returns defaultValue (true) for null", () => {
expect(parseBoolTemplatable(null)).toBe(true);
});

it("respects a custom defaultValue", () => {
expect(parseBoolTemplatable(undefined, false)).toBe(false);
expect(parseBoolTemplatable(null, false)).toBe(false);
});

it("handles boolean true", () => {
expect(parseBoolTemplatable(true)).toBe(true);
});

it("handles boolean false", () => {
expect(parseBoolTemplatable(false)).toBe(false);
});

it('handles string "true"', () => {
expect(parseBoolTemplatable("true")).toBe(true);
});

it('handles string "false"', () => {
expect(parseBoolTemplatable("false")).toBe(false);
});

it("treats a resolved expression value other than false as truthy", () => {
// GitHub Actions expressions that resolve to something other than "false"
// (e.g. "yes", "1", an empty object representation) should be truthy.
expect(parseBoolTemplatable("yes")).toBe(true);
expect(parseBoolTemplatable("1")).toBe(true);
});

it("treats the string false-equivalent as falsy", () => {
expect(parseBoolTemplatable("false", true)).toBe(false);
});
});
});
17 changes: 15 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4935,8 +4935,8 @@
"description": "Optional reviewer(s) to assign to the pull request. Accepts either a single string or an array of usernames. Use 'copilot' to request a code review from GitHub Copilot."
},
"draft": {
"type": "boolean",
"description": "Whether to create pull request as draft (defaults to true)",
"allOf": [{ "$ref": "#/$defs/templatable_boolean" }],
"description": "Whether to create pull request as draft (defaults to true). Accepts a boolean or a GitHub Actions expression.",
"default": true
},
"if-no-changes": {
Expand Down Expand Up @@ -6877,6 +6877,19 @@
}
],
"$defs": {
"templatable_boolean": {
"description": "A boolean value that may also be specified as a GitHub Actions expression string that resolves to a boolean at runtime (e.g. '${{ inputs.my-flag }}').",
"oneOf": [
{
"type": "boolean"
},
{
"type": "string",
"pattern": "^\\$\\{\\{.*\\}\\}$",
"description": "GitHub Actions expression that resolves to a boolean at runtime"
}
]
},
"engine_config": {
"examples": [
"claude",
Expand Down
73 changes: 72 additions & 1 deletion pkg/workflow/compile_outputs_pr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,77 @@ This workflow tests the create_pull_request job generation with draft: true.
// t.Logf("Generated workflow content:\n%s", lockContentStr)
}

func TestOutputPullRequestDraftExpression(t *testing.T) {
// Create temporary directory for test files
tmpDir := testutil.TempDir(t, "output-pr-draft-expr-test")

// Test case with create-pull-request configuration with draft as an expression
testContent := `---
on:
workflow_dispatch:
inputs:
draft-prs:
type: boolean
default: true
permissions:
contents: read
pull-requests: write
issues: read
tools:
github:
allowed: [list_issues]
engine: claude
features:
dangerous-permissions-write: true
strict: false
safe-outputs:
create-pull-request:
title-prefix: "[agent] "
labels: [automation]
draft: ${{ inputs.draft-prs }}
---

# Test Output Pull Request with Draft Expression

This workflow tests the create_pull_request job generation with draft as an expression.
`

testFile := filepath.Join(tmpDir, "test-output-pr-draft-expr.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()

// Compile the workflow
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Unexpected error compiling workflow with draft expression: %v", err)
}

// Read the generated lock file
lockFile := stringutil.MarkdownToLockFile(testFile)
lockContent, err := os.ReadFile(lockFile)
if err != nil {
t.Fatalf("Failed to read generated lock file: %v", err)
}

lockContentStr := string(lockContent)

// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG is present and contains the draft expression
if !strings.Contains(lockContentStr, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") {
t.Error("Expected handler manager config environment variable")
}

if !strings.Contains(lockContentStr, "draft") {
t.Error("Expected 'draft' field in handler manager config")
}

// The expression should be preserved in the handler config
if !strings.Contains(lockContentStr, "inputs.draft-prs") {
t.Error("Expected expression '${{ inputs.draft-prs }}' to be preserved in the handler config")
}
}

func TestCreatePullRequestIfNoChangesConfig(t *testing.T) {
// Create temporary directory for test files
tmpDir := testutil.TempDir(t, "create-pr-if-no-changes-test")
Expand Down Expand Up @@ -658,7 +729,7 @@ This workflow tests the create-pull-request with fallback-as-issue disabled.
t.Fatal("Expected fallback-as-issue to be set")
}

if *workflowData.SafeOutputs.CreatePullRequests.FallbackAsIssue != false {
if *workflowData.SafeOutputs.CreatePullRequests.FallbackAsIssue {
t.Error("Expected fallback-as-issue to be false")
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ var handlerRegistry = map[string]handlerBuilder{
AddIfNotEmpty("title_prefix", c.TitlePrefix).
AddStringSlice("labels", c.Labels).
AddStringSlice("reviewers", c.Reviewers).
AddBoolPtr("draft", c.Draft).
AddTemplatableBool("draft", c.Draft).
AddIfNotEmpty("if_no_changes", c.IfNoChanges).
AddIfTrue("allow_empty", c.AllowEmpty).
AddIfTrue("auto_merge", c.AutoMerge).
Expand Down
18 changes: 14 additions & 4 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func TestAddHandlerManagerConfigEnvVar(t *testing.T) {
},
TitlePrefix: "[PR] ",
Labels: []string{"automated"},
Draft: testBoolPtr(true),
Draft: testStringPtr("true"),
IfNoChanges: "skip",
AllowEmpty: true,
Expires: 7,
Expand All @@ -153,7 +153,7 @@ func TestAddHandlerManagerConfigEnvVar(t *testing.T) {
},
Reviewers: []string{"user1", "user2"},
Labels: []string{"automated"},
Draft: testBoolPtr(false),
Draft: testStringPtr("false"),
},
},
checkContains: []string{
Expand Down Expand Up @@ -418,6 +418,7 @@ func TestHandlerConfigBooleanFields(t *testing.T) {
safeOutputs *SafeOutputsConfig
checkField string
checkKey string
expected any // expected value in JSON (bool or string)
}{
{
name: "hide older comments",
Expand All @@ -428,6 +429,7 @@ func TestHandlerConfigBooleanFields(t *testing.T) {
},
checkField: "add_comment",
checkKey: "hide_older_comments",
expected: true,
},
{
name: "close older discussions",
Expand All @@ -438,6 +440,7 @@ func TestHandlerConfigBooleanFields(t *testing.T) {
},
checkField: "create_discussion",
checkKey: "close_older_discussions",
expected: true,
},
{
name: "allow empty PR",
Expand All @@ -448,16 +451,18 @@ func TestHandlerConfigBooleanFields(t *testing.T) {
},
checkField: "create_pull_request",
checkKey: "allow_empty",
expected: true,
},
{
name: "draft PR",
safeOutputs: &SafeOutputsConfig{
CreatePullRequests: &CreatePullRequestsConfig{
Draft: testBoolPtr(true),
Draft: testStringPtr("true"),
},
},
checkField: "create_pull_request",
checkKey: "draft",
expected: true, // AddTemplatableBool converts "true" string to JSON boolean
},
}

Expand Down Expand Up @@ -491,7 +496,7 @@ func TestHandlerConfigBooleanFields(t *testing.T) {

val, ok := fieldConfig[tt.checkKey]
require.True(t, ok, "Expected key: "+tt.checkKey)
assert.Equal(t, true, val)
assert.Equal(t, tt.expected, val)
}
}
}
Expand Down Expand Up @@ -696,6 +701,11 @@ func testBoolPtr(b bool) *bool {
return &b
}

// testStringPtr is a helper function for string pointers in config tests
func testStringPtr(s string) *string {
return &s
}

// TestAutoEnabledHandlers tests that missing_tool and missing_data
// are automatically enabled even when not explicitly configured.
// Note: noop is NOT included here because it is always processed by a dedicated
Expand Down
11 changes: 7 additions & 4 deletions pkg/workflow/create_discussion.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type CreateDiscussionsConfig struct {
CloseOlderDiscussions bool `yaml:"close-older-discussions,omitempty"` // When true, close older discussions with same title prefix or labels as outdated
RequiredCategory string `yaml:"required-category,omitempty"` // Required category for matching when close-older-discussions is enabled
Expires int `yaml:"expires,omitempty"` // Hours until the discussion expires and should be automatically closed
FallbackToIssue *bool `yaml:"fallback-to-issue,omitempty"` // When true (default), fallback to create-issue if discussion creation fails due to permissions
FallbackToIssue *bool `yaml:"fallback-to-issue,omitempty"` // When true (default), fallback to create-issue if discussion creation fails due to permissions.
Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
}

Expand Down Expand Up @@ -65,8 +65,8 @@ func (c *Compiler) parseDiscussionsConfig(outputMap map[string]any) *CreateDiscu

// Set default fallback-to-issue to true if not specified
if config.FallbackToIssue == nil {
trueValue := true
config.FallbackToIssue = &trueValue
trueVal := true
config.FallbackToIssue = &trueVal
discussionLog.Print("Using default fallback-to-issue: true")
}

Expand Down Expand Up @@ -140,7 +140,10 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam
}

// Add fallback-to-issue flag
if data.SafeOutputs.CreateDiscussions.FallbackToIssue != nil && *data.SafeOutputs.CreateDiscussions.FallbackToIssue {
ftiVal := data.SafeOutputs.CreateDiscussions.FallbackToIssue
if ftiVal != nil {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_DISCUSSION_FALLBACK_TO_ISSUE: \"%t\"\n", *ftiVal))
} else {
customEnvVars = append(customEnvVars, " GH_AW_DISCUSSION_FALLBACK_TO_ISSUE: \"true\"\n")
}

Expand Down
Loading
Loading