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 0000000000..946ae0f04b --- /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. diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index eafde05678..d73a795300 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": @@ -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: @@ -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' [ @@ -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": { @@ -983,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": { @@ -2170,7 +2212,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 cafac347b6..368a2e4f40 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 @@ -141,6 +142,7 @@ features: ## 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) @@ -149,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/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 5f7a2845f7..3a20d3a6f8 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 0000000000..c9626ff0b1 --- /dev/null +++ b/actions/setup/js/set_issue_type.cjs @@ -0,0 +1,265 @@ +// @ts-check +/// + +/** + * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction + */ + +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"; + +/** + * 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 { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); + 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(", ")}`); + } + 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; + + /** + * 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; + + // 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}`, + }; + } + + 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 ?? ""; + 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} in ${itemRepo}` : `Would set issue type to ${JSON.stringify(issueTypeName)} on issue #${issueNumber} in ${itemRepo}`; + logStagedPreviewInfo(description); + return { + success: true, + staged: true, + previewInfo: { + issue_number: issueNumber, + issue_type: issueTypeName, + repo: itemRepo, + }, + }; + } + + try { + const { owner, repo } = repoParts; + + // 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, + repo: itemRepo, + }; + } 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 0000000000..a2b8a1e9a3 --- /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 623a7ad5af..b3635fbf72 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 7e1ca5a902..03acc4d038 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/cli/workflows/test-copilot-set-issue-type.md b/pkg/cli/workflows/test-copilot-set-issue-type.md new file mode 100644 index 0000000000..bc7c443022 --- /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 dee5868af6..0b26c1a852 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,64 @@ ], "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": { + "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. 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", + "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": [ { diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 4425a9f88f..5d8ca16f9c 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 e7f874af23..58f1b7aac6 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 f96fae0b86..0d93332105 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 f39c03ac97..17acdb5b98 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 70eca8e27b..ad771c035f 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 998778f4f6..32603bdd0f 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 6f555b644e..af21f34e29 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_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 1c59cb642a..92f8fc5684 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 diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index fa965e53d8..833eea6493 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 95ccf6e05f..690938751b 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 386c8007be..ae07d21cfc 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 0000000000..116518da2f --- /dev/null +++ b/pkg/workflow/set_issue_type.go @@ -0,0 +1,35 @@ +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 set-issue-type config, disabling handler: %v", err) + return nil + } + + setIssueTypeLog.Printf("Parsed configuration: allowed_count=%d, target=%s", len(config.Allowed), config.Target) + + return &config +} diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 3a2b3ae816..d045c04634 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") }