Skip to content

Commit 3505b9d

Browse files
authored
Make create-pull-request auto-close issue behavior configurable (#23738)
1 parent 756f67d commit 3505b9d

File tree

8 files changed

+245
-2
lines changed

8 files changed

+245
-2
lines changed

actions/setup/js/create_pull_request.cjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ async function main(config = {}) {
174174

175175
const includeFooter = parseBoolTemplatable(config.footer, true);
176176
const fallbackAsIssue = config.fallback_as_issue !== false; // Default to true (fallback enabled)
177+
const autoCloseIssue = parseBoolTemplatable(config.auto_close_issue, true); // Default to true (auto-close enabled)
177178

178179
// Environment validation - fail early if required variables are missing
179180
const workflowId = process.env.GH_AW_WORKFLOW_ID;
@@ -557,12 +558,15 @@ async function main(config = {}) {
557558
// Auto-add "Fixes #N" closing keyword if triggered from an issue and not already present.
558559
// This ensures the triggering issue is auto-closed when the PR is merged.
559560
// Agents are instructed to include this but don't reliably do so.
560-
if (triggeringIssueNumber) {
561+
// This behavior can be disabled by setting auto-close-issue: false in the workflow config.
562+
if (triggeringIssueNumber && autoCloseIssue) {
561563
const hasClosingKeyword = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#\d+/i.test(processedBody);
562564
if (!hasClosingKeyword) {
563565
processedBody = processedBody.trimEnd() + `\n\n- Fixes #${triggeringIssueNumber}`;
564566
core.info(`Auto-added "Fixes #${triggeringIssueNumber}" closing keyword to PR body as bullet point`);
565567
}
568+
} else if (triggeringIssueNumber && !autoCloseIssue) {
569+
core.info(`Skipping auto-close keyword for #${triggeringIssueNumber} (auto-close-issue: false)`);
566570
}
567571

568572
let bodyLines = processedBody.split("\n");

actions/setup/js/create_pull_request.test.cjs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,157 @@ describe("create_pull_request - fallback-as-issue configuration", () => {
179179
});
180180
});
181181

182+
describe("create_pull_request - auto-close-issue configuration", () => {
183+
let tempDir;
184+
let originalEnv;
185+
186+
beforeEach(() => {
187+
originalEnv = { ...process.env };
188+
process.env.GH_AW_WORKFLOW_ID = "test-workflow";
189+
process.env.GITHUB_REPOSITORY = "test-owner/test-repo";
190+
process.env.GITHUB_BASE_REF = "main";
191+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-auto-close-test-"));
192+
193+
global.core = {
194+
info: vi.fn(),
195+
warning: vi.fn(),
196+
error: vi.fn(),
197+
debug: vi.fn(),
198+
setFailed: vi.fn(),
199+
setOutput: vi.fn(),
200+
startGroup: vi.fn(),
201+
endGroup: vi.fn(),
202+
summary: {
203+
addRaw: vi.fn().mockReturnThis(),
204+
write: vi.fn().mockResolvedValue(undefined),
205+
},
206+
};
207+
global.github = {
208+
rest: {
209+
pulls: {
210+
create: vi.fn().mockResolvedValue({ data: { number: 1, html_url: "https://github.com/test" } }),
211+
},
212+
repos: {
213+
get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }),
214+
},
215+
issues: {
216+
addLabels: vi.fn().mockResolvedValue({}),
217+
},
218+
},
219+
graphql: vi.fn(),
220+
};
221+
global.context = {
222+
eventName: "issues",
223+
repo: { owner: "test-owner", repo: "test-repo" },
224+
payload: {
225+
issue: { number: 42 },
226+
},
227+
};
228+
global.exec = {
229+
exec: vi.fn().mockResolvedValue(0),
230+
getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }),
231+
};
232+
233+
delete require.cache[require.resolve("./create_pull_request.cjs")];
234+
});
235+
236+
afterEach(() => {
237+
for (const key of Object.keys(process.env)) {
238+
if (!(key in originalEnv)) {
239+
delete process.env[key];
240+
}
241+
}
242+
Object.assign(process.env, originalEnv);
243+
244+
if (tempDir && fs.existsSync(tempDir)) {
245+
fs.rmSync(tempDir, { recursive: true, force: true });
246+
}
247+
248+
delete global.core;
249+
delete global.github;
250+
delete global.context;
251+
delete global.exec;
252+
vi.clearAllMocks();
253+
});
254+
255+
it("should auto-add 'Fixes #N' when triggered from an issue and auto_close_issue is not set (default)", async () => {
256+
const { main } = require("./create_pull_request.cjs");
257+
const handler = await main({ allow_empty: true });
258+
259+
await handler({ title: "Test PR", body: "Test body" }, {});
260+
261+
const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
262+
expect(createCall?.body).toContain("Fixes #42");
263+
expect(global.core.info).toHaveBeenCalledWith(expect.stringContaining('Auto-added "Fixes #42"'));
264+
});
265+
266+
it("should auto-add 'Fixes #N' when triggered from an issue and auto_close_issue is explicitly true", async () => {
267+
const { main } = require("./create_pull_request.cjs");
268+
const handler = await main({ allow_empty: true, auto_close_issue: true });
269+
270+
await handler({ title: "Test PR", body: "Test body" }, {});
271+
272+
const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
273+
expect(createCall?.body).toContain("Fixes #42");
274+
expect(global.core.info).toHaveBeenCalledWith(expect.stringContaining('Auto-added "Fixes #42"'));
275+
});
276+
277+
it("should NOT auto-add 'Fixes #N' when auto_close_issue is false", async () => {
278+
const { main } = require("./create_pull_request.cjs");
279+
const handler = await main({ allow_empty: true, auto_close_issue: false });
280+
281+
await handler({ title: "Test PR", body: "Test body" }, {});
282+
283+
const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
284+
expect(createCall?.body).not.toContain("Fixes #42");
285+
expect(global.core.info).toHaveBeenCalledWith(expect.stringContaining("Skipping auto-close keyword for #42 (auto-close-issue: false)"));
286+
});
287+
288+
it("should NOT auto-add 'Fixes #N' when body already contains a closing keyword, regardless of auto_close_issue", async () => {
289+
const { main } = require("./create_pull_request.cjs");
290+
const handler = await main({ allow_empty: true });
291+
292+
await handler({ title: "Test PR", body: "Test body\n\nCloses #42" }, {});
293+
294+
const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
295+
// Should not duplicate the keyword
296+
const fixesCount = (createCall?.body?.match(/Fixes #42/gi) || []).length;
297+
const closesCount = (createCall?.body?.match(/Closes #42/gi) || []).length;
298+
expect(closesCount).toBe(1);
299+
expect(fixesCount).toBe(0);
300+
});
301+
302+
it("should have no effect when not triggered from an issue, regardless of auto_close_issue value", async () => {
303+
// Override context to not be from an issue
304+
global.context = {
305+
eventName: "workflow_dispatch",
306+
repo: { owner: "test-owner", repo: "test-repo" },
307+
payload: {},
308+
};
309+
delete require.cache[require.resolve("./create_pull_request.cjs")];
310+
311+
const { main } = require("./create_pull_request.cjs");
312+
const handler = await main({ allow_empty: true, auto_close_issue: true });
313+
314+
await handler({ title: "Test PR", body: "Test body" }, {});
315+
316+
const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
317+
expect(createCall?.body).not.toContain("Fixes #");
318+
});
319+
320+
it("should NOT add 'Fixes #N' when auto_close_issue is false even if body has no closing keyword", async () => {
321+
const { main } = require("./create_pull_request.cjs");
322+
const handler = await main({ allow_empty: true, auto_close_issue: false });
323+
324+
await handler({ title: "Test PR", body: "Investigation findings - partial work only" }, {});
325+
326+
const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
327+
expect(createCall?.body).not.toContain("Fixes #");
328+
expect(createCall?.body).not.toContain("Closes #");
329+
expect(createCall?.body).not.toContain("Resolves #");
330+
});
331+
});
332+
182333
describe("create_pull_request - max limit enforcement", () => {
183334
let mockFs;
184335

actions/setup/js/types/safe-outputs-config.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ interface CreatePullRequestConfig extends SafeOutputConfig {
8484
draft?: boolean;
8585
"if-no-changes"?: string;
8686
footer?: boolean;
87+
"auto-close-issue"?: boolean | string;
8788
}
8889

8990
/**

docs/src/content/docs/reference/safe-outputs-pull-requests.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ safe-outputs:
2929
allowed-repos: ["org/repo1", "org/repo2"] # additional allowed repositories
3030
base-branch: "vnext" # target branch for PR (default: github.base_ref || github.ref_name)
3131
fallback-as-issue: false # disable issue fallback (default: true)
32+
auto-close-issue: false # don't auto-add "Fixes #N" to PR description (default: true)
3233
preserve-branch-name: true # omit random salt suffix from branch name (default: false)
3334
excluded-files: # files to omit from the patch entirely
3435
- "**/*.lock"
@@ -57,6 +58,8 @@ The `preserve-branch-name` field, when set to `true`, omits the random hex salt
5758

5859
The `draft` field is a **configuration policy**, not a default. Whatever value is set in the workflow frontmatter is always used — the agent cannot override it at runtime.
5960

61+
By default, when a workflow is triggered from an issue, the `create-pull-request` handler automatically appends `- Fixes #N` to the PR description if no closing keyword is already present. This causes GitHub to auto-close the triggering issue when the PR is merged. Set `auto-close-issue: false` to opt out of this behavior — useful for partial-work PRs, multi-PR workflows, or any case where the PR should reference but not close the issue.
62+
6063
PR creation may fail if "Allow GitHub Actions to create and approve pull requests" is disabled in Organization Settings. By default (`fallback-as-issue: true`), fallback creates an issue with branch link. Set `fallback-as-issue: false` to disable fallback.
6164

6265
When `create-pull-request` is configured, git commands (`checkout`, `branch`, `switch`, `add`, `rm`, `commit`, `merge`) are automatically enabled.

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5830,6 +5830,15 @@
58305830
"description": "Controls the fallback behavior when pull request creation fails. When true (default), an issue is created as a fallback with the patch content. When false, no issue is created and the workflow fails with an error. Setting to false also removes the issues:write permission requirement.",
58315831
"default": true
58325832
},
5833+
"auto-close-issue": {
5834+
"allOf": [
5835+
{
5836+
"$ref": "#/$defs/templatable_boolean"
5837+
}
5838+
],
5839+
"description": "When true (default), automatically appends a closing keyword (\"Fixes #N\") to the PR description when the workflow is triggered from an issue and no closing keyword is already present. This causes GitHub to auto-close the triggering issue when the PR is merged. Set to false to prevent this behavior, e.g., for partial-work PRs or multi-PR workflows. Accepts a boolean or a GitHub Actions expression.",
5840+
"default": true
5841+
},
58335842
"github-token-for-extra-empty-commit": {
58345843
"type": "string",
58355844
"description": "Token used to push an empty commit after PR creation to trigger CI events. Works around the GITHUB_TOKEN limitation where pushes don't trigger workflow runs. Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the repository. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a custom token, or 'app' for GitHub App auth."

pkg/workflow/compiler_safe_outputs_config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ var handlerRegistry = map[string]handlerBuilder{
519519
AddIfNotEmpty("github-token", c.GitHubToken).
520520
AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)).
521521
AddBoolPtr("fallback_as_issue", c.FallbackAsIssue).
522+
AddTemplatableBool("auto_close_issue", c.AutoCloseIssue).
522523
AddIfNotEmpty("base_branch", c.BaseBranch).
523524
AddStringPtr("protected_files_policy", c.ManifestFilesPolicy).
524525
AddStringSlice("protected_files", getAllManifestFiles()).

pkg/workflow/create_pull_request.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type CreatePullRequestsConfig struct {
3131
BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified)
3232
Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
3333
FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested.
34+
AutoCloseIssue *string `yaml:"auto-close-issue,omitempty"` // Auto-add "Fixes #N" closing keyword when triggered from an issue (default: true). Set to false to prevent auto-closing the triggering issue on PR merge. Accepts a boolean or a GitHub Actions expression.
3435
GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth.
3536
ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" pushes the branch but creates a review issue.
3637
AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass.
@@ -74,7 +75,7 @@ func (c *Compiler) parsePullRequestsConfig(outputMap map[string]any) *CreatePull
7475

7576
// Pre-process templatable bool fields: convert literal booleans to strings so that
7677
// GitHub Actions expression strings (e.g. "${{ inputs.draft-prs }}") are also accepted.
77-
for _, field := range []string{"draft", "allow-empty", "auto-merge", "footer"} {
78+
for _, field := range []string{"draft", "allow-empty", "auto-merge", "footer", "auto-close-issue"} {
7879
if err := preprocessBoolFieldAsString(configData, field, createPRLog); err != nil {
7980
createPRLog.Printf("Invalid %s value: %v", field, err)
8081
return nil

pkg/workflow/safe_outputs_config_generation_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,79 @@ func TestGenerateSafeOutputsConfigCreatePullRequestBackwardCompat(t *testing.T)
450450
assert.False(t, hasAllowedRepos, "allowed_repos should not be present when not configured")
451451
}
452452

453+
// TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssue tests that auto_close_issue
454+
// is correctly serialized into config.json for create_pull_request.
455+
func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssue(t *testing.T) {
456+
data := &WorkflowData{
457+
SafeOutputs: &SafeOutputsConfig{
458+
CreatePullRequests: &CreatePullRequestsConfig{
459+
BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")},
460+
AutoCloseIssue: strPtr("false"),
461+
},
462+
},
463+
}
464+
465+
result := generateSafeOutputsConfig(data)
466+
require.NotEmpty(t, result, "Expected non-empty config")
467+
468+
var parsed map[string]any
469+
require.NoError(t, json.Unmarshal([]byte(result), &parsed), "Result must be valid JSON")
470+
471+
prConfig, ok := parsed["create_pull_request"].(map[string]any)
472+
require.True(t, ok, "Expected create_pull_request key in config")
473+
474+
assert.Equal(t, false, prConfig["auto_close_issue"], "auto_close_issue should be false")
475+
}
476+
477+
// TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssueExpression tests that
478+
// auto_close_issue supports GitHub Actions expression strings.
479+
func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssueExpression(t *testing.T) {
480+
expr := "${{ inputs.auto-close-issue }}"
481+
data := &WorkflowData{
482+
SafeOutputs: &SafeOutputsConfig{
483+
CreatePullRequests: &CreatePullRequestsConfig{
484+
BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")},
485+
AutoCloseIssue: &expr,
486+
},
487+
},
488+
}
489+
490+
result := generateSafeOutputsConfig(data)
491+
require.NotEmpty(t, result, "Expected non-empty config")
492+
493+
var parsed map[string]any
494+
require.NoError(t, json.Unmarshal([]byte(result), &parsed), "Result must be valid JSON")
495+
496+
prConfig, ok := parsed["create_pull_request"].(map[string]any)
497+
require.True(t, ok, "Expected create_pull_request key in config")
498+
499+
assert.Equal(t, expr, prConfig["auto_close_issue"], "auto_close_issue should be an expression string")
500+
}
501+
502+
// TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssueOmittedByDefault tests that
503+
// auto_close_issue is omitted when not configured (backward compatibility).
504+
func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssueOmittedByDefault(t *testing.T) {
505+
data := &WorkflowData{
506+
SafeOutputs: &SafeOutputsConfig{
507+
CreatePullRequests: &CreatePullRequestsConfig{
508+
BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")},
509+
},
510+
},
511+
}
512+
513+
result := generateSafeOutputsConfig(data)
514+
require.NotEmpty(t, result, "Expected non-empty config")
515+
516+
var parsed map[string]any
517+
require.NoError(t, json.Unmarshal([]byte(result), &parsed), "Result must be valid JSON")
518+
519+
prConfig, ok := parsed["create_pull_request"].(map[string]any)
520+
require.True(t, ok, "Expected create_pull_request key in config")
521+
522+
_, hasAutoCloseIssue := prConfig["auto_close_issue"]
523+
assert.False(t, hasAutoCloseIssue, "auto_close_issue should be absent when not configured")
524+
}
525+
453526
// TestGenerateSafeOutputsConfigRepoMemory tests that generateSafeOutputsConfig includes
454527
// push_repo_memory configuration with the expected memories entries when RepoMemoryConfig is present.
455528
func TestGenerateSafeOutputsConfigRepoMemory(t *testing.T) {

0 commit comments

Comments
 (0)