Skip to content

Commit 66a62eb

Browse files
fix: Add type/lang labels and refactor label creation
Introduce explicit type and language GitHub labels (with LANG_LABEL_COLOR and TYPE_LABEL_COLOR) and helper functions: getTypeLabel, getLanguageLabels, buildIssueLabels, and ensureIssueLabels. Replace ensureCrowdinLabel with a generic ensureLabel, keep ensureCrowdinLabel as a wrapper, and make createGithubIssue accept an explicit labels array. Sync logic now builds a minimal patch (title/body/labels), ensures labels exist before applying them, and avoids unnecessary updates; createNewIssue ensures labels are created prior to issue creation. Tests updated to cover label generation, creation, and the refined sync behavior.
1 parent 1d1f331 commit 66a62eb

2 files changed

Lines changed: 436 additions & 127 deletions

File tree

src/sync-crowdin-issues.js

Lines changed: 122 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ const [GH_OWNER, GH_REPO] = (GITHUB_REPOSITORY ?? '/').split('/');
7474
/** Label applied to every GitHub issue managed by this script. */
7575
const CROWDIN_LABEL = 'crowdin';
7676

77+
/** Color used for language labels (lang:xx). */
78+
const LANG_LABEL_COLOR = 'bfd4f2';
79+
80+
/** Color used for issue-type labels (type:xx). */
81+
const TYPE_LABEL_COLOR = '0075ca';
82+
7783
/** RegExp that matches the hidden deduplication marker embedded in issue bodies. */
7884
const MARKER_RE = /<!-- crowdin-issue-id:(\d+):(\d+) -->/;
7985

@@ -106,25 +112,36 @@ async function fetchCrowdinIssues(projectId) {
106112
// GitHub helpers
107113

108114
/**
109-
* Creates the "crowdin" label in the repository if it doesn't already exist.
115+
* Creates a label in the repository if it doesn't already exist.
110116
* A 422 response means the label already exists and is silently ignored.
117+
*
118+
* @param {string} name
119+
* @param {string} color Hex color without the leading `#`.
120+
* @param {string} description
111121
*/
112-
async function ensureCrowdinLabel() {
122+
async function ensureLabel(name, color, description) {
113123
try {
114124
await octokit.rest.issues.createLabel({
115125
owner: GH_OWNER,
116126
repo: GH_REPO,
117-
name: CROWDIN_LABEL,
118-
color: '1f883d',
119-
description: 'Synced automatically from Crowdin',
127+
name,
128+
color,
129+
description,
120130
});
121-
console.log(` Created label "${CROWDIN_LABEL}".`);
131+
console.log(` Created label "${name}".`);
122132
} catch (e) {
123133
if (e.status === 422) return; // already exists – fine
124-
console.warn(` WARN: Could not create label "${CROWDIN_LABEL}": ${e.message}`);
134+
console.warn(` WARN: Could not create label "${name}": ${e.message}`);
125135
}
126136
}
127137

138+
/**
139+
* Ensures the base "crowdin" label exists.
140+
*/
141+
async function ensureCrowdinLabel() {
142+
await ensureLabel(CROWDIN_LABEL, '1f883d', 'Synced automatically from Crowdin');
143+
}
144+
128145
/**
129146
* Fetches ALL GitHub issues (open and closed) that carry the crowdin label
130147
* and returns a Map keyed by "projectId:issueId" derived from each issue's
@@ -155,19 +172,20 @@ async function loadExistingGithubIssues() {
155172
}
156173

157174
/**
158-
* Creates a new GitHub issue with the crowdin label.
175+
* Creates a new GitHub issue with the given labels.
159176
*
160-
* @param {string} title
161-
* @param {string} body
177+
* @param {string} title
178+
* @param {string} body
179+
* @param {string[]} labels Label names to apply (must already exist).
162180
* @returns {Promise<object>} Created issue object.
163181
*/
164-
async function createGithubIssue(title, body) {
182+
async function createGithubIssue(title, body, labels) {
165183
const { data } = await octokit.rest.issues.create({
166184
owner: GH_OWNER,
167185
repo: GH_REPO,
168186
title,
169187
body,
170-
labels: [CROWDIN_LABEL],
188+
labels,
171189
});
172190
return data;
173191
}
@@ -245,22 +263,77 @@ function getGithubAssignees(languageId) {
245263
.filter(Boolean);
246264
}
247265

266+
/**
267+
* Returns the label name for a Crowdin issue type.
268+
* Format: "type:<slug>" where slug replaces underscores/spaces with hyphens.
269+
*
270+
* @param {string} issueType Raw Crowdin issue type string.
271+
* @returns {string}
272+
*/
273+
function getTypeLabel(issueType) {
274+
return `type:${issueType.toLowerCase().replaceAll('_', '-')}`;
275+
}
276+
277+
/**
278+
* Returns the language label name(s) for a Crowdin language ID.
279+
* A compound language like "pt-BR" produces two labels: ["lang:pt", "lang:pt-BR"].
280+
* A simple language like "fr" produces one label: ["lang:fr"].
281+
*
282+
* @param {string|null|undefined} languageId
283+
* @returns {string[]}
284+
*/
285+
function getLanguageLabels(languageId) {
286+
if (!languageId) return [];
287+
const normalised = languageId.replace('_', '-');
288+
const parts = normalised.split('-');
289+
const labels = [`lang:${parts[0]}`];
290+
if (parts.length > 1) labels.push(`lang:${normalised}`);
291+
return labels;
292+
}
293+
294+
/**
295+
* Returns the full sorted list of GitHub label names for a Crowdin issue.
296+
* Always includes the base "crowdin" label plus type and language labels.
297+
*
298+
* @param {object} crowdinIssue
299+
* @returns {string[]}
300+
*/
301+
function buildIssueLabels(crowdinIssue) {
302+
return [
303+
CROWDIN_LABEL,
304+
getTypeLabel(crowdinIssue.issueType),
305+
...getLanguageLabels(crowdinIssue.languageId),
306+
];
307+
}
308+
309+
/**
310+
* Ensures all labels required for a Crowdin issue exist in the repository.
311+
*
312+
* @param {object} crowdinIssue
313+
* @returns {Promise<void>}
314+
*/
315+
async function ensureIssueLabels(crowdinIssue) {
316+
const typeLabel = getTypeLabel(crowdinIssue.issueType);
317+
const typeName = TYPE_MAP[crowdinIssue.issueType] ?? crowdinIssue.issueType;
318+
await ensureLabel(typeLabel, TYPE_LABEL_COLOR, typeName);
319+
320+
for (const langLabel of getLanguageLabels(crowdinIssue.languageId)) {
321+
await ensureLabel(langLabel, LANG_LABEL_COLOR, `Language: ${langLabel.slice(5)}`);
322+
}
323+
}
324+
248325
/**
249326
* Builds the GitHub issue title from a Crowdin issue object.
327+
* The title is just the (truncated) issue description — type and language
328+
* are expressed as labels instead.
250329
*
251330
* @param {object} crowdinIssue
252331
* @returns {string}
253332
*/
254333
function buildIssueTitle(crowdinIssue) {
255-
const type = TYPE_MAP[crowdinIssue.issueType] ?? crowdinIssue.issueType;
256-
const lang = crowdinIssue.languageId
257-
? ` [${crowdinIssue.languageId.toUpperCase()}]`
258-
: '';
259-
// Truncate description so the title stays concise.
260-
const snippet = (crowdinIssue.text ?? '')
334+
return (crowdinIssue.text ?? '')
261335
.replaceAll(/[\r\n]+/g, ' ')
262336
.slice(0, 72);
263-
return `[Crowdin]${lang} ${type}: ${snippet}`;
264337
}
265338

266339
/**
@@ -357,18 +430,33 @@ async function syncExistingIssue(ghIssue, crowdinIssue, projectId) {
357430
return;
358431
}
359432

433+
const expectedTitle = buildIssueTitle(crowdinIssue);
360434
const expectedBody = buildIssueBody(crowdinIssue, projectId);
361-
const bodyPatch = ghIssue.body === expectedBody ? {} : { body: expectedBody };
435+
const expectedLabels = buildIssueLabels(crowdinIssue);
436+
437+
// Build a patch containing only changed fields.
438+
const patch = {};
439+
if (ghIssue.title !== expectedTitle) patch.title = expectedTitle;
440+
if (ghIssue.body !== expectedBody) patch.body = expectedBody;
441+
442+
// Compare label sets (order-independent).
443+
const currentLabels = (ghIssue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)).sort();
444+
const desiredLabels = [...expectedLabels].sort();
445+
if (JSON.stringify(currentLabels) !== JSON.stringify(desiredLabels)) {
446+
patch.labels = expectedLabels;
447+
// Ensure any new labels exist before applying them.
448+
await ensureIssueLabels(crowdinIssue);
449+
}
362450

363451
if (isResolved && ghOpen) {
364-
await updateGithubIssue(ghIssue.number, { state: 'closed', state_reason: 'completed', ...bodyPatch });
452+
await updateGithubIssue(ghIssue.number, { state: 'closed', state_reason: 'completed', ...patch });
365453
console.log(` ✖ Closed GH #${ghIssue.number} ← Crowdin #${crowdinIssue.id} resolved`);
366454
} else if (!isResolved && !ghOpen) {
367-
await updateGithubIssue(ghIssue.number, { state: 'open', ...bodyPatch });
455+
await updateGithubIssue(ghIssue.number, { state: 'open', ...patch });
368456
console.log(` ↺ Reopened GH #${ghIssue.number} ← Crowdin #${crowdinIssue.id} re-opened`);
369-
} else if (Object.keys(bodyPatch).length > 0) {
370-
await updateGithubIssue(ghIssue.number, bodyPatch);
371-
console.log(` ✎ Updated GH #${ghIssue.number} ← Crowdin #${crowdinIssue.id} body refreshed`);
457+
} else if (Object.keys(patch).length > 0) {
458+
await updateGithubIssue(ghIssue.number, patch);
459+
console.log(` ✎ Updated GH #${ghIssue.number} ← Crowdin #${crowdinIssue.id} refreshed`);
372460
} else {
373461
console.log(` ✔ In sync GH #${ghIssue.number} ← Crowdin #${crowdinIssue.id}`);
374462
}
@@ -385,9 +473,11 @@ async function syncExistingIssue(ghIssue, crowdinIssue, projectId) {
385473
*/
386474
async function createNewIssue(crowdinIssue, projectId, existingMap, key) {
387475
const assignees = getGithubAssignees(crowdinIssue.languageId);
476+
await ensureIssueLabels(crowdinIssue);
388477
const gh = await createGithubIssue(
389478
buildIssueTitle(crowdinIssue),
390479
buildIssueBody(crowdinIssue, projectId),
480+
buildIssueLabels(crowdinIssue),
391481
);
392482
existingMap.set(key, gh);
393483
console.log(` ✚ Created GH #${gh.number} ← Crowdin #${crowdinIssue.id}`);
@@ -463,8 +553,11 @@ if (_isMain) {
463553
export {
464554
buildIssueTitle,
465555
buildIssueBody,
556+
buildIssueLabels,
466557
fetchCrowdinIssues,
558+
ensureLabel,
467559
ensureCrowdinLabel,
560+
ensureIssueLabels,
468561
loadExistingGithubIssues,
469562
createGithubIssue,
470563
addGithubAssignees,
@@ -476,6 +569,10 @@ export {
476569
TYPE_MAP,
477570
MARKER_RE,
478571
CROWDIN_LABEL,
572+
LANG_LABEL_COLOR,
573+
TYPE_LABEL_COLOR,
479574
getLanguageManagers,
480575
getGithubAssignees,
576+
getTypeLabel,
577+
getLanguageLabels,
481578
};

0 commit comments

Comments
 (0)