diff --git a/.github/workflows/daily-assign-issue-to-user.lock.yml b/.github/workflows/daily-assign-issue-to-user.lock.yml index 9a73b1c9504..f4e9d32275f 100644 --- a/.github/workflows/daily-assign-issue-to-user.lock.yml +++ b/.github/workflows/daily-assign-issue-to-user.lock.yml @@ -585,12 +585,30 @@ jobs: "sanitize": true, "maxLength": 39 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "issueOrPRNumber": true }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } } }, diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 0a8e2d75f5b..18ce2b0f7a7 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -608,12 +608,30 @@ jobs: "sanitize": true, "maxLength": 65000 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "optionalPositiveInteger": true }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } } }, diff --git a/.github/workflows/eslint-monster.lock.yml b/.github/workflows/eslint-monster.lock.yml index 011c97c79eb..2421e25c7d9 100644 --- a/.github/workflows/eslint-monster.lock.yml +++ b/.github/workflows/eslint-monster.lock.yml @@ -574,6 +574,15 @@ jobs: "sanitize": true, "maxLength": 128 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "issueNumberOrTemporaryId": true }, @@ -584,9 +593,18 @@ jobs: "type": "string", "maxLength": 256 }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } }, "customValidation": "requiresOneOf:issue_number,pull_number" @@ -599,12 +617,30 @@ jobs: "sanitize": true, "maxLength": 65000 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "optionalPositiveInteger": true }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } } }, diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 571b9285e62..5204e7d345e 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -994,6 +994,15 @@ jobs: "sanitize": true, "maxLength": 128 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "issueNumberOrTemporaryId": true }, @@ -1004,9 +1013,18 @@ jobs: "type": "string", "maxLength": 256 }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } }, "customValidation": "requiresOneOf:issue_number,pull_number" diff --git a/.github/workflows/lint-monster.lock.yml b/.github/workflows/lint-monster.lock.yml index 768748b68b4..7defa1a775d 100644 --- a/.github/workflows/lint-monster.lock.yml +++ b/.github/workflows/lint-monster.lock.yml @@ -569,6 +569,15 @@ jobs: "sanitize": true, "maxLength": 128 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "issueNumberOrTemporaryId": true }, @@ -579,9 +588,18 @@ jobs: "type": "string", "maxLength": 256 }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } }, "customValidation": "requiresOneOf:issue_number,pull_number" @@ -594,12 +612,30 @@ jobs: "sanitize": true, "maxLength": 65000 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "optionalPositiveInteger": true }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } } }, diff --git a/.github/workflows/objective-impact-report.lock.yml b/.github/workflows/objective-impact-report.lock.yml index 1ee18e9d305..300b7df8836 100644 --- a/.github/workflows/objective-impact-report.lock.yml +++ b/.github/workflows/objective-impact-report.lock.yml @@ -563,12 +563,30 @@ jobs: "sanitize": true, "maxLength": 65000 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "optionalPositiveInteger": true }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } } }, diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index d38dab90b8b..b1b0ecbb947 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -580,12 +580,30 @@ jobs: "sanitize": true, "maxLength": 65000 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "optionalPositiveInteger": true }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } } }, diff --git a/.github/workflows/smoke-agent-public-approved.lock.yml b/.github/workflows/smoke-agent-public-approved.lock.yml index 4c8e2cc9f15..fbaf98e3f04 100644 --- a/.github/workflows/smoke-agent-public-approved.lock.yml +++ b/.github/workflows/smoke-agent-public-approved.lock.yml @@ -663,6 +663,15 @@ jobs: "sanitize": true, "maxLength": 128 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "issueNumberOrTemporaryId": true }, @@ -673,9 +682,18 @@ jobs: "type": "string", "maxLength": 256 }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } }, "customValidation": "requiresOneOf:issue_number,pull_number" diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index de80effe3e5..e940b4bca43 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -612,6 +612,15 @@ jobs: "sanitize": true, "maxLength": 128 }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "x-strip-on-error": true + }, "issue_number": { "issueNumberOrTemporaryId": true }, @@ -622,9 +631,18 @@ jobs: "type": "string", "maxLength": 256 }, + "rationale": { + "type": "string", + "sanitize": true, + "maxLength": 280, + "x-strip-on-error": true + }, "repo": { "type": "string", "maxLength": 256 + }, + "suggest": { + "type": "boolean" } }, "customValidation": "requiresOneOf:issue_number,pull_number" diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index 9b49c0c1a04..fc08cb270ab 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -44,6 +44,7 @@ const main = createCountGatedHandler({ handlerType: HANDLER_TYPE, setup: async (config, maxCount, isStaged) => { const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config; + const issueIntentEnabled = config.issue_intent === true; const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; const requiredTitlePrefix = config.required_title_prefix || ""; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); @@ -190,7 +191,7 @@ const main = createCountGatedHandler({ const labelsRequestPayload = uniqueLabels.map(name => { const labelSpec = requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name }; const hasIntentMetadata = Boolean(labelSpec.rationale || labelSpec.confidence || labelSpec.suggest); - return hasIntentMetadata ? labelSpec : labelSpec.name; + return issueIntentEnabled && hasIntentMetadata ? labelSpec : labelSpec.name; }); core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(labelsRequestPayload)}`); diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index cff87925c90..e27ebf0406f 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -114,7 +114,7 @@ describe("add_labels", () => { }); it("should accept structured label entries and add normalized label names", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, issue_intent: true }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -137,7 +137,7 @@ describe("add_labels", () => { }); it("should send structured label metadata without requiring a runtime feature", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, issue_intent: true }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -159,7 +159,7 @@ describe("add_labels", () => { }); it("should normalize lowercase confidence in structured label metadata", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, issue_intent: true }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -513,7 +513,7 @@ describe("add_labels", () => { }); it("should prefer the metadata-bearing entry when a duplicate label name appears", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, issue_intent: true }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -537,6 +537,28 @@ describe("add_labels", () => { expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); }); + it("should strip structured intent metadata when issue_intent is disabled", async () => { + const handler = await main({ max: 10, issue_intent: false }); + const addLabelsCalls = []; + + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler( + { + item_number: 456, + labels: [{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }], + }, + {} + ); + + expect(result.success).toBe(true); + expect(addLabelsCalls).toHaveLength(1); + expect(addLabelsCalls[0].labels).toEqual(["bug"]); + }); + it("should sanitize and trim label names", async () => { const handler = await main({ max: 10 }); const addLabelsCalls = []; diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index 96d231dae0c..d2bdc7bf30c 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -332,6 +332,8 @@ async function getPullRequestDetails(owner, repo, pullNumber, githubClient = git * @param {Object} [githubClient] - Authenticated GitHub client (defaults to global github) * @param {{owner: string, repo: string, type: "issue"|"pull", number: number}|null} [taskContext] - Source issue/PR context for REST path * @param {string|null} [pullRequestRepoSlug] - Optional pull request repository slug (owner/repo) for REST path + * @param {{rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean}} [intentMetadata] - Optional issue-intent metadata + * @param {boolean} [useIssueIntent] - Whether to include issue-intent metadata/headers * @returns {Promise} True if successful */ async function assignAgentToIssue( @@ -346,7 +348,9 @@ async function assignAgentToIssue( baseBranch = null, githubClient = github, taskContext = null, - pullRequestRepoSlug = null + pullRequestRepoSlug = null, + intentMetadata = {}, + useIssueIntent = true ) { // SECURITY: pullRequestRepoSlug specifies a cross-repo target repository slug. // Callers MUST validate the corresponding repository slug against allowedRepos using @@ -391,12 +395,16 @@ async function assignAgentToIssue( try { core.info(`Assigning via issues assignees REST API with login: ${agentLogin}`); - await githubClient.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", { + const assignParams = { owner: targetOwner, repo: targetRepo, issue_number: issueNumber, assignees: [agentLogin], - }); + }; + if (useIssueIntent && intentMetadata && Object.keys(intentMetadata).length > 0) { + Object.assign(assignParams, intentMetadata); + } + await githubClient.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", assignParams); return true; } catch (error) { const errorMessage = getErrorMessage(error); diff --git a/actions/setup/js/assign_agent_helpers.test.cjs b/actions/setup/js/assign_agent_helpers.test.cjs index d66903bd11c..9fb1d04ea12 100644 --- a/actions/setup/js/assign_agent_helpers.test.cjs +++ b/actions/setup/js/assign_agent_helpers.test.cjs @@ -383,6 +383,22 @@ describe("assign_agent_helpers.cjs", () => { expect(mockRequest).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "myorg", repo: "myrepo", issue_number: 42 })); }); + it("should include issue-intent metadata when enabled", async () => { + const mockRequest = vi.fn().mockResolvedValue({ status: 201 }); + const restClient = { request: mockRequest }; + + await assignAgentToIssue("id", "copilot-swe-agent[bot]", [], "copilot", null, null, null, null, null, restClient, taskContext, null, { rationale: "Agent owns the code path", confidence: "HIGH" }, true); + + expect(mockRequest).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", { + owner: "myorg", + repo: "myrepo", + issue_number: 42, + assignees: ["copilot-swe-agent[bot]"], + rationale: "Agent owns the code path", + confidence: "HIGH", + }); + }); + it("should return false and not call request when taskContext is missing", async () => { const mockRequest = vi.fn(); const restClient = { request: mockRequest }; diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 2fc400af668..e9a9bb22f49 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -10,6 +10,7 @@ const { sleep } = require("./error_recovery.cjs"); const { parseAllowedRepos, validateRepo, resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { resolvePullRequestRepo } = require("./pr_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); /** * Module-level state — populated by main(), read by the exported getters below. @@ -64,6 +65,7 @@ async function main(config = {}) { const configuredBaseBranch = config["base-branch"] ? String(config["base-branch"]).trim() : null; const targetConfig = config.target ? String(config.target).trim() : "triggering"; const ignoreIfError = config["ignore-if-error"] === true || config["ignore-if-error"] === "true"; + const issueIntentEnabled = config.issue_intent !== false; const allowedAgents = config.allowed ? Array.isArray(config.allowed) ? config.allowed.map(a => String(a).trim()).filter(Boolean) @@ -87,6 +89,7 @@ async function main(config = {}) { if (configuredBaseBranch) core.info(`Configured base branch: ${configuredBaseBranch}`); core.info(`Target configuration: ${targetConfig}`); core.info(`Max count: ${maxCount}`); + core.info(`Issue intent enabled: ${issueIntentEnabled}`); if (ignoreIfError) core.info("Ignore-if-error mode enabled: Will not fail if agent assignment encounters auth or availability errors"); if (allowedAgents) core.info(`Allowed agents: ${allowedAgents.join(", ")}`); core.info(`Default target repo: ${defaultTargetRepo}`); @@ -179,6 +182,7 @@ async function main(config = {}) { } const agentName = message.agent ?? defaultAgent; + const intentMetadata = issueIntentEnabled ? normalizeIssueIntentMetadata(message) : {}; const model = defaultModel; const customAgent = defaultCustomAgent; const customInstructions = defaultCustomInstructions || null; @@ -387,7 +391,22 @@ async function main(config = {}) { if (customInstructions) core.info(`Using custom instructions: ${customInstructions.substring(0, 100)}${customInstructions.length > 100 ? "..." : ""}`); if (effectiveBaseBranch) core.info(`Using base branch: ${effectiveBaseBranch}`); - const success = await assignAgentToIssue(assignableId, agentLogin, currentAssignees, agentName, allowedAgents, model, customAgent, customInstructions, effectiveBaseBranch, githubClient, taskContext, effectivePullRequestRepoSlug); + const success = await assignAgentToIssue( + assignableId, + agentLogin, + currentAssignees, + agentName, + allowedAgents, + model, + customAgent, + customInstructions, + effectiveBaseBranch, + githubClient, + taskContext, + effectivePullRequestRepoSlug, + intentMetadata, + issueIntentEnabled + ); if (!success) throw new Error(`Failed to assign ${agentName} via REST`); core.info(`Successfully assigned ${agentName} coding agent to ${type} #${number}`); diff --git a/actions/setup/js/assign_to_user.cjs b/actions/setup/js/assign_to_user.cjs index 0ce67b04ae0..718a5e38db5 100644 --- a/actions/setup/js/assign_to_user.cjs +++ b/actions/setup/js/assign_to_user.cjs @@ -13,6 +13,7 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { createCountGatedHandler } = require("./handler_scaffold.cjs"); +const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); /** @type {string} Safe output type handled by this module */ const HANDLER_TYPE = "assign_to_user"; @@ -29,6 +30,7 @@ const main = createCountGatedHandler({ const allowedAssignees = config.allowed ?? []; const blockedAssignees = config.blocked ?? []; const unassignFirst = parseBoolTemplatable(config.unassign_first, false); + const issueIntentEnabled = config.issue_intent !== false; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const githubClient = await createAuthenticatedGitHubClient(config); const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; @@ -36,7 +38,7 @@ const main = createCountGatedHandler({ if (requiredLabels.length > 0) core.info(`Required labels (all): ${requiredLabels.join(", ")}`); if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`); - core.info(`Assign to user configuration: max=${maxCount}, unassign_first=${unassignFirst}`); + core.info(`Assign to user configuration: max=${maxCount}, unassign_first=${unassignFirst}, issue_intent=${issueIntentEnabled}`); if (allowedAssignees.length > 0) { core.info(`Allowed assignees: ${allowedAssignees.join(", ")}`); } @@ -68,6 +70,7 @@ const main = createCountGatedHandler({ core.info(`Target repository: ${itemRepo}`); const assignItem = message; + const intentMetadata = issueIntentEnabled ? normalizeIssueIntentMetadata(assignItem) : {}; // Determine issue number using shared helper const issueResult = resolveIssueNumber(assignItem); @@ -147,12 +150,16 @@ const main = createCountGatedHandler({ } // Add assignees to the issue - await githubClient.rest.issues.addAssignees({ + const addAssigneesParams = { owner: repoParts.owner, repo: repoParts.repo, issue_number: issueNumber, assignees: uniqueAssignees, - }); + }; + if (issueIntentEnabled && Object.keys(intentMetadata).length > 0) { + Object.assign(addAssigneesParams, intentMetadata); + } + await githubClient.rest.issues.addAssignees(addAssigneesParams); core.info(`Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber} in ${itemRepo}`); diff --git a/actions/setup/js/assign_to_user.test.cjs b/actions/setup/js/assign_to_user.test.cjs index a78e81e8593..6bb28f84e82 100644 --- a/actions/setup/js/assign_to_user.test.cjs +++ b/actions/setup/js/assign_to_user.test.cjs @@ -78,6 +78,57 @@ describe("assign_to_user (Handler Factory Architecture)", () => { }); }); + it("should include issue-intent metadata when enabled", async () => { + mockGithub.rest.issues.addAssignees.mockResolvedValue({}); + + const message = { + type: "assign_to_user", + assignees: ["user1"], + rationale: "Primary maintainer for impacted area", + confidence: "low", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + assignees: ["user1"], + rationale: "Primary maintainer for impacted area", + confidence: "LOW", + }); + }); + + it("should omit issue-intent metadata when disabled", async () => { + const { main } = require("./assign_to_user.cjs"); + const noIntentHandler = await main({ + max: 10, + allowed: ["user1", "user2", "user3"], + issue_intent: false, + }); + mockGithub.rest.issues.addAssignees.mockResolvedValue({}); + + const result = await noIntentHandler( + { + type: "assign_to_user", + assignees: ["user1"], + rationale: "Primary maintainer for impacted area", + confidence: "high", + }, + {} + ); + + expect(result.success).toBe(true); + expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + assignees: ["user1"], + }); + }); + it("should support singular assignee field", async () => { mockGithub.rest.issues.addAssignees.mockResolvedValue({}); diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index e1c018fa73e..89f874b942b 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -11,6 +11,7 @@ const { ERR_NOT_FOUND } = require("./error_codes.cjs"); const { createCloseEntityHandler, ISSUE_CONFIG } = require("./close_entity_helpers.cjs"); const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); /** * Parse a `duplicate_of` value into { owner, repo, issueNumber }. @@ -154,17 +155,32 @@ async function addIssueComment(github, owner, repo, issueNumber, message) { * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {number} issueNumber - Issue number - * @param {string} [stateReason] - The reason for closing: "COMPLETED", "NOT_PLANNED", or "DUPLICATE" + * @param {string} stateReason - The reason for closing: "COMPLETED", "NOT_PLANNED", or "DUPLICATE" * @returns {Promise<{number: number, html_url: string, title: string, node_id: string}>} Issue details */ -async function closeIssue(github, owner, repo, issueNumber, stateReason) { - const { data: issue } = await github.rest.issues.update({ +async function closeIssue(github, owner, repo, issueNumber, stateReason, intentMetadata, useIssueIntent) { + const baseParams = { owner, repo, issue_number: issueNumber, state: "closed", state_reason: (stateReason || "COMPLETED").toLowerCase(), - }); + }; + + const hasIntentMetadata = Boolean(intentMetadata && Object.keys(intentMetadata).length > 0); + if (useIssueIntent && hasIntentMetadata) { + try { + const { data: issue } = await github.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { + ...baseParams, + ...intentMetadata, + }); + return issue; + } catch (error) { + core.warning(`Issue-intent close path unavailable, falling back to legacy close path: ${getErrorMessage(error)}`); + } + } + + const { data: issue } = await github.rest.issues.update(baseParams); return issue; } @@ -176,12 +192,13 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason) { */ async function main(config = {}) { const configStateReason = config.state_reason || "COMPLETED"; + const issueIntentEnabled = config.issue_intent !== false; const requiredLabels = config.required_labels || []; const requiredTitlePrefix = config.required_title_prefix || ""; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const githubClient = await createAuthenticatedGitHubClient(config); - core.info(`Close issue configuration: max=${config.max || 10}, state_reason=${configStateReason}`); + core.info(`Close issue configuration: max=${config.max || 10}, state_reason=${configStateReason}, issue_intent=${issueIntentEnabled}`); if (requiredLabels.length > 0) { core.info(`Required labels: ${requiredLabels.join(", ")}`); } @@ -265,9 +282,10 @@ async function main(config = {}) { closeEntity(github, owner, repo, entityNumber, item) { // Support item-level state_reason override, falling back to config-level default const stateReason = item.state_reason || configStateReason; + const intentMetadata = issueIntentEnabled ? normalizeIssueIntentMetadata(item) : {}; core.info(`Closing issue #${entityNumber} with state_reason=${stateReason}`); - const closePromise = closeIssue(github, owner, repo, entityNumber, stateReason); + const closePromise = closeIssue(github, owner, repo, entityNumber, stateReason, intentMetadata, issueIntentEnabled); // When duplicate_of is provided and state_reason is DUPLICATE, create the native duplicate relationship const stateReasonUpper = stateReason.toUpperCase(); diff --git a/actions/setup/js/close_issue.test.cjs b/actions/setup/js/close_issue.test.cjs index ce8ec5b18d2..2205b2758d3 100644 --- a/actions/setup/js/close_issue.test.cjs +++ b/actions/setup/js/close_issue.test.cjs @@ -139,6 +139,69 @@ describe("close_issue", () => { expect(updateCalls[0].state).toBe("closed"); }); + it("should include issue-intent metadata on close when enabled", async () => { + const handler = await main({ max: 10 }); + const requestCalls = []; + mockGithub.request = async (route, params) => { + requestCalls.push({ route, params }); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler( + { + issue_number: 456, + body: "Closing this issue", + rationale: "Duplicate confirmed", + confidence: "medium", + }, + {} + ); + + expect(result.success).toBe(true); + expect(requestCalls).toHaveLength(1); + expect(requestCalls[0].route).toBe("PATCH /repos/{owner}/{repo}/issues/{issue_number}"); + expect(requestCalls[0].params.rationale).toBe("Duplicate confirmed"); + expect(requestCalls[0].params.confidence).toBe("MEDIUM"); + expect(requestCalls[0].params.headers).toBeUndefined(); + }); + + it("should skip issue-intent metadata when explicitly disabled", async () => { + const handler = await main({ max: 10, issue_intent: false }); + const updateCalls = []; + mockGithub.rest.issues.update = async params => { + updateCalls.push(params); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler( + { + issue_number: 456, + body: "Closing this issue", + rationale: "Duplicate confirmed", + confidence: "high", + }, + {} + ); + + expect(result.success).toBe(true); + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].rationale).toBeUndefined(); + expect(updateCalls[0].confidence).toBeUndefined(); + expect(updateCalls[0].headers).toBeUndefined(); + }); + it("should close an issue from context when issue_number not provided", async () => { const handler = await main({ max: 10 }); const updateCalls = []; diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs index 159cd6d978b..fd6d61f6ad2 100644 --- a/actions/setup/js/generate_safe_outputs_tools.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.cjs @@ -38,6 +38,61 @@ const ADD_COMMENT_DISCUSSIONS_DISABLED_NOTE = "NOTE: Discussion comments are disabled for this workflow because discussions:write permission is not available. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission."; const ADD_COMMENT_REPLY_SUPPORT_SENTENCE = "Supports reply_to_id for discussion threading."; const ADD_COMMENT_REPLY_SUPPORT_REGEX = /\s*Supports reply_to_id for discussion threading\./g; +const ISSUE_INTENT_SUFFIX = "INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call."; +const ISSUE_INTENT_TOOL_NAMES = new Set(["set_issue_type", "set_issue_field", "add_labels", "close_issue", "assign_to_user", "assign_to_agent"]); +const ISSUE_INTENT_SCHEMA_FIELDS = ["rationale", "confidence", "suggest"]; + +/** + * Determine whether issue-intent guidance is enabled for a tool. + * Default is disabled; explicit issue_intent: true enables it. + * + * @param {string} toolName + * @param {unknown} toolConfig + * @returns {boolean} + */ +function isIssueIntentEnabledForTool(toolName, toolConfig) { + if (!ISSUE_INTENT_TOOL_NAMES.has(toolName)) { + return false; + } + return !!(toolConfig && typeof toolConfig === "object" && "issue_intent" in toolConfig && toolConfig.issue_intent === true); +} + +/** + * Determine whether issue-intent schema fields should be omitted for a tool. + * Default is optional (present, not required); explicit issue_intent: false omits them. + * + * @param {string} toolName + * @param {unknown} toolConfig + * @returns {boolean} + */ +function isIssueIntentDisabledForTool(toolName, toolConfig) { + if (!ISSUE_INTENT_TOOL_NAMES.has(toolName)) { + return false; + } + return !!(toolConfig && typeof toolConfig === "object" && "issue_intent" in toolConfig && toolConfig.issue_intent === false); +} + +/** + * Remove issue-intent properties from a tool schema. + * If this removes all required fields, the required array is omitted. + * @param {{inputSchema?: {properties?: Record, required?: string[]}}} tool + */ +function stripIssueIntentSchemaFields(tool) { + if (!tool.inputSchema || !tool.inputSchema.properties) { + return; + } + for (const field of ISSUE_INTENT_SCHEMA_FIELDS) { + if (field in tool.inputSchema.properties) { + delete tool.inputSchema.properties[field]; + } + } + if (Array.isArray(tool.inputSchema.required)) { + tool.inputSchema.required = tool.inputSchema.required.filter(f => !ISSUE_INTENT_SCHEMA_FIELDS.includes(f)); + if (tool.inputSchema.required.length === 0) { + delete tool.inputSchema.required; + } + } +} /** * Update add_comment description to match runtime-safe-output permissions. @@ -148,8 +203,11 @@ async function main() { if (descSuffix) { enhancedTool.description = (enhancedTool.description || "") + descSuffix; } - if (["set_issue_type", "set_issue_field", "add_labels"].includes(tool.name)) { - enhancedTool.description = `${enhancedTool.description || ""} INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.`.trim(); + if (isIssueIntentEnabledForTool(tool.name, config[tool.name])) { + enhancedTool.description = `${enhancedTool.description || ""} ${ISSUE_INTENT_SUFFIX}`.trim(); + } + if (isIssueIntentDisabledForTool(tool.name, config[tool.name])) { + stripIssueIntentSchemaFields(enhancedTool); } if (tool.name === "add_comment") { diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index 38cad3e19d7..2a2ff46a06a 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -307,17 +307,31 @@ describe("generate_safe_outputs_tools", () => { expect(addCommentTool.description).not.toContain("Supports reply_to_id for discussion threading."); }); - it("adds issue intent suffix for issue tools without requiring a runtime feature", () => { + it("adds issue intent suffix for issue tools when explicitly enabled", () => { fs.writeFileSync( toolsSourcePath, JSON.stringify([ { name: "set_issue_type", description: "Sets issue type.", inputSchema: { type: "object", properties: {} } }, { name: "set_issue_field", description: "Sets issue field.", inputSchema: { type: "object", properties: {} } }, { name: "add_labels", description: "Adds labels.", inputSchema: { type: "object", properties: {} } }, + { name: "close_issue", description: "Closes issue.", inputSchema: { type: "object", properties: {} } }, + { name: "assign_to_user", description: "Assigns users.", inputSchema: { type: "object", properties: {} } }, + { name: "assign_to_agent", description: "Assigns agent.", inputSchema: { type: "object", properties: {} } }, { name: "create_issue", description: "Creates a GitHub issue.", inputSchema: { type: "object", properties: {} } }, ]) ); - fs.writeFileSync(configPath, JSON.stringify({ set_issue_type: {}, set_issue_field: {}, add_labels: {}, create_issue: {} })); + fs.writeFileSync( + configPath, + JSON.stringify({ + set_issue_type: { issue_intent: true }, + set_issue_field: { issue_intent: true }, + add_labels: { issue_intent: true }, + close_issue: { issue_intent: true }, + assign_to_user: { issue_intent: true }, + assign_to_agent: { issue_intent: true }, + create_issue: {}, + }) + ); fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] })); runScript(); @@ -327,6 +341,9 @@ describe("generate_safe_outputs_tools", () => { expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_type").description).toContain(intentSuffix); expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_field").description).toContain(intentSuffix); expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "add_labels").description).toContain(intentSuffix); + expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "close_issue").description).toContain(intentSuffix); + expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "assign_to_user").description).toContain(intentSuffix); + expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "assign_to_agent").description).toContain(intentSuffix); expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "create_issue").description).not.toContain(intentSuffix); }); @@ -339,7 +356,7 @@ describe("generate_safe_outputs_tools", () => { { name: "add_labels", description: "Adds labels.", inputSchema: { type: "object", properties: {} } }, ]) ); - fs.writeFileSync(configPath, JSON.stringify({ set_issue_type: {}, set_issue_field: {}, add_labels: {} })); + fs.writeFileSync(configPath, JSON.stringify({ set_issue_type: { issue_intent: true }, set_issue_field: { issue_intent: true }, add_labels: { issue_intent: true } })); fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] })); runScript({ GH_AW_RUNTIME_FEATURES: "other\nanother=true" }); @@ -350,4 +367,97 @@ describe("generate_safe_outputs_tools", () => { expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_field").description).toContain(intentSuffix); expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "add_labels").description).toContain(intentSuffix); }); + + it("omits issue intent suffix by default and when explicitly disabled per tool", () => { + fs.writeFileSync( + toolsSourcePath, + JSON.stringify([ + { name: "close_issue", description: "Closes issue.", inputSchema: { type: "object", properties: {} } }, + { name: "assign_to_user", description: "Assigns users.", inputSchema: { type: "object", properties: {} } }, + ]) + ); + fs.writeFileSync(configPath, JSON.stringify({ close_issue: { issue_intent: false }, assign_to_user: {} })); + fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] })); + + runScript(); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const intentSuffix = "INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call."; + expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "close_issue").description).not.toContain(intentSuffix); + expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "assign_to_user").description).not.toContain(intentSuffix); + }); + + it("reflects required/optional/absent intent fields per tool configuration", () => { + fs.writeFileSync( + toolsSourcePath, + JSON.stringify([ + { + name: "close_issue", + description: "Closes issue.", + inputSchema: { + type: "object", + properties: { body: { type: "string" }, rationale: { type: "string" }, confidence: { type: "string" }, suggest: { type: "boolean" } }, + required: ["body"], + }, + }, + { + name: "assign_to_user", + description: "Assigns users.", + inputSchema: { + type: "object", + properties: { issue_number: { type: "number" }, rationale: { type: "string" }, confidence: { type: "string" }, suggest: { type: "boolean" } }, + required: ["issue_number"], + }, + }, + { + name: "assign_to_agent", + description: "Assigns agent.", + inputSchema: { + type: "object", + properties: { issue_number: { type: "number" }, rationale: { type: "string" }, confidence: { type: "string" }, suggest: { type: "boolean" } }, + required: ["issue_number"], + }, + }, + ]) + ); + fs.writeFileSync( + configPath, + JSON.stringify({ + close_issue: { issue_intent: true }, + assign_to_user: {}, + assign_to_agent: { issue_intent: false }, + }) + ); + fs.writeFileSync( + toolsMetaPath, + JSON.stringify({ + description_suffixes: {}, + repo_params: {}, + dynamic_tools: [], + required_field_additions: { close_issue: ["rationale", "confidence"] }, + }) + ); + + runScript(); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const closeIssue = result.find((/** @type {{name: string, inputSchema: {properties: Record, required: string[]}}} */ t) => t.name === "close_issue"); + const assignToUser = result.find((/** @type {{name: string, inputSchema: {properties: Record, required: string[]}}} */ t) => t.name === "assign_to_user"); + const assignToAgent = result.find((/** @type {{name: string, inputSchema: {properties: Record, required: string[]}}} */ t) => t.name === "assign_to_agent"); + + expect(closeIssue.inputSchema.properties).toHaveProperty("rationale"); + expect(closeIssue.inputSchema.properties).toHaveProperty("confidence"); + expect(closeIssue.inputSchema.required).toEqual(expect.arrayContaining(["rationale", "confidence"])); + + expect(assignToUser.inputSchema.properties).toHaveProperty("rationale"); + expect(assignToUser.inputSchema.properties).toHaveProperty("confidence"); + expect(assignToUser.inputSchema.required).not.toContain("rationale"); + expect(assignToUser.inputSchema.required).not.toContain("confidence"); + + expect(assignToAgent.inputSchema.properties).not.toHaveProperty("rationale"); + expect(assignToAgent.inputSchema.properties).not.toHaveProperty("confidence"); + expect(assignToAgent.inputSchema.properties).not.toHaveProperty("suggest"); + expect(assignToAgent.inputSchema.required).not.toContain("rationale"); + expect(assignToAgent.inputSchema.required).not.toContain("confidence"); + }); }); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 9310999badf..3df1eb89a2f 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -217,6 +217,20 @@ "description": "Issue number to close. This is the numeric ID from the GitHub URL (e.g., 901 in github.com/owner/repo/issues/901). If omitted, closes the issue that triggered this workflow (requires an issue event trigger).", "x-synonyms": ["issueNumber"] }, + "rationale": { + "type": "string", + "maxLength": 280, + "description": "Optional rationale for closing the issue (max 280 characters)." + }, + "confidence": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH"], + "description": "Optional confidence level for closing the issue." + }, + "suggest": { + "type": "boolean", + "description": "When true, route through pending suggestion review." + }, "duplicate_of": { "type": ["number", "string"], "description": "Issue number or URL of the canonical original issue when closing as a duplicate. Accepts: a bare number (e.g. 123), a #-prefixed number (e.g. #123), an owner/repo#number reference (e.g. github/gh-aw#123), or a full GitHub issue URL. When provided together with state_reason: duplicate, creates a native GitHub duplicate relationship (marked_as_duplicate timeline event). Omit if not closing as a duplicate.", @@ -858,6 +872,20 @@ "type": "string", "description": "Agent identifier to assign. Defaults to 'copilot' (the Copilot coding agent) if not specified." }, + "rationale": { + "type": "string", + "maxLength": 280, + "description": "Optional rationale for the assignment (max 280 characters)." + }, + "confidence": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH"], + "description": "Optional confidence level for the assignment." + }, + "suggest": { + "type": "boolean", + "description": "When true, route through pending suggestion review." + }, "pull_request_repo": { "type": "string", "description": "Target repository where the pull request should be created, in 'owner/repo' format. If omitted, the PR will be created in the same repository as the issue. This allows issues and code to live in different repositories. The global pull-request-repo configuration (if set) is automatically allowed; additional repositories must be listed in allowed-pull-request-repos.", @@ -898,6 +926,20 @@ "type": "string", "description": "Single GitHub username to assign. Use 'assignees' array for multiple users." }, + "rationale": { + "type": "string", + "maxLength": 280, + "description": "Optional rationale for the assignment (max 280 characters)." + }, + "confidence": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH"], + "description": "Optional confidence level for the assignment." + }, + "suggest": { + "type": "boolean", + "description": "When true, route through pending suggestion review." + }, "secrecy": { "type": "string", "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")." diff --git a/actions/setup/js/set_issue_field.cjs b/actions/setup/js/set_issue_field.cjs index 27a329e4961..1595ca83699 100644 --- a/actions/setup/js/set_issue_field.cjs +++ b/actions/setup/js/set_issue_field.cjs @@ -136,18 +136,9 @@ function buildFieldUpdatePayload(field, rawValue) { * @param {Object} githubClient - Authenticated GitHub client * @param {string} issueNodeId - GraphQL node ID of the issue * @param {{fieldId: string, singleSelectOptionId?: string, numberValue?: number, dateValue?: string, textValue?: string, rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean}} fieldUpdate - * @param {boolean} [useIntentHeader] - When true, includes the GraphQL-Features header to expose intent input types * @returns {Promise} */ -async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader) { - /** @type {Record} */ - const variables = { - issueId: issueNodeId, - issueFields: [fieldUpdate], - }; - if (useIntentHeader) { - variables.headers = { "GraphQL-Features": "update_issue_suggestions" }; - } +async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate) { await githubClient.graphql( `mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) { setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) { @@ -156,7 +147,10 @@ async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useInt } } }`, - variables + { + issueId: issueNodeId, + issueFields: [fieldUpdate], + } ); } @@ -170,6 +164,7 @@ async function main(config = {}) { const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const githubClient = await createAuthenticatedGitHubClient(config); const isStaged = isStagedMode(config); + const issueIntentEnabled = config.issue_intent === true; core.info(`Set issue field configuration: max=${maxCount}`); const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; @@ -343,10 +338,11 @@ async function main(config = {}) { ...fieldUpdateResult.update, }; - Object.assign(fieldUpdate, normalizeIssueIntentMetadata(item)); - core.info("Using GraphQL-Features header for issue field mutation"); + if (issueIntentEnabled) { + Object.assign(fieldUpdate, normalizeIssueIntentMetadata(item)); + } - await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, true); + await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate); core.info(`Successfully set issue field ${JSON.stringify(fieldName || fieldNodeId)} to ${JSON.stringify(value)} on issue #${issueNumber}`); diff --git a/actions/setup/js/set_issue_field.test.cjs b/actions/setup/js/set_issue_field.test.cjs index 5b8619894cb..21f94f84535 100644 --- a/actions/setup/js/set_issue_field.test.cjs +++ b/actions/setup/js/set_issue_field.test.cjs @@ -90,7 +90,7 @@ describe("set_issue_field (Handler Factory Architecture)", () => { }); const { main } = require("./set_issue_field.cjs"); - handler = await main({ max: 5 }); + handler = await main({ max: 5, issue_intent: true }); }); it("should return a function from main()", async () => { @@ -475,7 +475,7 @@ describe("set_issue_field (Handler Factory Architecture)", () => { expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("No issue fields were discovered")); }); - it("should send the GraphQL-Features header even without optional intent metadata", async () => { + it("should set issue field without optional intent metadata", async () => { const result = await handler( { type: "set_issue_field", @@ -491,14 +491,13 @@ describe("set_issue_field (Handler Factory Architecture)", () => { expect.stringContaining("setIssueFieldValue"), expect.objectContaining({ issueFields: [expect.objectContaining({ fieldId: textFieldId, textValue: "High" })], - headers: { "GraphQL-Features": "update_issue_suggestions" }, }) ); }); it("should include issue intent metadata without requiring a runtime feature", async () => { const { main } = require("./set_issue_field.cjs"); - const featureHandler = await main({ max: 5 }); + const featureHandler = await main({ max: 5, issue_intent: true }); const result = await featureHandler( { @@ -526,8 +525,33 @@ describe("set_issue_field (Handler Factory Architecture)", () => { suggest: true, }), ], - headers: { "GraphQL-Features": "update_issue_suggestions" }, }) ); }); + + it("should omit intent metadata when issue_intent is disabled", async () => { + const { main } = require("./set_issue_field.cjs"); + const handlerWithoutIntent = await main({ max: 5, issue_intent: false }); + + const result = await handlerWithoutIntent( + { + type: "set_issue_field", + issue_number: 42, + field_name: "Customer Impact", + value: "High", + rationale: "should be ignored", + confidence: "HIGH", + suggest: true, + }, + {} + ); + + expect(result.success).toBe(true); + const mutationCall = mockGraphql.mock.calls.find(([query]) => query.includes("setIssueFieldValue")); + expect(mutationCall).toBeTruthy(); + expect(mutationCall[1].issueFields[0]).toEqual(expect.objectContaining({ fieldId: textFieldId, textValue: "High" })); + expect(mutationCall[1].issueFields[0]).not.toHaveProperty("rationale"); + expect(mutationCall[1].issueFields[0]).not.toHaveProperty("confidence"); + expect(mutationCall[1].issueFields[0]).not.toHaveProperty("suggest"); + }); }); diff --git a/actions/setup/js/set_issue_type.cjs b/actions/setup/js/set_issue_type.cjs index 0cafadfc7fa..bd8a65421bf 100644 --- a/actions/setup/js/set_issue_type.cjs +++ b/actions/setup/js/set_issue_type.cjs @@ -78,7 +78,6 @@ async function setIssueTypeById(githubClient, issueNodeId, issueTypeId, intentMe { issueId: issueNodeId, issueType, - headers: { "GraphQL-Features": "update_issue_suggestions" }, } ); } @@ -182,6 +181,7 @@ async function main(config = {}) { core.info(`Set issue type configuration: max=${maxCount}`); const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; const requiredTitlePrefix = config.required_title_prefix || ""; + const issueIntentEnabled = config.issue_intent === true; if (requiredLabels.length > 0) core.info(`Required labels (all): ${requiredLabels.join(", ")}`); if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`); if (allowedTypes.length > 0) { @@ -283,26 +283,35 @@ async function main(config = {}) { try { const { owner, repo } = repoParts; - const intentMetadata = normalizeIssueIntentMetadata(item); + const intentMetadata = issueIntentEnabled ? normalizeIssueIntentMetadata(item) : {}; if (!isClear) { - // GraphQL intent path: resolve the type's node ID from org issue types, then - // call setIssueTypeById with IssueTypeUpdateInput + the GraphQL-Features header. - core.info("Using GraphQL intent path with IssueTypeUpdateInput"); - core.info(`Fetching issue node ID for issue #${issueNumber}`); - const issueNodeId = await getIssueNodeId(githubClient, owner, repo, issueNumber); - core.info(`Fetching issue types for repo ${owner}/${repo}`); - const issueTypes = await fetchIssueTypesForRepo(githubClient, owner, repo); - core.info(`Found ${issueTypes.length} issue type(s) for repo ${owner}/${repo}`); - const typeNode = issueTypes.find(t => t.name.toLowerCase() === resolvedIssueTypeName.toLowerCase()); - if (!typeNode) { - const availableNames = issueTypes.map(t => t.name).join(", "); - const error = availableNames ? `Issue type ${JSON.stringify(resolvedIssueTypeName)} not found. Available types: ${availableNames}` : NO_ISSUE_TYPES_AVAILABLE_ERROR; - core.error(`Failed to set issue type on issue #${issueNumber}: ${error}`); - return { success: false, error }; + if (issueIntentEnabled) { + // GraphQL intent path: resolve the type's node ID from org issue types, then + // call setIssueTypeById with IssueTypeUpdateInput. + core.info("Using GraphQL intent path with IssueTypeUpdateInput"); + core.info(`Fetching issue node ID for issue #${issueNumber}`); + const issueNodeId = await getIssueNodeId(githubClient, owner, repo, issueNumber); + core.info(`Fetching issue types for repo ${owner}/${repo}`); + const issueTypes = await fetchIssueTypesForRepo(githubClient, owner, repo); + core.info(`Found ${issueTypes.length} issue type(s) for repo ${owner}/${repo}`); + const typeNode = issueTypes.find(t => t.name.toLowerCase() === resolvedIssueTypeName.toLowerCase()); + if (!typeNode) { + const availableNames = issueTypes.map(t => t.name).join(", "); + const error = availableNames ? `Issue type ${JSON.stringify(resolvedIssueTypeName)} not found. Available types: ${availableNames}` : NO_ISSUE_TYPES_AVAILABLE_ERROR; + core.error(`Failed to set issue type on issue #${issueNumber}: ${error}`); + return { success: false, error }; + } + core.info(`Resolved issue type ${JSON.stringify(resolvedIssueTypeName)} to node ID ${typeNode.id}`); + await setIssueTypeById(githubClient, issueNodeId, typeNode.id, intentMetadata); + } else { + await githubClient.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + type: resolvedIssueTypeName, + }); } - core.info(`Resolved issue type ${JSON.stringify(resolvedIssueTypeName)} to node ID ${typeNode.id}`); - await setIssueTypeById(githubClient, issueNodeId, typeNode.id, intentMetadata); } else { // REST path: preserve the existing clear behavior. await githubClient.rest.issues.update({ diff --git a/actions/setup/js/set_issue_type.test.cjs b/actions/setup/js/set_issue_type.test.cjs index 616aa0371d3..41ceeec6689 100644 --- a/actions/setup/js/set_issue_type.test.cjs +++ b/actions/setup/js/set_issue_type.test.cjs @@ -77,7 +77,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { }); const { main } = require("./set_issue_type.cjs"); - handler = await main({ max: 5 }); + handler = await main({ max: 5, issue_intent: true }); }); it("should return a function from main()", async () => { @@ -106,7 +106,6 @@ describe("set_issue_type (Handler Factory Architecture)", () => { issueType: { issueTypeId: "IT_kwDO_bug", }, - headers: { "GraphQL-Features": "update_issue_suggestions" }, }) ); }); @@ -157,6 +156,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { const handlerWithAllowed = await main({ max: 5, allowed: ["Bug", "Feature"], + issue_intent: true, }); const message = { @@ -174,6 +174,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { const handlerWithAllowed = await main({ max: 5, allowed: ["Bug", "Feature"], + issue_intent: true, }); const message = { @@ -192,6 +193,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { const handlerWithAllowed = await main({ max: 5, allowed: ["Bug", "Feature"], + issue_intent: true, }); const message = { @@ -330,6 +332,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { const handlerWithAllowed = await main({ max: 5, allowed: ["Bug", "Feature"], + issue_intent: true, }); const message = { @@ -351,7 +354,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { ); }); - it("should use GraphQL intent path with IssueTypeUpdateInput and GraphQL-Features header", async () => { + it("should use GraphQL intent path with IssueTypeUpdateInput", async () => { const issueNodeId = "I_kwDO_testissue"; const issueTypeNodeId = "IT_kwDO_bug"; @@ -373,7 +376,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { }); const { main } = require("./set_issue_type.cjs"); - const featureHandler = await main({ max: 5 }); + const featureHandler = await main({ max: 5, issue_intent: true }); const result = await featureHandler( { @@ -399,7 +402,6 @@ describe("set_issue_type (Handler Factory Architecture)", () => { confidence: "HIGH", suggest: true, }, - headers: { "GraphQL-Features": "update_issue_suggestions" }, }) ); }); @@ -427,7 +429,7 @@ describe("set_issue_type (Handler Factory Architecture)", () => { }); const { main } = require("./set_issue_type.cjs"); - const featureHandler = await main({ max: 5 }); + const featureHandler = await main({ max: 5, issue_intent: true }); const result = await featureHandler( { @@ -468,4 +470,29 @@ describe("set_issue_type (Handler Factory Architecture)", () => { expect(metadata).not.toHaveProperty("rationale"); }); + + it("should use legacy REST issue type update when issue_intent is disabled", async () => { + const { main } = require("./set_issue_type.cjs"); + const handlerWithoutIntent = await main({ max: 5, issue_intent: false }); + + const result = await handlerWithoutIntent( + { + type: "set_issue_type", + issue_number: 99, + issue_type: "Bug", + rationale: "should be ignored", + confidence: "HIGH", + }, + {} + ); + + expect(result.success).toBe(true); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 99, + type: "Bug", + }); + expect(mockGithub.graphql).not.toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.anything()); + }); }); diff --git a/actions/setup/js/types/safe-outputs.d.ts b/actions/setup/js/types/safe-outputs.d.ts index ebae4edb52a..d98c46f2e20 100644 --- a/actions/setup/js/types/safe-outputs.d.ts +++ b/actions/setup/js/types/safe-outputs.d.ts @@ -91,7 +91,7 @@ interface CloseDiscussionItem extends BaseSafeOutputItem { /** * JSONL item for closing a GitHub issue */ -interface CloseIssueItem extends BaseSafeOutputItem { +interface CloseIssueItem extends BaseSafeOutputItem, IssueIntentMetadata { type: "close_issue"; /** Comment body to add when closing the issue */ body: string; @@ -368,7 +368,7 @@ interface SetIssueFieldItem extends BaseSafeOutputItem, IssueIntentMetadata { /** * JSONL item for assigning a GitHub Copilot coding agent to an issue or project item */ -interface AssignToAgentItem extends BaseSafeOutputItem { +interface AssignToAgentItem extends BaseSafeOutputItem, IssueIntentMetadata { type: "assign_to_agent"; /** Issue number to assign agent to */ issue_number: number | string; diff --git a/pkg/parser/schema_safe_outputs_target_test.go b/pkg/parser/schema_safe_outputs_target_test.go index 8527070cd88..4212e4f782b 100644 --- a/pkg/parser/schema_safe_outputs_target_test.go +++ b/pkg/parser/schema_safe_outputs_target_test.go @@ -304,6 +304,20 @@ func TestMainWorkflowSchema_SafeOutputsTargetProperties(t *testing.T) { }, }, }, + { + name: "issue-intent toggle accepted for close and assignment tools", + safeOutputs: map[string]any{ + "close-issue": map[string]any{ + "issue-intent": false, + }, + "assign-to-user": map[string]any{ + "issue-intent": false, + }, + "assign-to-agent": map[string]any{ + "issue-intent": false, + }, + }, + }, } for _, tt := range tests { diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 96b9a1f003a..1d7f6243811 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4608,6 +4608,10 @@ "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, + "issue-intent": { + "type": "boolean", + "description": "Enable issue-intent metadata support (rationale/confidence/suggest) for this output type." + }, "samples": { "description": "Internal hidden feature. Optional list of declarative sample payloads that exercise this safe-output handler. Used by the hidden `gh aw compile --use-samples` flag to replace the agentic step with a deterministic replay through the safe-outputs MCP server. Each entry should conform to the corresponding MCP tool inputSchema; recognized sidecar keys (currently `patch` for create-pull-request and push-to-pull-request-branch) are stripped before schema validation and consumed by the replay driver.", "oneOf": [ @@ -5262,6 +5266,10 @@ "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, + "issue-intent": { + "type": "boolean", + "description": "Enable issue-intent metadata support (rationale/confidence/suggest) for this output type." + }, "samples": { "description": "Internal hidden feature. Optional list of declarative sample payloads that exercise this safe-output handler. Used by the hidden `gh aw compile --use-samples` flag to replace the agentic step with a deterministic replay through the safe-outputs MCP server. Each entry should conform to the corresponding MCP tool inputSchema; recognized sidecar keys (currently `patch` for create-pull-request and push-to-pull-request-branch) are stripped before schema validation and consumed by the replay driver.", "oneOf": [ @@ -5361,6 +5369,10 @@ "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, + "issue-intent": { + "type": "boolean", + "description": "Enable issue-intent metadata support (rationale/confidence/suggest) for this output type." + }, "samples": { "description": "Internal hidden feature. Optional list of declarative sample payloads that exercise this safe-output handler. Used by the hidden `gh aw compile --use-samples` flag to replace the agentic step with a deterministic replay through the safe-outputs MCP server. Each entry should conform to the corresponding MCP tool inputSchema; recognized sidecar keys (currently `patch` for create-pull-request and push-to-pull-request-branch) are stripped before schema validation and consumed by the replay driver.", "oneOf": [ @@ -6245,6 +6257,10 @@ "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, + "issue-intent": { + "type": "boolean", + "description": "Enable issue-intent metadata support (rationale/confidence/suggest) for this output type." + }, "samples": { "description": "Internal hidden feature. Optional list of declarative sample payloads that exercise this safe-output handler. Used by the hidden `gh aw compile --use-samples` flag to replace the agentic step with a deterministic replay through the safe-outputs MCP server. Each entry should conform to the corresponding MCP tool inputSchema; recognized sidecar keys (currently `patch` for create-pull-request and push-to-pull-request-branch) are stripped before schema validation and consumed by the replay driver.", "oneOf": [ @@ -8228,6 +8244,10 @@ "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, + "issue-intent": { + "type": "boolean", + "description": "Enable issue-intent metadata support (rationale/confidence/suggest) for this output type." + }, "samples": { "description": "Internal hidden feature. Optional list of declarative sample payloads that exercise this safe-output handler. Used by the hidden `gh aw compile --use-samples` flag to replace the agentic step with a deterministic replay through the safe-outputs MCP server. Each entry should conform to the corresponding MCP tool inputSchema; recognized sidecar keys (currently `patch` for create-pull-request and push-to-pull-request-branch) are stripped before schema validation and consumed by the replay driver.", "oneOf": [ @@ -8317,6 +8337,10 @@ "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, + "issue-intent": { + "type": "boolean", + "description": "Enable issue-intent metadata support (rationale/confidence/suggest) for this output type." + }, "samples": { "description": "Internal hidden feature. Optional list of declarative sample payloads that exercise this safe-output handler. Used by the hidden `gh aw compile --use-samples` flag to replace the agentic step with a deterministic replay through the safe-outputs MCP server. Each entry should conform to the corresponding MCP tool inputSchema; recognized sidecar keys (currently `patch` for create-pull-request and push-to-pull-request-branch) are stripped before schema validation and consumed by the replay driver.", "oneOf": [ diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 009a8c0c202..324ccce62f9 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -268,6 +268,24 @@ "issueNumber" ] }, + "rationale": { + "type": "string", + "maxLength": 280, + "description": "Optional rationale for closing the issue (max 280 characters)." + }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "description": "Optional confidence level for closing the issue." + }, + "suggest": { + "type": "boolean", + "description": "When true, route through pending suggestion review." + }, "duplicate_of": { "type": [ "number", @@ -1100,6 +1118,24 @@ "type": "string", "description": "Agent identifier to assign. Defaults to 'copilot' (the Copilot coding agent) if not specified." }, + "rationale": { + "type": "string", + "maxLength": 280, + "description": "Optional rationale for the assignment (max 280 characters)." + }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "description": "Optional confidence level for the assignment." + }, + "suggest": { + "type": "boolean", + "description": "When true, route through pending suggestion review." + }, "pull_request_repo": { "type": "string", "description": "Target repository where the pull request should be created, in 'owner/repo' format. If omitted, the PR will be created in the same repository as the issue. This allows issues and code to live in different repositories. The global pull-request-repo configuration (if set) is automatically allowed; additional repositories must be listed in allowed-pull-request-repos.", @@ -1149,6 +1185,24 @@ "type": "string", "description": "Single GitHub username to assign. Use 'assignees' array for multiple users." }, + "rationale": { + "type": "string", + "maxLength": 280, + "description": "Optional rationale for the assignment (max 280 characters)." + }, + "confidence": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "description": "Optional confidence level for the assignment." + }, + "suggest": { + "type": "boolean", + "description": "When true, route through pending suggestion review." + }, "secrecy": { "type": "string", "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")." diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index efb1d203a84..6094759d75c 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -230,12 +230,10 @@ func TestFieldValidationMarshaling(t *testing.T) { } func TestIssueIntentRationaleMaxLength(t *testing.T) { - if got := ValidationConfig["set_issue_type"].Fields["rationale"].MaxLength; got != 280 { - t.Fatalf("set_issue_type rationale maxLength = %d, want 280", got) - } - - if got := ValidationConfig["set_issue_field"].Fields["rationale"].MaxLength; got != 280 { - t.Fatalf("set_issue_field rationale maxLength = %d, want 280", got) + for _, typeName := range []string{"set_issue_type", "set_issue_field", "close_issue", "assign_to_user", "assign_to_agent"} { + if got := ValidationConfig[typeName].Fields["rationale"].MaxLength; got != 280 { + t.Fatalf("%s rationale maxLength = %d, want 280", typeName, got) + } } } @@ -290,7 +288,7 @@ func TestUpdateIssueValidationConfig(t *testing.T) { } func TestIssueIntentValidationFields(t *testing.T) { - for _, typeName := range []string{"set_issue_type", "set_issue_field"} { + for _, typeName := range []string{"set_issue_type", "set_issue_field", "close_issue", "assign_to_user", "assign_to_agent"} { config, ok := ValidationConfig[typeName] if !ok { t.Fatalf("%s not found in ValidationConfig", typeName) diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 911cc814089..412026f017d 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -24,6 +24,7 @@ type BaseSafeOutputConfig struct { GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for this specific output type GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"` // GitHub App credentials for minting a per-handler installation access token Staged *TemplatableBool `yaml:"staged,omitempty"` // Templatable preview-only mode for this specific output type + IssueIntent *bool `yaml:"issue-intent,omitempty"` // When true, enable issue-intent rationale/confidence guidance and schema requirements for this output type. NormalizeClosingKeywords *bool `yaml:"normalize-closing-keywords,omitempty"` // When true for this output type, strip backticks from recognized issue-closing keywords in body fields. // Samples carries deterministic replay samples for the hidden // `gh aw compile --use-samples` flag. Each entry is the JSON object diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 851e345c861..6e91b810fbc 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -110,6 +110,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("state_reason", c.StateReason). AddBoolPtr("allow_body", c.AllowBody). + AddBoolPtr("issue_intent", c.IssueIntent). AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, @@ -138,6 +139,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddStringSlice("allowed", c.Allowed). AddStringSlice("blocked", c.Blocked). + AddBoolPtr("issue_intent", c.IssueIntent). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). @@ -805,6 +807,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("custom-agent", c.DefaultCustomAgent). AddIfNotEmpty("custom-instructions", c.DefaultCustomInstructions). AddStringSlice("allowed", c.Allowed). + AddBoolPtr("issue_intent", c.IssueIntent). AddIfTrue("ignore-if-error", c.IgnoreIfError). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("target-repo", c.TargetRepoSlug). @@ -929,6 +932,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("unassign_first", c.UnassignFirst). + AddBoolPtr("issue_intent", c.IssueIntent). AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, @@ -970,6 +974,7 @@ var handlerRegistry = map[string]handlerBuilder{ config := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddStringSlice("allowed", c.Allowed). + AddBoolPtr("issue_intent", c.IssueIntent). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). @@ -992,6 +997,7 @@ var handlerRegistry = map[string]handlerBuilder{ config := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddStringSlice("allowed_fields", c.AllowedFields). + AddBoolPtr("issue_intent", c.IssueIntent). AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). diff --git a/pkg/workflow/safe_outputs_tools_generation.go b/pkg/workflow/safe_outputs_tools_generation.go index 25fd8c268b6..508fc6211ac 100644 --- a/pkg/workflow/safe_outputs_tools_generation.go +++ b/pkg/workflow/safe_outputs_tools_generation.go @@ -254,9 +254,29 @@ func computeRequiredFieldAdditions(safeOutputs *SafeOutputsConfig) map[string][] if safeOutputs.CreatePullRequests != nil && safeOutputs.CreatePullRequests.RequireTemporaryID { additions["create_pull_request"] = []string{"temporary_id"} } + issueIntentRequiredFields := []string{"rationale", "confidence"} + if safeOutputs.SetIssueType != nil && issueIntentRequired(safeOutputs.SetIssueType.IssueIntent) { + additions["set_issue_type"] = issueIntentRequiredFields + } + if safeOutputs.SetIssueField != nil && issueIntentRequired(safeOutputs.SetIssueField.IssueIntent) { + additions["set_issue_field"] = issueIntentRequiredFields + } + if safeOutputs.CloseIssues != nil && issueIntentRequired(safeOutputs.CloseIssues.IssueIntent) { + additions["close_issue"] = issueIntentRequiredFields + } + if safeOutputs.AssignToUser != nil && issueIntentRequired(safeOutputs.AssignToUser.IssueIntent) { + additions["assign_to_user"] = issueIntentRequiredFields + } + if safeOutputs.AssignToAgent != nil && issueIntentRequired(safeOutputs.AssignToAgent.IssueIntent) { + additions["assign_to_agent"] = issueIntentRequiredFields + } return additions } +func issueIntentRequired(issueIntent *bool) bool { + return issueIntent != nil && *issueIntent +} + // generateToolsMetaJSON generates the content for tools_meta.json: a compact file // that captures the workflow-specific customisations (description constraints, // repo parameters, dynamic tools) without inlining the entire diff --git a/pkg/workflow/safe_outputs_tools_generation_test.go b/pkg/workflow/safe_outputs_tools_generation_test.go index 2b97615540b..8aff34b8661 100644 --- a/pkg/workflow/safe_outputs_tools_generation_test.go +++ b/pkg/workflow/safe_outputs_tools_generation_test.go @@ -411,3 +411,28 @@ func TestComputeRequiredFieldAdditionsDisabledByDefault(t *testing.T) { }) assert.Empty(t, additions) } + +func TestComputeRequiredFieldAdditionsIssueIntentDefaultDisabled(t *testing.T) { + additions := computeRequiredFieldAdditions(&SafeOutputsConfig{ + CloseIssues: &CloseIssuesConfig{}, + AssignToUser: &AssignToUserConfig{}, + AssignToAgent: &AssignToAgentConfig{}, + }) + + assert.NotContains(t, additions, "close_issue") + assert.NotContains(t, additions, "assign_to_user") + assert.NotContains(t, additions, "assign_to_agent") +} + +func TestComputeRequiredFieldAdditionsIssueIntentOptIn(t *testing.T) { + enabled := true + additions := computeRequiredFieldAdditions(&SafeOutputsConfig{ + CloseIssues: &CloseIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{IssueIntent: &enabled}}, + AssignToUser: &AssignToUserConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{IssueIntent: &enabled}}, + AssignToAgent: &AssignToAgentConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{IssueIntent: &enabled}}, + }) + + assert.Equal(t, []string{"rationale", "confidence"}, additions["close_issue"]) + assert.Equal(t, []string{"rationale", "confidence"}, additions["assign_to_user"]) + assert.Equal(t, []string{"rationale", "confidence"}, additions["assign_to_agent"]) +} diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 835bd284836..76662a69d5e 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -166,6 +166,9 @@ var ValidationConfig = map[string]TypeValidationConfig{ "issue_number": {IssueNumberOrTemporaryID: true}, "pull_number": {OptionalPositiveInteger: true}, "agent": {Type: "string", Sanitize: true, MaxLength: 128}, + "rationale": {Type: "string", Sanitize: true, MaxLength: 280, StripOnError: true}, + "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}, StripOnError: true}, + "suggest": {Type: "boolean"}, "pull_request_repo": {Type: "string", MaxLength: 256}, // Optional: repository where the PR should be created "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" }, @@ -176,7 +179,10 @@ var ValidationConfig = map[string]TypeValidationConfig{ "issue_number": {IssueOrPRNumber: true}, "assignees": {Type: "[]string", Sanitize: true, MaxLength: 39}, // GitHub username max length is 39 "assignee": {Type: "string", Sanitize: true, MaxLength: 39}, // Single assignee alternative - "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" + "rationale": {Type: "string", Sanitize: true, MaxLength: 280, StripOnError: true}, + "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}, StripOnError: true}, + "suggest": {Type: "boolean"}, + "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" }, }, "update_issue": { @@ -285,6 +291,9 @@ var ValidationConfig = map[string]TypeValidationConfig{ Fields: map[string]FieldValidation{ "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, "issue_number": {OptionalPositiveInteger: true}, + "rationale": {Type: "string", Sanitize: true, MaxLength: 280, StripOnError: true}, + "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}, StripOnError: true}, + "suggest": {Type: "boolean"}, "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" }, },