Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 103 additions & 70 deletions actions/setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,75 @@ function hasLabelIntentMetadata(spec) {
return Boolean(spec && (spec.rationale || spec.confidence || spec.suggest));
}

/**
* Detect whether an item fetched via the REST issues endpoint is a pull request.
* The endpoint returns a `pull_request` field for PRs, and PR node IDs start with "PR_".
* @param {{ pull_request?: unknown } | null | undefined} issueData
* @param {string} nodeId
* @returns {boolean}
*/
function isPullRequestItem(issueData, nodeId) {
return Boolean(issueData?.pull_request) || nodeId.startsWith("PR_");
}

/**
* 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
* issue's existing labels to preserve add-only semantics. Existing labels are sent without
* intent metadata; newly requested labels carry their metadata.
* @param {{
* githubClient: any,
* core: any,
* repoParts: { owner: string, repo: string },
* itemNumber: number,
* itemRepo: string,
* contextType: string,
* issueData: any,
* issueNodeId: string,
* labelSpecs: Array<{ name: string }>,
* }} params
* @returns {Promise<string[]>} The label names on the issue after the mutation
*/
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 labelIntentUpdates = buildIssueIntentLabelUpdates(mergedSpecs, labelIdByName);

core.info(`Adding ${labelSpecs.length} labels to ${contextType} #${itemNumber} in ${itemRepo} via GraphQL intent mutation`);
// updateIssue accepts LabelUpdateInput (rationale/confidence/suggest), which is gated
// by the "update_issue_suggestions" GraphQL feature flag.
const intentHeaders = { "GraphQL-Features": "update_issue_suggestions" };
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: intentHeaders }
),
RATE_LIMIT_RETRY_CONFIG,
`add_labels to ${contextType} #${itemNumber} in ${itemRepo}`
);

return normalizeLabelNames(result?.updateIssue?.issue?.labels?.nodes || []);
}

/**
* Main handler factory for add_labels
* Uses shared count-gated scaffold for max-limit enforcement.
Expand Down Expand Up @@ -278,77 +347,41 @@ const main = createCountGatedHandler({
throw new Error(`Failed to resolve GraphQL node ID for ${contextType} #${itemNumber}`);
}

// Detect whether the item is a pull request. The REST issues endpoint returns a
// `pull_request` field for PRs, and PR node IDs start with "PR_". The GraphQL
// updateIssue mutation only accepts Issue node IDs; PRs must use updatePullRequest.
const itemIsPR = Boolean(issueData?.pull_request) || issueNodeId.startsWith("PR_");

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`);
// Both updateIssue and updatePullRequest use LabelUpdateInput (rationale/confidence/suggest),
// which is gated by the "update_issue_suggestions" GraphQL feature flag.
const intentHeaders = { "GraphQL-Features": "update_issue_suggestions" };
const [mutationQuery, mutationVars, getResultLabels] = itemIsPR
? [
`mutation($prId: ID!, $labels: [LabelUpdateInput!]!) {
updatePullRequest(input: { pullRequestId: $prId, labels: $labels }) {
pullRequest {
id
labels(first: 100) {
nodes {
name
}
}
}
}
}`,
{ prId: issueNodeId, labels: labelIntentUpdates, headers: intentHeaders },
r => r?.updatePullRequest?.pullRequest?.labels?.nodes,
]
: [
`mutation($issueId: ID!, $labels: [LabelUpdateInput!]!) {
updateIssue(input: { id: $issueId, labels: $labels }) {
issue {
id
labels(first: 100) {
nodes {
name
}
}
}
}
}`,
{ issueId: issueNodeId, labels: labelIntentUpdates, headers: intentHeaders },
r => r?.updateIssue?.issue?.labels?.nodes,
];
const result = await withRetry(() => githubClient.graphql(mutationQuery, mutationVars), 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 = getResultLabels(result) || [];
return attachExecutionState(
{
success: true,
number: itemNumber,
repo: itemRepo,
labelsAdded: uniqueLabels,
// The GraphQL updateIssue mutation only accepts Issue node IDs, and
// UpdatePullRequestInput does not accept a `labels` field, so there is no
// intent-aware mutation for PRs. Fall back to the REST add-labels endpoint
// (add-only, without intent metadata).
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,
},
beforeState,
{
...beforeState,
labels: normalizeLabelNames(afterLabels),
}
);
issueData,
issueNodeId,
labelSpecs: uniqueLabelSpecs,
});

core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`);
return attachExecutionState(
{
success: true,
number: itemNumber,
repo: itemRepo,
labelsAdded: uniqueLabels,
contextType,
},
beforeState,
{
...beforeState,
labels: afterLabels,
}
);
}
}

const { data: labels } = await withRetry(
Expand Down
48 changes: 30 additions & 18 deletions actions/setup/js/add_labels.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -755,9 +755,10 @@ describe("add_labels", () => {
expect(graphqlMutationCalls[0].labels).toEqual([{ labelId: "LABEL_bug", rationale: "Crash on upload", confidence: "HIGH" }]);
});

it("should use updatePullRequest mutation for PRs when using issue_intent (pull_request field)", async () => {
it("should fall back to the REST add-labels endpoint for PRs when using issue_intent (pull_request field)", async () => {
const handler = await main({ max: 10, issue_intent: true });
const graphqlMutationCalls = [];
const addLabelsCalls = [];

mockGithub.rest.issues.get = async () => ({
data: {
Expand All @@ -768,12 +769,15 @@ describe("add_labels", () => {
},
});

mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return { data: (params.labels || []).map(name => ({ name })) };
};

const originalGraphql = mockGithub.graphql;
mockGithub.graphql = async (query, variables) => {
if (typeof query === "string" && query.includes("updatePullRequest")) {
graphqlMutationCalls.push({ mutation: "updatePullRequest", variables });
const labels = (variables?.labels || []).map(l => ({ name: l.name || l.labelId }));
return { updatePullRequest: { pullRequest: { id: variables?.prId, labels: { nodes: labels } } } };
if (typeof query === "string" && (query.includes("updatePullRequest") || query.includes("updateIssue"))) {
graphqlMutationCalls.push({ query, variables });
}
return originalGraphql(query, variables);
};
Expand All @@ -788,15 +792,17 @@ describe("add_labels", () => {

expect(result.success).toBe(true);
expect(result.labelsAdded).toEqual(["security:medium"]);
Comment thread
pelikhan marked this conversation as resolved.
expect(graphqlMutationCalls).toHaveLength(1);
expect(graphqlMutationCalls[0].mutation).toBe("updatePullRequest");
expect(graphqlMutationCalls[0].variables.prId).toBe("PR_kwDOB7ZBY877o-t0");
expect(graphqlMutationCalls[0].variables.labels).toEqual([{ labelId: "LABEL_security:medium", rationale: "CVE found", confidence: "HIGH" }]);
expect(result.after_state.labels).toEqual(["security:medium"]);
expect(graphqlMutationCalls).toHaveLength(0);
expect(addLabelsCalls).toHaveLength(1);
expect(addLabelsCalls[0].issue_number).toBe(456);
expect(addLabelsCalls[0].labels).toEqual(["security:medium"]);
});

it("should use updatePullRequest mutation for PRs when node_id starts with PR_", async () => {
it("should fall back to the REST add-labels endpoint for PRs when node_id starts with PR_", async () => {
const handler = await main({ max: 10, issue_intent: true });
const graphqlMutationCalls = [];
const addLabelsCalls = [];

// PR without pull_request field (detected by node_id prefix)
mockGithub.rest.issues.get = async () => ({
Expand All @@ -807,12 +813,15 @@ describe("add_labels", () => {
},
});

mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return { data: (params.labels || []).map(name => ({ name })) };
};

const originalGraphql = mockGithub.graphql;
mockGithub.graphql = async (query, variables) => {
if (typeof query === "string" && query.includes("updatePullRequest")) {
graphqlMutationCalls.push({ mutation: "updatePullRequest", variables });
const labels = (variables?.labels || []).map(l => ({ name: l.name || l.labelId }));
return { updatePullRequest: { pullRequest: { id: variables?.prId, labels: { nodes: labels } } } };
if (typeof query === "string" && (query.includes("updatePullRequest") || query.includes("updateIssue"))) {
graphqlMutationCalls.push({ query, variables });
}
return originalGraphql(query, variables);
};
Expand All @@ -826,12 +835,15 @@ describe("add_labels", () => {
);

expect(result.success).toBe(true);
Comment thread
pelikhan marked this conversation as resolved.
expect(graphqlMutationCalls).toHaveLength(1);
expect(graphqlMutationCalls[0].mutation).toBe("updatePullRequest");
expect(graphqlMutationCalls[0].variables.prId).toBe("PR_kwDOABC123");
expect(result.labelsAdded).toEqual(["enhancement"]);
expect(result.after_state.labels).toEqual(["enhancement"]);
expect(graphqlMutationCalls).toHaveLength(0);
expect(addLabelsCalls).toHaveLength(1);
expect(addLabelsCalls[0].issue_number).toBe(789);
expect(addLabelsCalls[0].labels).toEqual(["enhancement"]);
});

it("should not call updatePullRequest for regular issues (ISSUE_ node_id)", async () => {
it("should use the updateIssue intent mutation for regular issues (ISSUE_ node_id)", async () => {
const handler = await main({ max: 10, issue_intent: true });
const updateIssueCalls = [];
const updatePRCalls = [];
Expand Down
Loading