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:
Logan Rosen
2026-07-30 21:51:30 -05:00
committed by GitHub
co-authored by Copilot App
parent bf12e9b00b
commit b7a8804475
14 changed files with 553 additions and 2119 deletions
+65
View File
@@ -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
View File
@@ -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';
+19
View File
@@ -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});
};
-15
View File
@@ -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
View File
@@ -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;
}