Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion actions/setup/js/update_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,16 @@ function buildIssueUpdateData(item, config) {
// Sanitize title for Unicode security (no prefix handling needed for updates)
updateData.title = sanitizeTitle(item.title);
}
if (item.body !== undefined) {
// Check if body updates are allowed (defaults to true if not specified)
const canUpdateBody = config.allow_body !== false;
if (item.body !== undefined && canUpdateBody) {
// Store operation information for consistent footer/append behavior.
// Default to "append" so we preserve the original issue text.
updateData._operation = item.operation || "append";
updateData._rawBody = item.body;
} else if (item.body !== undefined && !canUpdateBody) {
// Body update attempted but not allowed by configuration
core.warning("Body update not allowed by safe-outputs configuration");
}
// The safe-outputs schema uses "status" (open/closed), while the GitHub API uses "state".
// Accept both for compatibility.
Expand Down
124 changes: 124 additions & 0 deletions actions/setup/js/update_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,127 @@ describe("update_issue.cjs - footer support", () => {
});
});
});

describe("update_issue.cjs - allow_body configuration", () => {
beforeEach(async () => {
// Reset all mocks before each test
vi.clearAllMocks();
vi.resetModules();
});

it("should allow body updates when allow_body is true", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = {
body: "New body content",
};

const config = {
allow_body: true,
};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._rawBody).toBe("New body content");
expect(result.data._operation).toBe("append");
});

it("should allow body updates when allow_body is not specified (defaults to true)", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = {
body: "New body content",
};

const config = {};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._rawBody).toBe("New body content");
expect(result.data._operation).toBe("append");
});

it("should block body updates when allow_body is false", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = {
body: "New body content",
};

const config = {
allow_body: false,
};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._rawBody).toBeUndefined();
expect(result.data._operation).toBeUndefined();
expect(mockCore.warning).toHaveBeenCalledWith("Body update not allowed by safe-outputs configuration");
});

it("should still allow other fields when body is blocked", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = {
title: "New Title",
body: "New body content",
status: "closed",
labels: ["bug", "enhancement"],
};

const config = {
allow_body: false,
};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data.title).toBe("New Title");
expect(result.data.state).toBe("closed");
expect(result.data.labels).toEqual(["bug", "enhancement"]);
expect(result.data._rawBody).toBeUndefined();
expect(mockCore.warning).toHaveBeenCalledWith("Body update not allowed by safe-outputs configuration");
});

it("should respect allow_body true with explicit operation", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = {
body: "New body content",
operation: "prepend",
};

const config = {
allow_body: true,
};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._rawBody).toBe("New body content");
expect(result.data._operation).toBe("prepend");
});

it("should block body update even with explicit operation when allow_body is false", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = {
body: "New body content",
operation: "replace",
};

const config = {
allow_body: false,
};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._rawBody).toBeUndefined();
expect(result.data._operation).toBeUndefined();
expect(mockCore.warning).toHaveBeenCalledWith("Body update not allowed by safe-outputs configuration");
});
});
5 changes: 3 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5311,8 +5311,9 @@
"description": "Allow updating issue title - presence of key indicates field can be updated"
},
"body": {
"type": "null",
"description": "Allow updating issue body - presence of key indicates field can be updated"
"type": ["boolean", "null"],
"description": "Allow updating issue body. Set to true to enable body updates, false to disable. For backward compatibility, null (body:) also enables body updates.",
"default": true
},
"footer": {
"type": "boolean",
Expand Down
5 changes: 2 additions & 3 deletions pkg/workflow/compiler_safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,8 @@ var handlerRegistry = map[string]handlerBuilder{
if c.Title != nil {
builder.AddDefault("allow_title", true)
}
if c.Body != nil {
builder.AddDefault("allow_body", true)
}
// Body uses boolean value mode - add the actual boolean value
builder.AddBoolPtrOrDefault("allow_body", c.Body, true)
return builder.
Comment on lines 234 to 239
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

This change makes allow_body always present (defaulting to true when c.Body is nil) and also enables body: false to flow through as allow_body: false. There isn’t currently a Go unit test asserting the emitted handler-config JSON contains allow_body: false when configured, and contains the expected default when body is omitted/null; adding an assertion in compiler_safe_outputs_config_test.go would help prevent regressions.

Copilot uses AI. Check for mistakes.
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
AddStringSlice("allowed_repos", c.AllowedRepos).
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/update_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 - presence indicates field can be updated
Body *bool `yaml:"body,omitempty"` // Allow updating issue body - boolean value controls permission (defaults to true)
Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
}

Expand All @@ -23,7 +23,7 @@ func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssu
return []UpdateEntityFieldSpec{
{Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status},
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
{Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body},
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

Switching body from FieldParsingKeyExistence to FieldParsingBoolValue changes the meaning of body:/"body": nil in frontmatter maps: it will now parse as a nil pointer instead of a non-nil *bool. There are existing tests/fixtures that set "body": nil and assert UpdateIssues.Body is non-nil (e.g., pkg/workflow/safe_outputs_max_test.go); those will fail unless updated to use body: true or to expect nil + defaulting behavior.

Suggested change
{Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},

Copilot uses AI. Check for mistakes.
{Name: "footer", Mode: FieldParsingBoolValue, Dest: &cfg.Footer},
}
}, nil)
Expand Down
156 changes: 155 additions & 1 deletion pkg/workflow/update_issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ safe-outputs:
target: "*"
status:
title:
body:
body: true
---

# Test Update Issue Full Configuration
Expand Down Expand Up @@ -153,6 +153,11 @@ This workflow tests the update-issue configuration with all options.
if workflowData.SafeOutputs.UpdateIssues.Body == nil {
t.Fatal("Expected body to be non-nil (updatable)")
}

// Verify body is set to true
if !*workflowData.SafeOutputs.UpdateIssues.Body {
t.Fatal("Expected body to be true")
}
}

func TestUpdateIssueConfigTargetParsing(t *testing.T) {
Expand Down Expand Up @@ -213,3 +218,152 @@ This workflow tests the update-issue target configuration parsing.
t.Fatal("Expected title to be non-nil (updatable)")
}
}

func TestUpdateIssueBodyBooleanTrue(t *testing.T) {
// Test that body: true explicitly enables body updates
tmpDir := testutil.TempDir(t, "output-update-issue-body-true-test")

testContent := `---
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: read
engine: claude
features:
dangerous-permissions-write: true
strict: false
safe-outputs:
update-issue:
body: true
---

# Test Update Issue Body True

This workflow tests body: true configuration.
`

testFile := filepath.Join(tmpDir, "test-update-issue-body-true.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
workflowData, err := compiler.ParseWorkflowFile(testFile)
if err != nil {
t.Fatalf("Unexpected error parsing workflow: %v", err)
}

if workflowData.SafeOutputs.UpdateIssues == nil {
t.Fatal("Expected update-issue configuration to be parsed")
}

if workflowData.SafeOutputs.UpdateIssues.Body == nil {
t.Fatal("Expected body to be non-nil")
}

if !*workflowData.SafeOutputs.UpdateIssues.Body {
t.Fatal("Expected body to be true")
}
}

func TestUpdateIssueBodyBooleanFalse(t *testing.T) {
// Test that body: false explicitly disables body updates
tmpDir := testutil.TempDir(t, "output-update-issue-body-false-test")

testContent := `---
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: read
engine: claude
features:
dangerous-permissions-write: true
strict: false
safe-outputs:
update-issue:
body: false
---

# Test Update Issue Body False

This workflow tests body: false configuration.
`

testFile := filepath.Join(tmpDir, "test-update-issue-body-false.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
workflowData, err := compiler.ParseWorkflowFile(testFile)
if err != nil {
t.Fatalf("Unexpected error parsing workflow: %v", err)
}

if workflowData.SafeOutputs.UpdateIssues == nil {
t.Fatal("Expected update-issue configuration to be parsed")
}

if workflowData.SafeOutputs.UpdateIssues.Body == nil {
t.Fatal("Expected body to be non-nil")
}

if *workflowData.SafeOutputs.UpdateIssues.Body {
t.Fatal("Expected body to be false")
}
}

func TestUpdateIssueBodyNullBackwardCompatibility(t *testing.T) {
// Test that body: (null) maintains backward compatibility and defaults to true
tmpDir := testutil.TempDir(t, "output-update-issue-body-null-test")

testContent := `---
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: read
engine: claude
features:
dangerous-permissions-write: true
strict: false
safe-outputs:
update-issue:
body:
---

# Test Update Issue Body Null

This workflow tests body: (null) for backward compatibility.
`

testFile := filepath.Join(tmpDir, "test-update-issue-body-null.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
workflowData, err := compiler.ParseWorkflowFile(testFile)
if err != nil {
t.Fatalf("Unexpected error parsing workflow: %v", err)
}

if workflowData.SafeOutputs.UpdateIssues == nil {
t.Fatal("Expected update-issue configuration to be parsed")
}

// With FieldParsingBoolValue mode, null values result in nil pointer
// The handler will default to true via AddBoolPtrOrDefault
// This maintains backward compatibility where body: enables body updates
if workflowData.SafeOutputs.UpdateIssues.Body != nil {
t.Fatal("Expected body to be nil when set to null (will default to true in handler)")
}
}
Loading