-
Notifications
You must be signed in to change notification settings - Fork 498
Prevent issue-intent labels from removing existing labels #53672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
88f31e2
8ba6bfa
bdb3557
6bd4121
7e18838
6a761e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,23 @@ function isPullRequestItem(issueData, nodeId) { | |
| return Boolean(issueData?.pull_request) || nodeId.startsWith("PR_"); | ||
| } | ||
|
|
||
| /** | ||
| * @param {...Array<string|{ name?: string }>} labelGroups | ||
| * @returns {string[]} | ||
| */ | ||
| function mergeLabelNames(...labelGroups) { | ||
| const merged = []; | ||
| const seenLower = new Set(); | ||
| for (const label of labelGroups.flatMap(group => normalizeLabelNames(group))) { | ||
| const key = label.toLowerCase(); | ||
| if (!seenLower.has(key)) { | ||
| seenLower.add(key); | ||
| merged.push(label); | ||
| } | ||
| } | ||
| return merged; | ||
| } | ||
|
|
||
| /** | ||
| * Apply labels with issue-intent metadata through the GraphQL updateIssue mutation. | ||
| * That mutation replaces the issue's label set, so the requested specs are merged with the | ||
|
|
@@ -72,17 +89,15 @@ function isPullRequestItem(issueData, nodeId) { | |
| * issueNodeId: string, | ||
| * labelSpecs: Array<{ name: string }>, | ||
| * }} params | ||
| * @returns {Promise<string[]>} The label names on the issue after the mutation | ||
| * @returns {Promise<string[]>} The label names on the issue after the mutation and any recovery | ||
| */ | ||
| async function applyIssueIntentLabels({ githubClient, core, repoParts, itemNumber, itemRepo, contextType, issueData, issueNodeId, labelSpecs }) { | ||
| 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(labelSpecs.map(spec => spec.name.toLowerCase())); | ||
| const existingLabelNames = normalizeLabelNames(issueData.labels || []); | ||
| const mergedSpecs = [...labelSpecs, ...existingLabelNames.filter(name => !requestedNamesLower.has(name.toLowerCase())).map(name => ({ name }))]; | ||
| const labelSpecNamesLower = new Set(labelSpecs.map(spec => spec.name.toLowerCase())); | ||
| const mergedSpecs = [...labelSpecs, ...existingLabelNames.filter(name => !labelSpecNamesLower.has(name.toLowerCase())).map(name => ({ name }))]; | ||
|
|
||
| const labelIntentUpdates = buildIssueIntentLabelUpdates(mergedSpecs, labelIdByName); | ||
|
|
||
|
|
@@ -111,7 +126,29 @@ async function applyIssueIntentLabels({ githubClient, core, repoParts, itemNumbe | |
| `add_labels to ${contextType} #${itemNumber} in ${itemRepo}` | ||
| ); | ||
|
|
||
| return normalizeLabelNames(result?.updateIssue?.issue?.labels?.nodes || []); | ||
| let afterLabels = normalizeLabelNames(result?.updateIssue?.issue?.labels?.nodes || []); | ||
| const afterNamesLower = new Set(afterLabels.map(name => name.toLowerCase())); | ||
| const missingExistingLabels = existingLabelNames.filter(name => !afterNamesLower.has(name.toLowerCase())); | ||
|
|
||
| if (missingExistingLabels.length > 0) { | ||
| core.warning( | ||
| `The GraphQL intent mutation removed ${missingExistingLabels.length} pre-existing label(s) from ${contextType} #${itemNumber} in ${itemRepo}; restoring them via the REST add-labels endpoint: ${JSON.stringify(missingExistingLabels)}` | ||
| ); | ||
| const { data: restoredLabels } = await withRetry( | ||
| () => | ||
| githubClient.rest.issues.addLabels({ | ||
| owner: repoParts.owner, | ||
| repo: repoParts.repo, | ||
| issue_number: itemNumber, | ||
| labels: missingExistingLabels, | ||
| }), | ||
| RATE_LIMIT_RETRY_CONFIG, | ||
| `restore labels on ${contextType} #${itemNumber} in ${itemRepo}` | ||
| ); | ||
| afterLabels = mergeLabelNames(existingLabelNames, afterLabels, restoredLabels); | ||
| } | ||
|
|
||
| return afterLabels; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -345,10 +382,7 @@ const main = createCountGatedHandler({ | |
| 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. | ||
| // Intent metadata is only supported via the GraphQL updateIssue mutation. | ||
| const { data: issueData } = await withRetry( | ||
| () => | ||
| githubClient.rest.issues.get({ | ||
|
|
@@ -372,25 +406,60 @@ const main = createCountGatedHandler({ | |
| if (isPullRequestItem(issueData, issueNodeId)) { | ||
| core.info(`Issue-intent label metadata is not supported for pull requests; falling back to the REST add-labels endpoint for ${contextType} #${itemNumber} in ${itemRepo}`); | ||
| } else { | ||
| const afterLabels = await applyIssueIntentLabels({ | ||
| githubClient, | ||
| core, | ||
| repoParts, | ||
| itemNumber, | ||
| itemRepo, | ||
| contextType, | ||
| issueData, | ||
| issueNodeId, | ||
| labelSpecs: uniqueLabelSpecs, | ||
| }); | ||
|
|
||
| core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`); | ||
| const existingLabels = normalizeLabelNames(issueData.labels || []); | ||
| const existingNamesLower = new Set(existingLabels.map(name => name.toLowerCase())); | ||
| const newLabelSpecs = uniqueLabelSpecs.filter(spec => !existingNamesLower.has(spec.name.toLowerCase())); | ||
| let afterLabels = | ||
| newLabelSpecs.length > 0 | ||
| ? await applyIssueIntentLabels({ | ||
| githubClient, | ||
| core, | ||
| repoParts, | ||
| itemNumber, | ||
| itemRepo, | ||
| contextType, | ||
| issueData, | ||
| issueNodeId, | ||
| labelSpecs: newLabelSpecs, | ||
| }) | ||
| : existingLabels; | ||
| let afterNamesLower = new Set(afterLabels.map(name => name.toLowerCase())); | ||
| const plainLabelsNotApplied = newLabelSpecs.filter(spec => !hasLabelIntentMetadata(spec) && !afterNamesLower.has(spec.name.toLowerCase())).map(spec => spec.name); | ||
|
|
||
| if (plainLabelsNotApplied.length > 0) { | ||
| core.info(`Adding ${plainLabelsNotApplied.length} metadata-free label(s) not applied by the intent mutation via the REST add-labels endpoint: ${JSON.stringify(plainLabelsNotApplied)}`); | ||
| const { data: labels } = await withRetry( | ||
| () => | ||
| githubClient.rest.issues.addLabels({ | ||
| owner: repoParts.owner, | ||
| repo: repoParts.repo, | ||
| issue_number: itemNumber, | ||
| labels: plainLabelsNotApplied, | ||
| }), | ||
| RATE_LIMIT_RETRY_CONFIG, | ||
| `add metadata-free labels to ${contextType} #${itemNumber} in ${itemRepo}` | ||
| ); | ||
| afterLabels = mergeLabelNames(afterLabels, labels); | ||
| afterNamesLower = new Set(afterLabels.map(name => name.toLowerCase())); | ||
| } | ||
|
|
||
| const labelsAdded = newLabelSpecs.filter(spec => afterNamesLower.has(spec.name.toLowerCase())).map(spec => spec.name); | ||
| const labelsSuggested = newLabelSpecs.filter(spec => hasLabelIntentMetadata(spec) && !afterNamesLower.has(spec.name.toLowerCase())).map(spec => spec.name); | ||
|
|
||
| if (newLabelSpecs.length === 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. L412-413: delete: redundant "No new labels to add" info log. Line 415's "Successfully added 0 labels" already conveys this. |
||
| core.info(`No new labels to add to ${contextType} #${itemNumber} in ${itemRepo}`); | ||
| } | ||
| core.info(`Successfully added ${labelsAdded.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`); | ||
| if (labelsSuggested.length > 0) { | ||
| core.info(`Suggested ${labelsSuggested.length} labels for ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(labelsSuggested)}`); | ||
| } | ||
| return attachExecutionState( | ||
| { | ||
| success: true, | ||
| number: itemNumber, | ||
| repo: itemRepo, | ||
| labelsAdded: uniqueLabels, | ||
| labelsAdded, | ||
| labelsSuggested, | ||
| contextType, | ||
| }, | ||
| beforeState, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,7 +51,7 @@ describe("add_labels", () => { | |
| }; | ||
| } | ||
| // updateIssue intent mutation: echo back the requested label names. | ||
| const labels = (variables?.labels || []).map(l => ({ name: l.name || l.labelId })); | ||
| const labels = (variables?.labels || []).map(l => ({ name: l.name || l.labelId?.replace(/^LABEL_/, "") })); | ||
| return { updateIssue: { issue: { id: variables?.issueId, labels: { nodes: labels } } } }; | ||
| }, | ||
| rest: { | ||
|
|
@@ -246,6 +246,193 @@ describe("add_labels", () => { | |
| expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Crash on upload", confidence: "HIGH" }, { labelId: "LABEL_enhancement" }]); | ||
| }); | ||
|
|
||
| it("should skip an already-applied label instead of re-proposing its intent metadata", async () => { | ||
| const handler = await main({ max: 10 }); | ||
| const graphqlMutationCalls = []; | ||
| const addLabelsCalls = []; | ||
|
|
||
| mockGithub.rest.issues.get = async () => ({ | ||
| data: { | ||
| node_id: "ISSUE_NODE_ID", | ||
| title: "Test issue title", | ||
| labels: [{ name: "feature-openapi" }, { name: "area-minimal" }], | ||
| }, | ||
| }); | ||
| 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 {}; | ||
| }; | ||
|
|
||
| const result = await handler( | ||
| { | ||
| item_number: 68619, | ||
| labels: [{ name: "area-minimal", rationale: "Minimal APIs area", confidence: "MEDIUM" }], | ||
| }, | ||
| {} | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.labelsAdded).toEqual([]); | ||
| expect(result.labelsSuggested).toEqual([]); | ||
| expect(result.after_state.labels).toEqual(["feature-openapi", "area-minimal"]); | ||
| expect(graphqlMutationCalls).toHaveLength(0); | ||
| expect(addLabelsCalls).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("should report a confidence-gated intent label as suggested rather than added", async () => { | ||
| const handler = await main({ max: 10 }); | ||
|
|
||
| mockGithub.rest.issues.get = async () => ({ | ||
| data: { | ||
| node_id: "ISSUE_NODE_ID", | ||
| title: "Test issue title", | ||
| labels: [{ name: "feature-openapi" }], | ||
| }, | ||
| }); | ||
| mockGithub.graphql = async (query, variables) => { | ||
| if (typeof query === "string" && query.includes("repository(owner")) { | ||
| return { | ||
| repository: { | ||
| labels: { | ||
| nodes: [ | ||
| { id: "LABEL_feature-openapi", name: "feature-openapi" }, | ||
| { id: "LABEL_area-minimal", name: "area-minimal" }, | ||
| ], | ||
| pageInfo: { hasNextPage: false, endCursor: null }, | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
| return { updateIssue: { issue: { labels: { nodes: [{ name: "feature-openapi" }] } } } }; | ||
| }; | ||
|
|
||
| const result = await handler( | ||
| { | ||
| item_number: 68619, | ||
| labels: [{ name: "area-minimal", rationale: "Minimal APIs area", confidence: "MEDIUM" }], | ||
| }, | ||
| {} | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.labelsAdded).toEqual([]); | ||
| expect(result.labelsSuggested).toEqual(["area-minimal"]); | ||
| expect(result.after_state.labels).toEqual(["feature-openapi"]); | ||
| expect(mockCore.infos).toContain("Successfully added 0 labels to issue #68619 in test-owner/test-repo"); | ||
| }); | ||
|
|
||
| it("should add metadata-free labels through REST when the intent mutation does not apply them", async () => { | ||
| const handler = await main({ max: 10 }); | ||
| const addLabelsCalls = []; | ||
| const graphqlMutationCalls = []; | ||
|
|
||
| mockGithub.rest.issues.get = async () => ({ | ||
| data: { | ||
| node_id: "ISSUE_NODE_ID", | ||
| title: "Test issue title", | ||
| labels: [{ name: "feature-openapi" }], | ||
| }, | ||
| }); | ||
| mockGithub.graphql = async (query, variables) => { | ||
| if (typeof query === "string" && query.includes("repository(owner")) { | ||
| return { | ||
| repository: { | ||
| labels: { | ||
| nodes: [ | ||
| { id: "LABEL_feature-openapi", name: "feature-openapi" }, | ||
| { id: "LABEL_area-minimal", name: "area-minimal" }, | ||
| { id: "LABEL_bug", name: "bug" }, | ||
| ], | ||
| pageInfo: { hasNextPage: false, endCursor: null }, | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
| graphqlMutationCalls.push(variables); | ||
| return { updateIssue: { issue: { labels: { nodes: [{ name: "feature-openapi" }] } } } }; | ||
| }; | ||
| mockGithub.rest.issues.addLabels = async params => { | ||
| addLabelsCalls.push(params); | ||
| return { data: [{ name: "bug" }] }; | ||
| }; | ||
|
|
||
| const result = await handler( | ||
| { | ||
| item_number: 68619, | ||
| labels: [{ name: "area-minimal", rationale: "Minimal APIs area", confidence: "MEDIUM" }, { name: "bug" }], | ||
| }, | ||
| {} | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.labelsAdded).toEqual(["bug"]); | ||
| expect(result.labelsSuggested).toEqual(["area-minimal"]); | ||
| expect(result.after_state.labels).toEqual(["feature-openapi", "bug"]); | ||
| expect(result.after_state.labels).not.toContain("area-minimal"); | ||
| expect(graphqlMutationCalls).toHaveLength(1); | ||
| expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_area-minimal", rationale: "Minimal APIs area", confidence: "MEDIUM" }, { labelId: "LABEL_bug" }, { labelId: "LABEL_feature-openapi" }]); | ||
| expect(addLabelsCalls).toHaveLength(1); | ||
| expect(addLabelsCalls[0].labels).toEqual(["bug"]); | ||
| }); | ||
|
|
||
| it("should restore pre-existing labels omitted by the intent mutation", async () => { | ||
| const handler = await main({ max: 10 }); | ||
| const addLabelsCalls = []; | ||
| const restoredLabels = [{ name: "area-minimal" }]; | ||
|
|
||
| mockGithub.rest.issues.get = async () => ({ | ||
| data: { | ||
| node_id: "ISSUE_NODE_ID", | ||
| title: "Test issue title", | ||
| labels: [{ name: "feature-openapi" }, { name: "area-minimal" }], | ||
| }, | ||
| }); | ||
| mockGithub.graphql = async (query, variables) => { | ||
| if (typeof query === "string" && query.includes("repository(owner")) { | ||
| return { | ||
| repository: { | ||
| labels: { | ||
| nodes: [ | ||
| { id: "LABEL_feature-openapi", name: "feature-openapi" }, | ||
| { id: "LABEL_area-minimal", name: "area-minimal" }, | ||
| { id: "LABEL_bug", name: "bug" }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The "restore pre-existing labels" test asserts This means 💡 Suggested fixMock the @copilot please address this. |
||
| ], | ||
| pageInfo: { hasNextPage: false, endCursor: null }, | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
| return { updateIssue: { issue: { labels: { nodes: [{ name: "feature-openapi" }, { name: "bug" }] } } } }; | ||
| }; | ||
| mockGithub.rest.issues.addLabels = async params => { | ||
| addLabelsCalls.push(params); | ||
| return { data: restoredLabels }; | ||
| }; | ||
|
|
||
| const result = await handler( | ||
| { | ||
| item_number: 68619, | ||
| labels: [{ name: "bug", rationale: "Confirmed defect", confidence: "HIGH" }], | ||
| }, | ||
| {} | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.labelsAdded).toEqual(["bug"]); | ||
| expect(result.labelsSuggested).toEqual([]); | ||
| expect(result.after_state.labels).toEqual(["feature-openapi", "area-minimal", "bug"]); | ||
| expect(addLabelsCalls).toHaveLength(1); | ||
| expect(addLabelsCalls[0].labels).toEqual(["area-minimal"]); | ||
| expect(mockCore.warnings[0]).toContain("restoring them via the REST add-labels endpoint"); | ||
| }); | ||
|
|
||
| it("should return a standardized error code when issue node_id is missing on issue-intent path", async () => { | ||
| const handler = await main({ max: 10, issue_intent: true }); | ||
| mockGithub.rest.issues.get = async () => ({ | ||
|
|
@@ -894,7 +1081,7 @@ describe("add_labels", () => { | |
| const result = await handler( | ||
| { | ||
| item_number: 456, | ||
| labels: [{ name: "bug", rationale: "Crash", confidence: "HIGH" }], | ||
| labels: [{ name: "enhancement", rationale: "Improves the issue", confidence: "HIGH" }], | ||
| }, | ||
| {} | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/diagnosing-bugs] Silent loss: plain-name
newLabelSpecsentries dropped by the mutation land in neitherlabelsAddednorlabelsSuggested.labelsSuggestedis gated onhasLabelIntentMetadata(spec), so a{ name: "foo" }spec that the mutation silently drops vanishes from the result with no trace — callers can't distinguish "applied", "suggested", and "silently dropped".💡 Suggested fix
Capture all unapplied new labels and split them afterwards:
A regression test with a metadata-free spec that the mutation silently drops would lock this in.
@copilot please address this.