mirror of
https://github.com/actions/labeler
synced 2026-09-24 22:51:48 +02:00
Preserve externally managed labels during synchronization (#957)
* Preserve externally managed pull request labels Replace whole-set label writes with batched selective additions and removals so sync-labels only manages configured labels. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 * Reconcile ambiguous label addition failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 * Handle paginated label reconciliation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 --------- Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182
This commit is contained in:
co-authored by
Copilot App
parent
bf12e9b00b
commit
b7a8804475
@@ -0,0 +1,65 @@
|
||||
import * as github from '@actions/github';
|
||||
import {ClientType} from './types.js';
|
||||
|
||||
const isServerError = (error: unknown): error is {status: number} =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof error.status === 'number' &&
|
||||
error.status >= 500 &&
|
||||
error.status < 600;
|
||||
|
||||
export const addLabels = async (
|
||||
client: ClientType,
|
||||
prNumber: number,
|
||||
labels: string[]
|
||||
) => {
|
||||
const request = {
|
||||
owner: github.context.repo.owner,
|
||||
repo: github.context.repo.repo,
|
||||
issue_number: prNumber
|
||||
};
|
||||
|
||||
try {
|
||||
await client.rest.issues.addLabels({
|
||||
...request,
|
||||
labels,
|
||||
request: {retries: 0}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (!isServerError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const currentLabelNames = new Set<string>();
|
||||
let page = 1;
|
||||
try {
|
||||
while (true) {
|
||||
const currentLabels = await client.rest.issues.listLabelsOnIssue({
|
||||
...request,
|
||||
per_page: 100,
|
||||
page,
|
||||
request: {retries: 0}
|
||||
});
|
||||
|
||||
for (const label of currentLabels.data) {
|
||||
currentLabelNames.add(label.name.toLowerCase());
|
||||
}
|
||||
|
||||
if (labels.every(label => currentLabelNames.has(label.toLowerCase()))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentLabels.headers.link?.match(/;\s*rel="next"/)) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
export * from './add-labels.js';
|
||||
export * from './get-changed-files.js';
|
||||
export * from './get-changed-pull-requests.js';
|
||||
export * from './get-content.js';
|
||||
export * from './get-label-configs.js';
|
||||
export * from './set-labels.js';
|
||||
export * from './remove-labels.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {ClientType} from './types.js';
|
||||
|
||||
const REMOVE_LABELS_MUTATION = `
|
||||
mutation RemoveLabels($labelableId: ID!, $labelIds: [ID!]!) {
|
||||
removeLabelsFromLabelable(
|
||||
input: {labelableId: $labelableId, labelIds: $labelIds}
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const removeLabels = async (
|
||||
client: ClientType,
|
||||
labelableId: string,
|
||||
labelIds: string[]
|
||||
) => {
|
||||
await client.graphql(REMOVE_LABELS_MUTATION, {labelableId, labelIds});
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as github from '@actions/github';
|
||||
import {ClientType} from './types.js';
|
||||
|
||||
export const setLabels = async (
|
||||
client: ClientType,
|
||||
prNumber: number,
|
||||
labels: string[]
|
||||
) => {
|
||||
await client.rest.issues.setLabels({
|
||||
owner: github.context.repo.owner,
|
||||
repo: github.context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: labels
|
||||
});
|
||||
};
|
||||
+37
-32
@@ -2,7 +2,6 @@ import * as core from '@actions/core';
|
||||
import * as github from '@actions/github';
|
||||
import * as pluginRetry from '@octokit/plugin-retry';
|
||||
import * as api from './api/index.js';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import {getInputs} from './get-inputs/index.js';
|
||||
|
||||
import {
|
||||
@@ -104,50 +103,56 @@ export async function labeler() {
|
||||
const labelsToApply = [...allLabels].slice(0, GITHUB_MAX_LABELS);
|
||||
const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS);
|
||||
|
||||
let finalLabels = labelsToApply;
|
||||
let newLabels: string[] = [];
|
||||
const finalLabels = labelsToApply;
|
||||
const newLabels = labelsToApply.filter(
|
||||
label => !preexistingLabels.includes(label)
|
||||
);
|
||||
const staleLabels = pullRequest.data.labels.filter(
|
||||
label => labelConfigs.has(label.name) && !allLabels.has(label.name)
|
||||
);
|
||||
|
||||
try {
|
||||
if (!isEqual(labelsToApply, preexistingLabels)) {
|
||||
// Fetch the latest labels for the PR
|
||||
const latestLabels: string[] = [];
|
||||
// Skip fetching real labels when running tests (uses mock data instead)
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const pr = await client.rest.pulls.get({
|
||||
...github.context.repo,
|
||||
pull_number: pullRequest.number
|
||||
});
|
||||
latestLabels.push(...pr.data.labels.map(l => l.name).filter(Boolean));
|
||||
if (staleLabels.length) {
|
||||
const labelableId = pullRequest.data.node_id;
|
||||
const missingNodeId = staleLabels.find(label => !label.node_id);
|
||||
if (!labelableId || missingNodeId) {
|
||||
throw new Error(
|
||||
`Failed to resolve node IDs while removing configured labels from PR #${pullRequest.number}`
|
||||
);
|
||||
}
|
||||
|
||||
// Labels added manually during the run (not in first snapshot)
|
||||
const manualAddedDuringRun = latestLabels.filter(
|
||||
l => !preexistingLabels.includes(l)
|
||||
);
|
||||
try {
|
||||
await api.removeLabels(
|
||||
client,
|
||||
labelableId,
|
||||
staleLabels.map(label => label.node_id)
|
||||
);
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to remove configured labels '${staleLabels.map(label => label.name).join("', '")}' from PR #${pullRequest.number}`,
|
||||
{cause: error}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve manual labels first, then apply config-based labels, respecting GitHub's 100-label limit
|
||||
finalLabels = [
|
||||
...new Set([...manualAddedDuringRun, ...labelsToApply])
|
||||
].slice(0, GITHUB_MAX_LABELS);
|
||||
|
||||
await api.setLabels(client, pullRequest.number, finalLabels);
|
||||
|
||||
newLabels = finalLabels.filter(l => !preexistingLabels.includes(l));
|
||||
if (newLabels.length) {
|
||||
await api.addLabels(client, pullRequest.number, newLabels);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const apiError = error.cause ?? error;
|
||||
if (
|
||||
error.name === 'HttpError' &&
|
||||
error.status === 403 &&
|
||||
error.message.toLowerCase().includes('unauthorized')
|
||||
apiError.name === 'HttpError' &&
|
||||
apiError.status === 403 &&
|
||||
apiError.message.toLowerCase().includes('unauthorized')
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to set labels for PR #${pullRequest.number}. The workflow does not have permission to create labels. ` +
|
||||
`Failed to update labels for PR #${pullRequest.number}. The workflow does not have permission to create labels. ` +
|
||||
`Ensure the 'issues: write' permission is granted in the workflow file or manually create the missing labels in the repository before running the action.`,
|
||||
{cause: error}
|
||||
);
|
||||
} else if (
|
||||
error.name !== 'HttpError' ||
|
||||
error.message !== 'Resource not accessible by integration'
|
||||
apiError.name !== 'HttpError' ||
|
||||
apiError.message !== 'Resource not accessible by integration'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
@@ -160,7 +165,7 @@ export async function labeler() {
|
||||
}
|
||||
);
|
||||
|
||||
core.setFailed(error.message);
|
||||
core.setFailed(apiError.message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user