From 23ac7eee6b65b160001065586b3a323257732893 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 05:08:43 +0000 Subject: [PATCH 1/8] Initial plan From fefbf9cae212c88916c7344df79407d355386486 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 05:34:22 +0000 Subject: [PATCH 2/8] Add set-issue-type safe output type Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/safe_output_handler_manager.cjs | 1 + actions/setup/js/set_issue_type.cjs | 215 ++++++++++++ actions/setup/js/set_issue_type.test.cjs | 311 ++++++++++++++++++ .../setup/js/types/safe-outputs-config.d.ts | 10 + actions/setup/js/types/safe-outputs.d.ts | 13 + pkg/workflow/compiler_safe_outputs_config.go | 21 ++ pkg/workflow/compiler_safe_outputs_job.go | 1 + pkg/workflow/compiler_types.go | 1 + pkg/workflow/imports.go | 5 + pkg/workflow/js/safe_outputs_tools.json | 24 ++ pkg/workflow/safe_output_validation_config.go | 8 + pkg/workflow/safe_outputs_config.go | 6 + pkg/workflow/safe_outputs_permissions.go | 4 + pkg/workflow/safe_outputs_tools_generation.go | 11 +- pkg/workflow/safe_outputs_tools_test.go | 1 + pkg/workflow/set_issue_type.go | 37 +++ 16 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 actions/setup/js/set_issue_type.cjs create mode 100644 actions/setup/js/set_issue_type.test.cjs create mode 100644 pkg/workflow/set_issue_type.go diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 5f7a2845f7f..3a20d3a6f83 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -47,6 +47,7 @@ const HANDLER_MAP = { close_pull_request: "./close_pull_request.cjs", mark_pull_request_as_ready_for_review: "./mark_pull_request_as_ready_for_review.cjs", hide_comment: "./hide_comment.cjs", + set_issue_type: "./set_issue_type.cjs", add_reviewer: "./add_reviewer.cjs", assign_milestone: "./assign_milestone.cjs", assign_to_user: "./assign_to_user.cjs", diff --git a/actions/setup/js/set_issue_type.cjs b/actions/setup/js/set_issue_type.cjs new file mode 100644 index 00000000000..d424eb3b637 --- /dev/null +++ b/actions/setup/js/set_issue_type.cjs @@ -0,0 +1,215 @@ +// @ts-check +/// + +/** + * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction + */ + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); + +/** @type {string} Safe output type handled by this module */ +const HANDLER_TYPE = "set_issue_type"; + +/** + * Fetches the node ID of an issue for use in GraphQL mutations. + * @param {Object} authClient - Authenticated GitHub client + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue number + * @returns {Promise} Issue node ID + */ +async function getIssueNodeId(authClient, owner, repo, issueNumber) { + const { data } = await authClient.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + return data.node_id; +} + +/** + * Fetches the available issue types for the given repository via GraphQL. + * Returns an array of { id, name } objects, or an empty array if not supported. + * @param {Object} authClient - Authenticated GitHub client + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {Promise>} Available issue types + */ +async function fetchIssueTypes(authClient, owner, repo) { + try { + const result = await authClient.graphql( + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issueTypes(first: 100) { + nodes { + id + name + } + } + } + }`, + { owner, repo } + ); + return result?.repository?.issueTypes?.nodes ?? []; + } catch (error) { + // Issue types may not be enabled for this repository/organization + // Log at debug level to aid debugging without being noisy + if (typeof core !== "undefined") { + core.debug(`Could not fetch issue types (may not be enabled): ${error instanceof Error ? error.message : String(error)}`); + } + return []; + } +} + +/** + * Sets the issue type via GraphQL mutation. + * Passing null for typeId clears the type. + * @param {Object} authClient - Authenticated GitHub client + * @param {string} issueNodeId - GraphQL node ID of the issue + * @param {string|null} typeId - GraphQL node ID of the issue type, or null to clear + * @returns {Promise} + */ +async function setIssueTypeById(authClient, issueNodeId, typeId) { + await authClient.graphql( + `mutation($issueId: ID!, $typeId: ID) { + updateIssue(input: { id: $issueId, issueTypeId: $typeId }) { + issue { + id + } + } + }`, + { issueId: issueNodeId, typeId } + ); +} + +/** + * Main handler factory for set_issue_type + * Returns a message handler function that processes individual set_issue_type messages + * @type {HandlerFactoryFunction} + */ +async function main(config = {}) { + // Extract configuration + const allowedTypes = config.allowed || []; + const maxCount = config.max || 5; + const authClient = await createAuthenticatedGitHubClient(config); + + // Check if we're in staged mode + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + + core.info(`Set issue type configuration: max=${maxCount}`); + if (allowedTypes.length > 0) { + core.info(`Allowed issue types: ${allowedTypes.join(", ")}`); + } + + // Track how many items we've processed for max limit + let processedCount = 0; + + /** + * Message handler function that processes a single set_issue_type message + * @param {Object} message - The set_issue_type message to process + * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number} + * @returns {Promise} Result with success/error status + */ + return async function handleSetIssueType(message, resolvedTemporaryIds) { + // Check if we've hit the max limit + if (processedCount >= maxCount) { + core.warning(`Skipping set_issue_type: max count of ${maxCount} reached`); + return { + success: false, + error: `Max count of ${maxCount} reached`, + }; + } + + processedCount++; + + const item = message; + + // Determine target issue number + const issueNumber = item.issue_number !== undefined ? parseInt(String(item.issue_number), 10) : (context.payload?.issue?.number ?? null); + + if (!issueNumber || isNaN(issueNumber) || issueNumber <= 0) { + const error = item.issue_number !== undefined ? `Invalid issue_number: ${item.issue_number}` : "No issue number available"; + core.warning(error); + return { success: false, error }; + } + + const issueTypeName = item.issue_type ?? ""; + const isClear = issueTypeName === ""; + + core.info(`Setting issue type on issue #${issueNumber}: ${isClear ? "(clear)" : JSON.stringify(issueTypeName)}`); + + // Validate against allowed list if configured (empty string always allowed to clear) + if (allowedTypes.length > 0 && !isClear) { + const normalizedAllowed = allowedTypes.map(t => t.toLowerCase()); + if (!normalizedAllowed.includes(issueTypeName.toLowerCase())) { + const error = `Issue type ${JSON.stringify(issueTypeName)} is not in the allowed list: ${JSON.stringify(allowedTypes)}`; + core.warning(error); + return { success: false, error }; + } + } + + // If in staged mode, preview without executing + if (isStaged) { + const description = isClear ? `Would clear issue type on issue #${issueNumber}` : `Would set issue type to ${JSON.stringify(issueTypeName)} on issue #${issueNumber}`; + logStagedPreviewInfo(description); + return { + success: true, + staged: true, + previewInfo: { + issue_number: issueNumber, + issue_type: issueTypeName, + }, + }; + } + + try { + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Get the issue's node ID for GraphQL + const issueNodeId = await getIssueNodeId(authClient, owner, repo, issueNumber); + + let typeId = null; + if (!isClear) { + // Fetch available issue types and find the matching one + const issueTypes = await fetchIssueTypes(authClient, owner, repo); + + if (issueTypes.length === 0) { + const error = "No issue types are available for this repository. Issue types must be configured in the repository or organization settings."; + core.error(error); + return { success: false, error }; + } + + const matchedType = issueTypes.find(t => t.name.toLowerCase() === issueTypeName.toLowerCase()); + if (!matchedType) { + const availableNames = issueTypes.map(t => t.name).join(", "); + const error = `Issue type ${JSON.stringify(issueTypeName)} not found. Available types: ${availableNames}`; + core.error(error); + return { success: false, error }; + } + + typeId = matchedType.id; + core.info(`Resolved issue type ${JSON.stringify(issueTypeName)} to node ID: ${typeId}`); + } + + await setIssueTypeById(authClient, issueNodeId, typeId); + + const successMsg = isClear ? `Successfully cleared issue type on issue #${issueNumber}` : `Successfully set issue type to ${JSON.stringify(issueTypeName)} on issue #${issueNumber}`; + core.info(successMsg); + + return { + success: true, + issue_number: issueNumber, + issue_type: issueTypeName, + }; + } catch (error) { + const errorMessage = getErrorMessage(error); + core.error(`Failed to set issue type on issue #${issueNumber}: ${errorMessage}`); + return { success: false, error: errorMessage }; + } + }; +} + +module.exports = { main }; diff --git a/actions/setup/js/set_issue_type.test.cjs b/actions/setup/js/set_issue_type.test.cjs new file mode 100644 index 00000000000..a2b8a1e9a3e --- /dev/null +++ b/actions/setup/js/set_issue_type.test.cjs @@ -0,0 +1,311 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +const mockContext = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + eventName: "issues", + payload: { + issue: { + number: 123, + }, + }, +}; + +const mockGraphql = vi.fn(); + +const mockGithub = { + rest: { + issues: { + get: vi.fn(), + }, + }, + graphql: mockGraphql, +}; + +global.core = mockCore; +global.context = mockContext; +global.github = mockGithub; + +describe("set_issue_type (Handler Factory Architecture)", () => { + let handler; + + const issueNodeId = "I_kwDOABCD123456"; + const bugTypeId = "IT_kwDOABCD_bug"; + const featureTypeId = "IT_kwDOABCD_feature"; + + const mockIssueTypesQuery = { + repository: { + issueTypes: { + nodes: [ + { id: bugTypeId, name: "Bug" }, + { id: featureTypeId, name: "Feature" }, + ], + }, + }, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockGithub.rest.issues.get.mockResolvedValue({ data: { node_id: issueNodeId } }); + mockGraphql.mockImplementation(query => { + if (query.includes("issueTypes")) { + return Promise.resolve(mockIssueTypesQuery); + } + if (query.includes("updateIssue")) { + return Promise.resolve({ updateIssue: { issue: { id: issueNodeId } } }); + } + return Promise.resolve({}); + }); + + const { main } = require("./set_issue_type.cjs"); + handler = await main({ max: 5 }); + }); + + it("should return a function from main()", async () => { + const { main } = require("./set_issue_type.cjs"); + const result = await main({}); + expect(typeof result).toBe("function"); + }); + + it("should set issue type successfully", async () => { + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "Bug", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issue_number).toBe(42); + expect(result.issue_type).toBe("Bug"); + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 42, + }); + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.objectContaining({ issueId: issueNodeId, typeId: bugTypeId })); + }); + + it("should clear issue type when issue_type is empty string", async () => { + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issue_type).toBe(""); + // Should call mutation with null typeId to clear + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.objectContaining({ issueId: issueNodeId, typeId: null })); + // Should NOT fetch issue types when clearing + expect(mockGraphql).not.toHaveBeenCalledWith(expect.stringContaining("issueTypes"), expect.anything()); + }); + + it("should use context issue number when issue_number not provided", async () => { + const message = { + type: "set_issue_type", + issue_type: "Bug", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issue_number).toBe(123); // from context.payload.issue.number + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + }); + }); + + it("should validate against allowed types list", async () => { + const { main } = require("./set_issue_type.cjs"); + const handlerWithAllowed = await main({ + max: 5, + allowed: ["Bug", "Feature"], + }); + + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "Bug", + }; + + const result = await handlerWithAllowed(message, {}); + expect(result.success).toBe(true); + }); + + it("should reject type not in allowed list", async () => { + const { main } = require("./set_issue_type.cjs"); + const handlerWithAllowed = await main({ + max: 5, + allowed: ["Bug", "Feature"], + }); + + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "Task", + }; + + const result = await handlerWithAllowed(message, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("is not in the allowed list"); + }); + + it("should allow clearing type even with allowed list configured", async () => { + const { main } = require("./set_issue_type.cjs"); + const handlerWithAllowed = await main({ + max: 5, + allowed: ["Bug", "Feature"], + }); + + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "", + }; + + const result = await handlerWithAllowed(message, {}); + expect(result.success).toBe(true); + }); + + it("should return error when issue type not found in repository", async () => { + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "NonExistentType", + }; + + const result = await handler(message, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + expect(result.error).toContain("Available types"); + }); + + it("should return error when no issue types are available", async () => { + mockGraphql.mockImplementation(query => { + if (query.includes("issueTypes")) { + return Promise.resolve({ repository: { issueTypes: { nodes: [] } } }); + } + return Promise.resolve({}); + }); + + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "Bug", + }; + + const result = await handler(message, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("No issue types are available"); + }); + + it("should respect max count configuration", async () => { + const { main } = require("./set_issue_type.cjs"); + const limitedHandler = await main({ max: 1 }); + + const message1 = { type: "set_issue_type", issue_number: 1, issue_type: "Bug" }; + const message2 = { type: "set_issue_type", issue_number: 2, issue_type: "Feature" }; + + const result1 = await limitedHandler(message1, {}); + expect(result1.success).toBe(true); + + const result2 = await limitedHandler(message2, {}); + expect(result2.success).toBe(false); + expect(result2.error).toContain("Max count"); + }); + + it("should handle API errors gracefully", async () => { + mockGraphql.mockImplementation(query => { + if (query.includes("issueTypes")) { + return Promise.resolve(mockIssueTypesQuery); + } + if (query.includes("updateIssue")) { + return Promise.reject(new Error("GraphQL API error")); + } + return Promise.resolve({}); + }); + + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "Bug", + }; + + const result = await handler(message, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("GraphQL API error"); + }); + + it("should handle invalid issue numbers", async () => { + const message = { + type: "set_issue_type", + issue_number: -1, + issue_type: "Bug", + }; + + const result = await handler(message, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid issue_number"); + }); + + it("should handle staged mode", async () => { + process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"; + + try { + const { main } = require("./set_issue_type.cjs"); + const stagedHandler = await main({ max: 5 }); + + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "Bug", + }; + + const result = await stagedHandler(message, {}); + expect(result.success).toBe(true); + expect(result.staged).toBe(true); + expect(result.previewInfo.issue_number).toBe(42); + expect(result.previewInfo.issue_type).toBe("Bug"); + // Should not call any API when staged + expect(mockGithub.rest.issues.get).not.toHaveBeenCalled(); + expect(mockGraphql).not.toHaveBeenCalled(); + } finally { + delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; + } + }); + + it("should handle case-insensitive type matching", async () => { + const message = { + type: "set_issue_type", + issue_number: 42, + issue_type: "bug", // lowercase + }; + + const result = await handler(message, {}); + expect(result.success).toBe(true); + // Should still resolve to the Bug type + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.objectContaining({ typeId: bugTypeId })); + }); +}); diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index 623a7ad5aff..b3635fbf72b 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -214,6 +214,14 @@ interface AssignMilestoneConfig extends SafeOutputConfig { target?: string; } +/** + * Configuration for setting the type of an issue + */ +interface SetIssueTypeConfig extends SafeOutputConfig { + allowed?: string[]; + target?: string; +} + /** * Configuration for assigning agents to issues */ @@ -319,6 +327,7 @@ type SpecificSafeOutputConfig = | PushToPullRequestBranchConfig | UploadAssetConfig | AssignMilestoneConfig + | SetIssueTypeConfig | AssignToAgentConfig | UpdateReleaseConfig | NoOpConfig @@ -354,6 +363,7 @@ export { PushToPullRequestBranchConfig, UploadAssetConfig, AssignMilestoneConfig, + SetIssueTypeConfig, AssignToAgentConfig, UpdateReleaseConfig, NoOpConfig, diff --git a/actions/setup/js/types/safe-outputs.d.ts b/actions/setup/js/types/safe-outputs.d.ts index 7e1ca5a9022..03acc4d038d 100644 --- a/actions/setup/js/types/safe-outputs.d.ts +++ b/actions/setup/js/types/safe-outputs.d.ts @@ -271,6 +271,17 @@ interface AssignMilestoneItem extends BaseSafeOutputItem { milestone_number: number | string; } +/** + * JSONL item for setting the type of a GitHub issue + */ +interface SetIssueTypeItem extends BaseSafeOutputItem { + type: "set_issue_type"; + /** Issue type name to set (e.g., "Bug", "Feature"). Use empty string "" to clear the type. */ + issue_type: string; + /** Issue number (optional - uses triggering issue if not provided) */ + issue_number?: number | string; +} + /** * JSONL item for assigning a GitHub Copilot coding agent to an issue or project item */ @@ -400,6 +411,7 @@ type SafeOutputItem = | MissingToolItem | UploadAssetItem | AssignMilestoneItem + | SetIssueTypeItem | AssignToAgentItem | UpdateReleaseItem | NoOpItem @@ -441,6 +453,7 @@ export { MissingToolItem, UploadAssetItem, AssignMilestoneItem, + SetIssueTypeItem, AssignToAgentItem, UpdateReleaseItem, NoOpItem, diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 4425a9f88fa..5d8ca16f9ca 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -681,6 +681,27 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("project", c.Project). Build() }, + "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.SetIssueType == nil { + return nil + } + c := cfg.SetIssueType + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + Build() + // If config is empty, it means set_issue_type was explicitly configured with no options + // (null config), which means "allow any type". Return non-nil empty map to + // indicate the handler is enabled. + if len(config) == 0 { + return make(map[string]any) + } + return config + }, } func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *WorkflowData) { diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index e7f874af230..58f1b7aac63 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -134,6 +134,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa data.SafeOutputs.ClosePullRequests != nil || data.SafeOutputs.MarkPullRequestAsReadyForReview != nil || data.SafeOutputs.HideComment != nil || + data.SafeOutputs.SetIssueType != nil || data.SafeOutputs.DispatchWorkflow != nil || data.SafeOutputs.CreateCodeScanningAlerts != nil || data.SafeOutputs.AutofixCodeScanningAlert != nil || diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index f96fae0b86d..0d933321050 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -472,6 +472,7 @@ type SafeOutputsConfig struct { CreateProjectStatusUpdates *CreateProjectStatusUpdateConfig `yaml:"create-project-status-update,omitempty"` // Create GitHub project status updates LinkSubIssue *LinkSubIssueConfig `yaml:"link-sub-issue,omitempty"` // Link issues as sub-issues HideComment *HideCommentConfig `yaml:"hide-comment,omitempty"` // Hide comments + SetIssueType *SetIssueTypeConfig `yaml:"set-issue-type,omitempty"` // Set the type of an issue (empty string clears the type) DispatchWorkflow *DispatchWorkflowConfig `yaml:"dispatch-workflow,omitempty"` // Dispatch workflow_dispatch events to other workflows MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality MissingData *MissingDataConfig `yaml:"missing-data,omitempty"` // Optional for reporting missing data required to achieve goals diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go index f39c03ac971..17acdb5b989 100644 --- a/pkg/workflow/imports.go +++ b/pkg/workflow/imports.go @@ -493,6 +493,8 @@ func hasSafeOutputType(config *SafeOutputsConfig, key string) bool { return config.LinkSubIssue != nil case "hide-comment": return config.HideComment != nil + case "set-issue-type": + return config.SetIssueType != nil case "dispatch-workflow": return config.DispatchWorkflow != nil case "missing-data": @@ -618,6 +620,9 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c * if result.HideComment == nil && importedConfig.HideComment != nil { result.HideComment = importedConfig.HideComment } + if result.SetIssueType == nil && importedConfig.SetIssueType != nil { + result.SetIssueType = importedConfig.SetIssueType + } if result.DispatchWorkflow == nil && importedConfig.DispatchWorkflow != nil { result.DispatchWorkflow = importedConfig.DispatchWorkflow } diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 70eca8e27bf..ad771c035fe 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -889,6 +889,30 @@ "additionalProperties": false } }, + { + "name": "set_issue_type", + "description": "Set the type of a GitHub issue. Pass an empty string \"\" to clear the issue type. Issue types must be configured in the repository or organization settings before they can be assigned.", + "inputSchema": { + "type": "object", + "required": [ + "issue_type" + ], + "properties": { + "issue_number": { + "type": [ + "number", + "string" + ], + "description": "Issue number to set the type for. If omitted, sets the type on the issue that triggered this workflow." + }, + "issue_type": { + "type": "string", + "description": "Issue type name to set (e.g., \"Bug\", \"Feature\", \"Task\"). Use an empty string \"\" to clear the current issue type." + } + }, + "additionalProperties": false + } + }, { "name": "update_project", "description": "Manage GitHub Projects: add issues/pull requests/draft issues, update item fields (status, priority, effort, dates), manage custom fields, and create project views. Use this to organize work by adding items to projects, updating field values, creating custom fields up-front, and setting up project views (table, board, roadmap).\n\nThree modes: (1) Add or update project items with custom field values; (2) Create project fields; (3) Create project views. This is the primary tool for ProjectOps automation - add items to projects, set custom fields for tracking, and organize project boards.", diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 998778f4f6e..32603bdd0f7 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -104,6 +104,14 @@ var ValidationConfig = map[string]TypeValidationConfig{ "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" }, }, + "set_issue_type": { + DefaultMax: 5, + Fields: map[string]FieldValidation{ + "issue_number": {IssueOrPRNumber: true}, + "issue_type": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, // Empty string clears the type + "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" + }, + }, "assign_to_agent": { DefaultMax: 1, CustomValidation: "requiresOneOf:issue_number,pull_number", diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 6f555b644e4..af21f34e29f 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -287,6 +287,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.HideComment = hideCommentConfig } + // Handle set-issue-type + setIssueTypeConfig := c.parseSetIssueTypeConfig(outputMap) + if setIssueTypeConfig != nil { + config.SetIssueType = setIssueTypeConfig + } + // Handle dispatch-workflow dispatchWorkflowConfig := c.parseDispatchWorkflowConfig(outputMap) if dispatchWorkflowConfig != nil { diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index fa965e53d83..833eea6493c 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -190,6 +190,10 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio safeOutputsPermissionsLog.Print("Adding permissions for assign-milestone") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } + if safeOutputs.SetIssueType != nil { + safeOutputsPermissionsLog.Print("Adding permissions for set-issue-type") + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } if safeOutputs.AddReviewer != nil { safeOutputsPermissionsLog.Print("Adding permissions for add-reviewer") permissions.Merge(NewPermissionsContentsReadPRWrite()) diff --git a/pkg/workflow/safe_outputs_tools_generation.go b/pkg/workflow/safe_outputs_tools_generation.go index 95ccf6e05fe..690938751b4 100644 --- a/pkg/workflow/safe_outputs_tools_generation.go +++ b/pkg/workflow/safe_outputs_tools_generation.go @@ -165,6 +165,9 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, if data.SafeOutputs.HideComment != nil { enabledTools["hide_comment"] = true } + if data.SafeOutputs.SetIssueType != nil { + enabledTools["set_issue_type"] = true + } if data.SafeOutputs.UpdateProjects != nil { enabledTools["update_project"] = true } @@ -373,7 +376,8 @@ func addRepoParameterIfNeeded(tool map[string]any, toolName string, safeOutputs targetRepoSlug = config.TargetRepoSlug } case "add_labels", "remove_labels", "hide_comment", "link_sub_issue", "mark_pull_request_as_ready_for_review", - "add_reviewer", "assign_milestone", "assign_to_agent", "assign_to_user", "unassign_from_user": + "add_reviewer", "assign_milestone", "assign_to_agent", "assign_to_user", "unassign_from_user", + "set_issue_type": // These use SafeOutputTargetConfig - check the appropriate config switch toolName { case "add_labels": @@ -426,6 +430,11 @@ func addRepoParameterIfNeeded(tool map[string]any, toolName string, safeOutputs hasAllowedRepos = len(config.AllowedRepos) > 0 targetRepoSlug = config.TargetRepoSlug } + case "set_issue_type": + if config := safeOutputs.SetIssueType; config != nil { + hasAllowedRepos = len(config.AllowedRepos) > 0 + targetRepoSlug = config.TargetRepoSlug + } } } diff --git a/pkg/workflow/safe_outputs_tools_test.go b/pkg/workflow/safe_outputs_tools_test.go index 386c8007be7..ae07d21cfc2 100644 --- a/pkg/workflow/safe_outputs_tools_test.go +++ b/pkg/workflow/safe_outputs_tools_test.go @@ -334,6 +334,7 @@ func TestGetSafeOutputsToolsJSON(t *testing.T) { "update_release", "link_sub_issue", "hide_comment", + "set_issue_type", "update_project", "create_project", "create_project_status_update", diff --git a/pkg/workflow/set_issue_type.go b/pkg/workflow/set_issue_type.go new file mode 100644 index 00000000000..8a6eb1eb03b --- /dev/null +++ b/pkg/workflow/set_issue_type.go @@ -0,0 +1,37 @@ +package workflow + +import ( + "github.com/github/gh-aw/pkg/logger" +) + +var setIssueTypeLog = logger.New("workflow:set_issue_type") + +// SetIssueTypeConfig holds configuration for setting the type of an issue from agent output +type SetIssueTypeConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` + Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed issue type names. If omitted, any type is allowed (including clearing with ""). +} + +// parseSetIssueTypeConfig handles set-issue-type configuration +func (c *Compiler) parseSetIssueTypeConfig(outputMap map[string]any) *SetIssueTypeConfig { + // Check if the key exists + if _, exists := outputMap["set-issue-type"]; !exists { + return nil + } + + setIssueTypeLog.Print("Parsing set-issue-type configuration") + + // Unmarshal into typed config struct + var config SetIssueTypeConfig + if err := unmarshalConfig(outputMap, "set-issue-type", &config, setIssueTypeLog); err != nil { + setIssueTypeLog.Printf("Failed to unmarshal config: %v", err) + // Handle null case: create empty config (allows any type) + setIssueTypeLog.Print("Using empty configuration (allows any issue type)") + return &SetIssueTypeConfig{} + } + + setIssueTypeLog.Printf("Parsed configuration: allowed_count=%d, target=%s", len(config.Allowed), config.Target) + + return &config +} From e0f020efd473ce7b3f2a9690cc6bd42cc1dd04f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 05:47:53 +0000 Subject: [PATCH 3/8] Add set-issue-type to smoke-copilot and create test workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.lock.yml | 28 ++++++++++- .github/workflows/smoke-copilot.md | 2 + .../workflows/test-copilot-set-issue-type.md | 17 +++++++ pkg/parser/schemas/main_workflow_schema.json | 49 ++++++++++++++++++- 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 pkg/cli/workflows/test-copilot-set-issue-type.md diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index eafde056780..20bf7328ac5 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-safe-input.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"2db010ebae2dafeeecf613a4430a7b8159d150f79ab54eeddc32fce6b932ac3f"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"4627e6a885992491762dac46a6ad592a73d411faecda2b2d02be4895fa36c1dc"} name: "Smoke Copilot" "on": @@ -711,6 +711,30 @@ jobs: }, "name": "noop" }, + { + "description": "Set the type of a GitHub issue. Pass an empty string \"\" to clear the issue type. Issue types must be configured in the repository or organization settings before they can be assigned.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "issue_number": { + "description": "Issue number to set the type for. If omitted, sets the type on the issue that triggered this workflow.", + "type": [ + "number", + "string" + ] + }, + "issue_type": { + "description": "Issue type name to set (e.g., \"Bug\", \"Feature\", \"Task\"). Use an empty string \"\" to clear the current issue type.", + "type": "string" + } + }, + "required": [ + "issue_type" + ], + "type": "object" + }, + "name": "set_issue_type" + }, { "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", "inputSchema": { @@ -2170,7 +2194,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,playwright.download.prss.microsoft.com,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"allowed_repos\":[\"github/gh-aw\"],\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-copilot\"],\"allowed_repos\":[\"github/gh-aw\"]},\"create_discussion\":{\"category\":\"announcements\",\"close_older_discussions\":true,\"expires\":2,\"fallback_to_issue\":true,\"labels\":[\"ai-generated\"],\"max\":1},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\"},\"dispatch_workflow\":{\"max\":1,\"workflow_files\":{\"haiku-printer\":\".yml\"},\"workflows\":[\"haiku-printer\"]},\"missing_data\":{},\"missing_tool\":{},\"remove_labels\":{\"allowed\":[\"smoke\"]},\"submit_pull_request_review\":{\"max\":1}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"allowed_repos\":[\"github/gh-aw\"],\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-copilot\"],\"allowed_repos\":[\"github/gh-aw\"]},\"create_discussion\":{\"category\":\"announcements\",\"close_older_discussions\":true,\"expires\":2,\"fallback_to_issue\":true,\"labels\":[\"ai-generated\"],\"max\":1},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\"},\"dispatch_workflow\":{\"max\":1,\"workflow_files\":{\"haiku-printer\":\".yml\"},\"workflows\":[\"haiku-printer\"]},\"missing_data\":{},\"missing_tool\":{},\"remove_labels\":{\"allowed\":[\"smoke\"]},\"set_issue_type\":{},\"submit_pull_request_review\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index cafac347b6f..825f97edd3a 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -70,6 +70,7 @@ safe-outputs: allowed-repos: ["github/gh-aw"] remove-labels: allowed: [smoke] + set-issue-type: dispatch-workflow: workflows: - haiku-printer @@ -137,6 +138,7 @@ features: 10. **Discussion Creation Testing**: Use the `create_discussion` safe-output tool to create a discussion in the announcements category titled "copilot was here" with the label "ai-generated" 11. **Workflow Dispatch Testing**: Use the `dispatch_workflow` safe output tool to trigger the `haiku-printer` workflow with a haiku as the message input. Create an original, creative haiku about software testing or automation. 12. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. +13. **Set Issue Type Testing**: Use the `set_issue_type` safe-output tool to set the type of the issue created in step 1 to "Bug". Use the temporary ID from step 1 to reference the issue. ## Output diff --git a/pkg/cli/workflows/test-copilot-set-issue-type.md b/pkg/cli/workflows/test-copilot-set-issue-type.md new file mode 100644 index 00000000000..bc7c4430221 --- /dev/null +++ b/pkg/cli/workflows/test-copilot-set-issue-type.md @@ -0,0 +1,17 @@ +--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + set-issue-type: + allowed: ["Bug", "Feature", "Task"] + max: 2 +--- + +# Test Copilot Set Issue Type + +This workflow tests the set-issue-type safe output type with Copilot engine. + +Please set the type of issue #1 to "Bug". diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index dee5868af64..bd88de5f400 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3992,7 +3992,7 @@ }, "safe-outputs": { "type": "object", - "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: autofix-code-scanning-alert, add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, assign-to-user, close-discussion, close-issue, close-pull-request, create-agent-session, create-agent-task (deprecated, use create-agent-session), create-code-scanning-alert, create-discussion, create-issue, create-project, create-project-status-update, create-pull-request, create-pull-request-review-comment, dispatch-workflow, hide-comment, link-sub-issue, mark-pull-request-as-ready-for-review, missing-data, missing-tool, noop, push-to-pull-request-branch, remove-labels, reply-to-pull-request-review-comment, resolve-pull-request-review-thread, submit-pull-request-review, threat-detection, unassign-from-user, update-discussion, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", + "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: autofix-code-scanning-alert, add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, assign-to-user, close-discussion, close-issue, close-pull-request, create-agent-session, create-agent-task (deprecated, use create-agent-session), create-code-scanning-alert, create-discussion, create-issue, create-project, create-project-status-update, create-pull-request, create-pull-request-review-comment, dispatch-workflow, hide-comment, link-sub-issue, mark-pull-request-as-ready-for-review, missing-data, missing-tool, noop, push-to-pull-request-branch, remove-labels, reply-to-pull-request-review-comment, resolve-pull-request-review-thread, set-issue-type, submit-pull-request-review, threat-detection, unassign-from-user, update-discussion, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", "description": "Safe output processing configuration that automatically creates GitHub issues, comments, and pull requests from AI workflow output without requiring write permissions in the main job", "examples": [ { @@ -6301,6 +6301,53 @@ ], "description": "Enable AI agents to minimize (hide) comments on issues or pull requests based on relevance, spam detection, or moderation rules." }, + "set-issue-type": { + "oneOf": [ + { + "type": "null", + "description": "Null configuration allows setting any issue type" + }, + { + "type": "object", + "description": "Configuration for setting the type of GitHub issues from agentic workflow output", + "properties": { + "allowed": { + "type": "array", + "description": "Optional list of allowed issue type names (e.g. 'Bug', 'Feature'). If omitted, any type is allowed. Empty string is always allowed to clear the type.", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 50 + }, + "max": { + "description": "Optional maximum number of set-issue-type operations (default: 5). Supports integer or GitHub Actions expression (e.g. '${{ inputs.max }}').", + "oneOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression that resolves to an integer at runtime" + } + ] + }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository issue type setting." + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false + } + ], + "description": "Enable AI agents to set or clear the type of GitHub issues. Use an empty string to clear the current type." + }, "dispatch-workflow": { "oneOf": [ { From b676cb0c0e935cedc8e0da885d374181ec5f44fb Mon Sep 17 00:00:00 2001 From: Coder Date: Sun, 1 Mar 2026 05:53:54 +0000 Subject: [PATCH 4/8] Add changeset [skip-ci] --- .changeset/patch-add-set-issue-type-safe-output.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-add-set-issue-type-safe-output.md diff --git a/.changeset/patch-add-set-issue-type-safe-output.md b/.changeset/patch-add-set-issue-type-safe-output.md new file mode 100644 index 00000000000..946ae0f04b9 --- /dev/null +++ b/.changeset/patch-add-set-issue-type-safe-output.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Added a `set-issue-type` safe output type so workflows can set or clear GitHub issue types with optional allowlists. From 54a075f01d8839276a1ff7c520926ec52c86c0ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 05:55:11 +0000 Subject: [PATCH 5/8] ci: trigger CI checks From 9a96ab212cc2adee4cf65cd66390879ae255bfd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:11:57 +0000 Subject: [PATCH 6/8] Fix set_issue_type handler: add temporary ID support and repo targeting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.md | 10 +-- actions/setup/js/set_issue_type.cjs | 68 +++++++++++++++++--- pkg/parser/schemas/main_workflow_schema.json | 13 +++- pkg/workflow/set_issue_type.go | 6 +- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 825f97edd3a..368a2e4f409 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -138,11 +138,11 @@ features: 10. **Discussion Creation Testing**: Use the `create_discussion` safe-output tool to create a discussion in the announcements category titled "copilot was here" with the label "ai-generated" 11. **Workflow Dispatch Testing**: Use the `dispatch_workflow` safe output tool to trigger the `haiku-printer` workflow with a haiku as the message input. Create an original, creative haiku about software testing or automation. 12. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. -13. **Set Issue Type Testing**: Use the `set_issue_type` safe-output tool to set the type of the issue created in step 1 to "Bug". Use the temporary ID from step 1 to reference the issue. ## Output 1. **Create an issue** with a summary of the smoke test run: + - Use a temporary ID (e.g. `aw_smoke1`) for the issue so you can reference it later - Title: "Smoke Test: Copilot - ${{ github.run_id }}" - Body should include: - Test results (✅ or ❌ for each test) @@ -151,15 +151,17 @@ features: - Timestamp - Pull request author and assignees -2. **Only if this workflow was triggered by a pull_request event**: Use the `add_comment` tool to add a **very brief** comment (max 5-10 lines) to the triggering pull request (omit the `item_number` parameter to auto-target the triggering PR) with: +2. **Set Issue Type**: Use the `set_issue_type` safe-output tool with `issue_number: "aw_smoke1"` (the temporary ID from step 1) and `issue_type: "Bug"` to set the type of the just-created smoke test issue. + +3. **Only if this workflow was triggered by a pull_request event**: Use the `add_comment` tool to add a **very brief** comment (max 5-10 lines) to the triggering pull request (omit the `item_number` parameter to auto-target the triggering PR) with: - PR titles only (no descriptions) - ✅ or ❌ for each test result - Overall status: PASS or FAIL - Mention the pull request author and any assignees -3. Use the `add_comment` tool to add a **fun and creative comment** to the latest discussion (using the `discussion_number` you extracted in step 8) - be playful and entertaining in your comment +4. Use the `add_comment` tool to add a **fun and creative comment** to the latest discussion (using the `discussion_number` you extracted in step 8) - be playful and entertaining in your comment -4. Use the `send_slack_message` tool to send a brief summary message (e.g., "Smoke test ${{ github.run_id }}: All tests passed! ✅") +5. Use the `send_slack_message` tool to send a brief summary message (e.g., "Smoke test ${{ github.run_id }}: All tests passed! ✅") If all tests pass and this workflow was triggered by a pull_request event: - Use the `add_labels` safe-output tool to add the label `smoke-copilot` to the pull request (omit the `item_number` parameter to auto-target the triggering PR) diff --git a/actions/setup/js/set_issue_type.cjs b/actions/setup/js/set_issue_type.cjs index d424eb3b637..c9626ff0b1e 100644 --- a/actions/setup/js/set_issue_type.cjs +++ b/actions/setup/js/set_issue_type.cjs @@ -6,8 +6,10 @@ */ const { getErrorMessage } = require("./error_helpers.cjs"); +const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); +const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs"); /** @type {string} Safe output type handled by this module */ const HANDLER_TYPE = "set_issue_type"; @@ -93,6 +95,7 @@ async function main(config = {}) { // Extract configuration const allowedTypes = config.allowed || []; const maxCount = config.max || 5; + const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const authClient = await createAuthenticatedGitHubClient(config); // Check if we're in staged mode @@ -102,6 +105,10 @@ async function main(config = {}) { if (allowedTypes.length > 0) { core.info(`Allowed issue types: ${allowedTypes.join(", ")}`); } + core.info(`Default target repo: ${defaultTargetRepo}`); + if (allowedRepos.size > 0) { + core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); + } // Track how many items we've processed for max limit let processedCount = 0; @@ -126,13 +133,55 @@ async function main(config = {}) { const item = message; - // Determine target issue number - const issueNumber = item.issue_number !== undefined ? parseInt(String(item.issue_number), 10) : (context.payload?.issue?.number ?? null); + // Build temporary ID map from resolved IDs + const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds); + + // Resolve and validate target repository + const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "issue"); + if (!repoResult.success) { + core.warning(`Skipping set_issue_type: ${repoResult.error}`); + return { + success: false, + error: repoResult.error, + }; + } + const { repo: itemRepo, repoParts } = repoResult; + core.info(`Target repository: ${itemRepo}`); + + // Determine target issue number, with temporary ID support + let issueNumber; + if (item.issue_number !== undefined && item.issue_number !== null) { + const resolvedTarget = resolveRepoIssueTarget(item.issue_number, temporaryIdMap, repoParts.owner, repoParts.repo); + + if (resolvedTarget.wasTemporaryId && !resolvedTarget.resolved) { + core.info(`Deferring set_issue_type: unresolved temporary ID (${item.issue_number})`); + return { + success: false, + deferred: true, + error: resolvedTarget.errorMessage || `Unresolved temporary ID: ${item.issue_number}`, + }; + } + + if (resolvedTarget.errorMessage || !resolvedTarget.resolved) { + core.warning(`Invalid issue_number: ${item.issue_number}`); + return { + success: false, + error: `Invalid issue_number: ${item.issue_number}`, + }; + } - if (!issueNumber || isNaN(issueNumber) || issueNumber <= 0) { - const error = item.issue_number !== undefined ? `Invalid issue_number: ${item.issue_number}` : "No issue number available"; - core.warning(error); - return { success: false, error }; + issueNumber = resolvedTarget.resolved.number; + core.info(`Resolved issue number: #${issueNumber}`); + } else { + const contextIssueNumber = context.payload?.issue?.number; + if (!contextIssueNumber) { + core.warning("No issue_number provided and not in issue context"); + return { + success: false, + error: "No issue number available", + }; + } + issueNumber = contextIssueNumber; } const issueTypeName = item.issue_type ?? ""; @@ -152,7 +201,7 @@ async function main(config = {}) { // If in staged mode, preview without executing if (isStaged) { - const description = isClear ? `Would clear issue type on issue #${issueNumber}` : `Would set issue type to ${JSON.stringify(issueTypeName)} on issue #${issueNumber}`; + const description = isClear ? `Would clear issue type on issue #${issueNumber} in ${itemRepo}` : `Would set issue type to ${JSON.stringify(issueTypeName)} on issue #${issueNumber} in ${itemRepo}`; logStagedPreviewInfo(description); return { success: true, @@ -160,13 +209,13 @@ async function main(config = {}) { previewInfo: { issue_number: issueNumber, issue_type: issueTypeName, + repo: itemRepo, }, }; } try { - const owner = context.repo.owner; - const repo = context.repo.repo; + const { owner, repo } = repoParts; // Get the issue's node ID for GraphQL const issueNodeId = await getIssueNodeId(authClient, owner, repo, issueNumber); @@ -203,6 +252,7 @@ async function main(config = {}) { success: true, issue_number: issueNumber, issue_type: issueTypeName, + repo: itemRepo, }; } catch (error) { const errorMessage = getErrorMessage(error); diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index bd88de5f400..0b26c1a8523 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6334,9 +6334,20 @@ } ] }, + "target": { + "type": "string", + "description": "Target for issue type: 'triggering' (default), '*' (any issue), or explicit issue number" + }, "target-repo": { "type": "string", - "description": "Target repository in format 'owner/repo' for cross-repository issue type setting." + "description": "Target repository in format 'owner/repo' for cross-repository issue type setting. Takes precedence over trial target repo settings." + }, + "allowed-repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of additional repositories in format 'owner/repo' where issue types can be set. When specified, the agent can use a 'repo' field in the output to specify which repository to target. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { "$ref": "#/$defs/github_token", diff --git a/pkg/workflow/set_issue_type.go b/pkg/workflow/set_issue_type.go index 8a6eb1eb03b..116518da2fb 100644 --- a/pkg/workflow/set_issue_type.go +++ b/pkg/workflow/set_issue_type.go @@ -25,10 +25,8 @@ func (c *Compiler) parseSetIssueTypeConfig(outputMap map[string]any) *SetIssueTy // Unmarshal into typed config struct var config SetIssueTypeConfig if err := unmarshalConfig(outputMap, "set-issue-type", &config, setIssueTypeLog); err != nil { - setIssueTypeLog.Printf("Failed to unmarshal config: %v", err) - // Handle null case: create empty config (allows any type) - setIssueTypeLog.Print("Using empty configuration (allows any issue type)") - return &SetIssueTypeConfig{} + setIssueTypeLog.Printf("Failed to unmarshal set-issue-type config, disabling handler: %v", err) + return nil } setIssueTypeLog.Printf("Parsed configuration: allowed_count=%d, target=%s", len(config.Allowed), config.Target) From 414e71d6a79c5e734b72955f7327dcd6ac4c6890 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:42:28 +0000 Subject: [PATCH 7/8] Fix set_issue_type missing from prompt tools list; add compiled lock files Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.lock.yml | 2 +- pkg/workflow/unified_prompt_step.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 20bf7328ac5..f5629048046 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -171,7 +171,7 @@ jobs: cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' - Tools: add_comment, create_issue, create_discussion, create_pull_request_review_comment, submit_pull_request_review, add_labels, remove_labels, dispatch_workflow, missing_tool, missing_data, noop + Tools: add_comment, create_issue, create_discussion, create_pull_request_review_comment, submit_pull_request_review, add_labels, remove_labels, set_issue_type, dispatch_workflow, missing_tool, missing_data, noop The following GitHub context information is available for this workflow: diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 3a2b3ae8164..d045c046345 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -711,6 +711,9 @@ func buildSafeOutputsSections(safeOutputs *SafeOutputsConfig) []PromptSection { if safeOutputs.HideComment != nil { tools = append(tools, "hide_comment") } + if safeOutputs.SetIssueType != nil { + tools = append(tools, "set_issue_type") + } if safeOutputs.DispatchWorkflow != nil { tools = append(tools, "dispatch_workflow") } From 3957d916a0113aac8aa9c5fd357a97ff39e3e36d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:13:18 +0000 Subject: [PATCH 8/8] Fix set_issue_type missing from safe outputs config.json generation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.lock.yml | 20 ++++++++++++++++++- .../safe_outputs_config_generation.go | 12 +++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index f5629048046..d73a7953004 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -456,7 +456,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"add_comment":{"allowed_repos":["github/gh-aw"],"max":2},"add_labels":{"allowed":["smoke-copilot"],"allowed_repos":["github/gh-aw"],"max":3},"create_discussion":{"expires":2,"max":1},"create_issue":{"expires":2,"group":true,"max":1},"create_pull_request_review_comment":{"max":5},"dispatch_workflow":{"max":1,"workflow_files":{"haiku-printer":".yml"},"workflows":["haiku-printer"]},"missing_data":{},"missing_tool":{},"noop":{"max":1},"remove_labels":{"allowed":["smoke"],"max":3},"send-slack-message":{"description":"Send a message to Slack (stub for testing)","inputs":{"message":{"default":null,"description":"The message to send","required":true,"type":"string"}},"output":"Slack message stub executed!"},"submit_pull_request_review":{"max":1}} + {"add_comment":{"allowed_repos":["github/gh-aw"],"max":2},"add_labels":{"allowed":["smoke-copilot"],"allowed_repos":["github/gh-aw"],"max":3},"create_discussion":{"expires":2,"max":1},"create_issue":{"expires":2,"group":true,"max":1},"create_pull_request_review_comment":{"max":5},"dispatch_workflow":{"max":1,"workflow_files":{"haiku-printer":".yml"},"workflows":["haiku-printer"]},"missing_data":{},"missing_tool":{},"noop":{"max":1},"remove_labels":{"allowed":["smoke"],"max":3},"send-slack-message":{"description":"Send a message to Slack (stub for testing)","inputs":{"message":{"default":null,"description":"The message to send","required":true,"type":"string"}},"output":"Slack message stub executed!"},"set_issue_type":{"max":5},"submit_pull_request_review":{"max":1}} GH_AW_SAFE_OUTPUTS_CONFIG_EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' [ @@ -1007,6 +1007,24 @@ jobs: } } }, + "set_issue_type": { + "defaultMax": 5, + "fields": { + "issue_number": { + "issueOrPRNumber": true + }, + "issue_type": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, "submit_pull_request_review": { "defaultMax": 1, "fields": { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 1c59cb642af..92f8fc5684d 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -474,6 +474,18 @@ func generateSafeOutputsConfig(data *WorkflowData) string { data.SafeOutputs.HideComment.AllowedReasons, ) } + if data.SafeOutputs.SetIssueType != nil { + additionalFields := make(map[string]any) + if len(data.SafeOutputs.SetIssueType.Allowed) > 0 { + additionalFields["allowed"] = data.SafeOutputs.SetIssueType.Allowed + } + safeOutputsConfig["set_issue_type"] = generateTargetConfigWithRepos( + data.SafeOutputs.SetIssueType.SafeOutputTargetConfig, + data.SafeOutputs.SetIssueType.Max, + 5, // default max + additionalFields, + ) + } } // Add safe-jobs configuration from SafeOutputs.Jobs