From 83128b6ca0709b05b56c8def66de6a9e08d7744e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:16:02 +0000 Subject: [PATCH 1/3] Initial plan From b635537aea7bf5e729e965a38ed260f97ab0264c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:28:55 +0000 Subject: [PATCH 2/3] Fix add_labels: send plain label names to REST addLabels endpoint Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_labels.cjs | 11 +++++------ actions/setup/js/add_labels.test.cjs | 18 +++++++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index 15e5e33fb47..373b2e3d58d 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -52,7 +52,6 @@ const main = createCountGatedHandler({ handlerType: HANDLER_TYPE, setup: async (config, maxCount, isStaged) => { const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config; - const issueIntentEnabled = config.issue_intent !== false; const issueIntentStrict = config.issue_intent === true; // strict mode: plain-string labels rejected, metadata required const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; const requiredTitlePrefix = config.required_title_prefix || ""; @@ -223,11 +222,11 @@ const main = createCountGatedHandler({ }; } - const labelsRequestPayload = uniqueLabels.map(name => { - const labelSpec = requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name }; - const hasIntentMetadata = hasLabelIntentMetadata(labelSpec); - return issueIntentEnabled && hasIntentMetadata ? labelSpec : labelSpec.name; - }); + // The REST issues.addLabels endpoint only accepts label name strings; it does not + // support issue-intent metadata (rationale/confidence/suggest). Passing objects with + // those extra keys causes GitHub to return success while silently applying no labels. + // Always send plain label names so the labels are actually added. + const labelsRequestPayload = uniqueLabels; 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 f30d722a5fc..39d59826031 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -133,7 +133,8 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(result.number).toBe(456); expect(addLabelsCalls).toHaveLength(1); - expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); + // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped + expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); it("should send structured label metadata without requiring a runtime feature", async () => { @@ -155,7 +156,8 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(addLabelsCalls).toHaveLength(1); - expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]); + // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped + expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); it("should normalize lowercase confidence in structured label metadata", async () => { @@ -177,7 +179,8 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(addLabelsCalls).toHaveLength(1); - expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]); + // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped + expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); it("should accept issue_number as an alias for item_number", async () => { @@ -534,8 +537,8 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(result.labelsAdded).toEqual(["bug"]); expect(addLabelsCalls.length).toBe(1); - // The payload sent to the API must use the spec with metadata, not the plain string - expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); + // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped + expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); it("should strip structured intent metadata when issue_intent is disabled", async () => { @@ -560,7 +563,7 @@ describe("add_labels", () => { expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); - it("should forward per-label intent metadata by default when issue_intent is omitted", async () => { + it("should strip per-label intent metadata from the REST request when issue_intent is omitted", async () => { const handler = await main({ max: 10 }); const addLabelsCalls = []; @@ -579,7 +582,8 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(addLabelsCalls).toHaveLength(1); - expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); + // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped + expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); it("should accept plain string labels by default when issue_intent is omitted", async () => { From a1c028866751be26c25ca629f19ec2b881331920 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:52:48 +0000 Subject: [PATCH 3/3] Forward add_labels intent metadata via GraphQL updateIssue mutation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_labels.cjs | 86 +++++++++++++- actions/setup/js/add_labels.test.cjs | 163 ++++++++++++++++++++------- 2 files changed, 205 insertions(+), 44 deletions(-) diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index 373b2e3d58d..49f7cd9d8a8 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -33,7 +33,8 @@ const { MAX_LABELS } = require("./constants.cjs"); const { createCountGatedHandler } = require("./handler_scaffold.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); -const { normalizeIssueIntentLabelInputs } = require("./issue_intents.cjs"); +const { normalizeIssueIntentLabelInputs, buildIssueIntentLabelUpdates } = require("./issue_intents.cjs"); +const { fetchAllRepoLabels } = require("./github_api_helpers.cjs"); /** * @param {{ rationale?: string, confidence?: string, suggest?: boolean } | null | undefined} spec @@ -52,6 +53,7 @@ const main = createCountGatedHandler({ handlerType: HANDLER_TYPE, setup: async (config, maxCount, isStaged) => { const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config; + const issueIntentEnabled = config.issue_intent !== false; const issueIntentStrict = config.issue_intent === true; // strict mode: plain-string labels rejected, metadata required const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; const requiredTitlePrefix = config.required_title_prefix || ""; @@ -222,10 +224,17 @@ const main = createCountGatedHandler({ }; } + // Build the resolved label specs (name + optional intent metadata) for the validated + // unique labels, preserving the order returned by validation. + const uniqueLabelSpecs = uniqueLabels.map(name => requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name }); + const intentLabelSpecs = uniqueLabelSpecs.filter(spec => hasLabelIntentMetadata(spec)); + const useIssueIntentPath = issueIntentEnabled && intentLabelSpecs.length > 0; + // The REST issues.addLabels endpoint only accepts label name strings; it does not // support issue-intent metadata (rationale/confidence/suggest). Passing objects with // those extra keys causes GitHub to return success while silently applying no labels. - // Always send plain label names so the labels are actually added. + // When intent metadata is present, route through the GraphQL updateIssue/LabelUpdateInput + // mutation instead (see update_issue.cjs), which does support intent metadata. const labelsRequestPayload = uniqueLabels; core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(labelsRequestPayload)}`); @@ -247,6 +256,79 @@ const main = createCountGatedHandler({ try { const beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); + + if (useIssueIntentPath) { + // Intent metadata is only supported via the GraphQL updateIssue mutation. That + // mutation replaces the issue's label set, so merge the newly requested labels with + // the issue's existing labels to preserve add-only semantics. Existing labels are + // sent without intent metadata; newly requested labels carry their metadata. + const { data: issueData } = await withRetry( + () => + githubClient.rest.issues.get({ + owner: repoParts.owner, + repo: repoParts.repo, + issue_number: itemNumber, + }), + RATE_LIMIT_RETRY_CONFIG, + `get ${contextType} #${itemNumber} in ${itemRepo}` + ); + + const issueNodeId = issueData?.node_id; + if (!issueNodeId) { + throw new Error(`Failed to resolve GraphQL node ID for ${contextType} #${itemNumber}`); + } + + const repoLabels = await fetchAllRepoLabels(githubClient, repoParts.owner, repoParts.repo); + const labelIdByName = new Map(repoLabels.map(label => [label.name.toLowerCase(), label.id])); + + // Merge existing labels (metadata-free) with the requested specs, de-duplicating by + // lowercased name and favouring the requested specs so their intent metadata wins. + const requestedNamesLower = new Set(uniqueLabelSpecs.map(spec => spec.name.toLowerCase())); + const existingLabelNames = normalizeLabelNames(issueData.labels || []); + const mergedSpecs = [...uniqueLabelSpecs, ...existingLabelNames.filter(name => !requestedNamesLower.has(name.toLowerCase())).map(name => ({ name }))]; + + const labelIntentUpdates = buildIssueIntentLabelUpdates(mergedSpecs, labelIdByName); + + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo} via GraphQL intent mutation`); + const result = await withRetry( + () => + githubClient.graphql( + `mutation($issueId: ID!, $labels: [LabelUpdateInput!]!) { + updateIssue(input: { id: $issueId, labels: $labels }) { + issue { + id + labels(first: 100) { + nodes { + name + } + } + } + } + }`, + { issueId: issueNodeId, labels: labelIntentUpdates, headers: { "GraphQL-Features": "update_issue_suggestions" } } + ), + RATE_LIMIT_RETRY_CONFIG, + `add_labels to ${contextType} #${itemNumber} in ${itemRepo}` + ); + + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`); + const afterLabels = result?.updateIssue?.issue?.labels?.nodes || []; + return attachExecutionState( + { + success: true, + number: itemNumber, + repo: itemRepo, + labelsAdded: uniqueLabels, + contextType, + }, + beforeState, + { + ...beforeState, + labels: normalizeLabelNames(afterLabels), + } + ); + } + const { data: labels } = await withRetry( () => githubClient.rest.issues.addLabels({ diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index 39d59826031..07259c99eae 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -35,11 +35,31 @@ describe("add_labels", () => { }; mockGithub = { + graphql: async (query, variables) => { + // Repo labels query used by fetchAllRepoLabels: resolve label IDs by name. + if (typeof query === "string" && query.includes("repository(owner")) { + return { + repository: { + labels: { + nodes: (mockGithub._repoLabels || ["bug", "enhancement", "documentation", "security:low", "security:medium", "security:high"]).map(name => ({ + id: `LABEL_${name}`, + name, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + // updateIssue intent mutation: echo back the requested label names. + const labels = (variables?.labels || []).map(l => ({ name: l.name || l.labelId })); + return { updateIssue: { issue: { id: variables?.issueId, labels: { nodes: labels } } } }; + }, rest: { issues: { addLabels: async () => ({}), get: async () => ({ data: { + node_id: "ISSUE_NODE_ID", title: "Test issue title", labels: [], }, @@ -115,11 +135,14 @@ describe("add_labels", () => { it("should accept structured label entries and add normalized label names", async () => { const handler = await main({ max: 10, issue_intent: true }); - const addLabelsCalls = []; - - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; + const graphqlMutationCalls = []; + + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); }; const result = await handler( @@ -132,18 +155,22 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(result.number).toBe(456); - expect(addLabelsCalls).toHaveLength(1); - // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped - expect(addLabelsCalls[0].labels).toEqual(["bug"]); + // Intent metadata routes through the GraphQL updateIssue mutation, not REST addLabels + expect(graphqlMutationCalls).toHaveLength(1); + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); + expect(graphqlMutationCalls[0].headers).toEqual({ "GraphQL-Features": "update_issue_suggestions" }); }); it("should send structured label metadata without requiring a runtime feature", async () => { const handler = await main({ max: 10, issue_intent: true }); - const addLabelsCalls = []; - - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; + const graphqlMutationCalls = []; + + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); }; const result = await handler( @@ -155,18 +182,20 @@ describe("add_labels", () => { ); expect(result.success).toBe(true); - expect(addLabelsCalls).toHaveLength(1); - // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped - expect(addLabelsCalls[0].labels).toEqual(["bug"]); + expect(graphqlMutationCalls).toHaveLength(1); + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]); }); it("should normalize lowercase confidence in structured label metadata", async () => { const handler = await main({ max: 10, issue_intent: true }); - const addLabelsCalls = []; - - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; + const graphqlMutationCalls = []; + + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); }; const result = await handler( @@ -178,9 +207,43 @@ describe("add_labels", () => { ); expect(result.success).toBe(true); - expect(addLabelsCalls).toHaveLength(1); - // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped - expect(addLabelsCalls[0].labels).toEqual(["bug"]); + expect(graphqlMutationCalls).toHaveLength(1); + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]); + }); + + it("should preserve existing labels when adding intent labels via GraphQL", async () => { + const handler = await main({ max: 10, issue_intent: true }); + const graphqlMutationCalls = []; + + mockGithub.rest.issues.get = async () => ({ + data: { + node_id: "ISSUE_NODE_ID", + title: "Test issue title", + labels: [{ name: "enhancement" }], + }, + }); + + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); + }; + + const result = await handler( + { + item_number: 456, + labels: [{ name: "bug", rationale: "Crash on upload", confidence: "HIGH" }], + }, + {} + ); + + expect(result.success).toBe(true); + expect(result.labelsAdded).toEqual(["bug"]); + expect(graphqlMutationCalls).toHaveLength(1); + // Existing labels are merged (metadata-free) so add-only semantics are preserved + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Crash on upload", confidence: "HIGH" }, { labelId: "LABEL_enhancement" }]); }); it("should accept issue_number as an alias for item_number", async () => { @@ -518,11 +581,14 @@ describe("add_labels", () => { it("should prefer the metadata-bearing entry when a duplicate label name appears", async () => { // Default (omitted issue_intent) accepts both strings and objects; deduplication favours the metadata-bearing entry. const handler = await main({ max: 10 }); - const addLabelsCalls = []; - - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; + const graphqlMutationCalls = []; + + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); }; const result = await handler( @@ -536,9 +602,9 @@ describe("add_labels", () => { expect(result.success).toBe(true); expect(result.labelsAdded).toEqual(["bug"]); - expect(addLabelsCalls.length).toBe(1); - // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped - expect(addLabelsCalls[0].labels).toEqual(["bug"]); + // Intent metadata routes through the GraphQL mutation; the metadata-bearing spec wins + expect(graphqlMutationCalls).toHaveLength(1); + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); }); it("should strip structured intent metadata when issue_intent is disabled", async () => { @@ -563,10 +629,18 @@ describe("add_labels", () => { expect(addLabelsCalls[0].labels).toEqual(["bug"]); }); - it("should strip per-label intent metadata from the REST request when issue_intent is omitted", async () => { + it("should forward per-label intent metadata via GraphQL when issue_intent is omitted", async () => { const handler = await main({ max: 10 }); + const graphqlMutationCalls = []; const addLabelsCalls = []; + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); + }; mockGithub.rest.issues.addLabels = async params => { addLabelsCalls.push(params); return {}; @@ -581,9 +655,10 @@ describe("add_labels", () => { ); expect(result.success).toBe(true); - expect(addLabelsCalls).toHaveLength(1); - // The REST addLabels endpoint only accepts label name strings; intent metadata is stripped - expect(addLabelsCalls[0].labels).toEqual(["bug"]); + // Intent metadata is forwarded through GraphQL, not the REST addLabels endpoint + expect(addLabelsCalls).toHaveLength(0); + expect(graphqlMutationCalls).toHaveLength(1); + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]); }); it("should accept plain string labels by default when issue_intent is omitted", async () => { @@ -657,11 +732,14 @@ describe("add_labels", () => { it("should accept label objects with both rationale and confidence in strict mode", async () => { const handler = await main({ max: 10, issue_intent: true }); - const addLabelsCalls = []; - - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; + const graphqlMutationCalls = []; + + const originalGraphql = mockGithub.graphql; + mockGithub.graphql = async (query, variables) => { + if (typeof query === "string" && query.includes("updateIssue")) { + graphqlMutationCalls.push(variables); + } + return originalGraphql(query, variables); }; const result = await handler( @@ -673,7 +751,8 @@ describe("add_labels", () => { ); expect(result.success).toBe(true); - expect(addLabelsCalls).toHaveLength(1); + expect(graphqlMutationCalls).toHaveLength(1); + expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Crash on upload", confidence: "HIGH" }]); }); it("should sanitize and trim label names", async () => {