@@ -74,6 +74,12 @@ const [GH_OWNER, GH_REPO] = (GITHUB_REPOSITORY ?? '/').split('/');
7474/** Label applied to every GitHub issue managed by this script. */
7575const 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. */
7884const MARKER_RE = / < ! - - c r o w d i n - i s s u e - i d : ( \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 */
254333function 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 */
386474async 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) {
463553export {
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