@@ -10,93 +10,248 @@ const yaml = require('js-yaml');
1010const fs = require ( 'fs' ) ;
1111const path = require ( 'path' ) ;
1212
13- async function run ( ) {
13+ /**
14+ * Get labels that were added by this action by checking issue events
15+ */
16+ async function getLabelsAddedByAction ( octokit , owner , repo , issueNumber ) {
1417 try {
15- // Get environment variables
16- const githubToken = process . env . GITHUB_TOKEN ;
17- const configFilePath =
18- process . env . CONFIG_FILE || 'pr-title-labeler-config.yml' ;
19- const eventPath = process . env . GITHUB_EVENT_PATH ;
20- const repository = process . env . GITHUB_REPOSITORY ;
18+ const events = await octokit . rest . issues . listEvents ( {
19+ owner ,
20+ repo ,
21+ issue_number : issueNumber ,
22+ per_page : 100
23+ } ) ;
2124
22- if ( ! githubToken ) {
23- console . error ( '❌ GITHUB_TOKEN is required' ) ;
24- process . exit ( 1 ) ;
25+ const labelsAddedByAction = new Set ( ) ;
26+
27+ // Look for "labeled" events that were created by the GitHub Actions bot
28+ for ( const event of events . data ) {
29+ if (
30+ event . event === 'labeled' &&
31+ event . actor ?. login === 'github-actions[bot]' &&
32+ event . label ?. name
33+ ) {
34+ labelsAddedByAction . add ( event . label . name ) ;
35+ }
2536 }
2637
27- if ( ! eventPath ) {
28- console . error ( '❌ GITHUB_EVENT_PATH is required' ) ;
29- process . exit ( 1 ) ;
38+ return labelsAddedByAction ;
39+ } catch ( error ) {
40+ console . log ( '⚠️ Could not fetch issue events:' , error . message ) ;
41+ return new Set ( ) ;
42+ }
43+ }
44+
45+ /**
46+ * Get current labels on the PR
47+ */
48+ async function getCurrentLabels ( octokit , owner , repo , issueNumber ) {
49+ try {
50+ const response = await octokit . rest . issues . listLabelsOnIssue ( {
51+ owner,
52+ repo,
53+ issue_number : issueNumber
54+ } ) ;
55+ return new Set ( response . data . map ( label => label . name ) ) ;
56+ } catch ( error ) {
57+ console . log ( '⚠️ Could not fetch current labels:' , error . message ) ;
58+ return new Set ( ) ;
59+ }
60+ }
61+
62+ /**
63+ * Find matching label based on PR title prefix
64+ */
65+ function findMatchingLabel ( title , labelMappings ) {
66+ const normalizedTitle = title . toLowerCase ( ) ;
67+
68+ for ( const mapping of labelMappings ) {
69+ for ( const prefix of mapping . prefixes ) {
70+ const prefixWithColon = `${ prefix } :` ;
71+ if ( normalizedTitle . startsWith ( prefixWithColon ) ) {
72+ return mapping . label ;
73+ }
3074 }
75+ }
3176
32- // Read GitHub event data
33- const eventData = JSON . parse ( fs . readFileSync ( eventPath , 'utf8' ) ) ;
34- const pr = eventData . pull_request ;
77+ return null ;
78+ }
79+
80+ /**
81+ * Determine which labels to remove
82+ */
83+ function getLabelsToRemove ( labelsAddedByAction , currentLabels , targetLabel ) {
84+ const labelsToRemove = new Set ( ) ;
3585
36- if ( ! pr ) {
37- console . error ( '❌ No pull request found in event data' ) ;
38- process . exit ( 1 ) ;
86+ for ( const addedLabel of labelsAddedByAction ) {
87+ if ( currentLabels . has ( addedLabel ) && addedLabel !== targetLabel ) {
88+ labelsToRemove . add ( addedLabel ) ;
3989 }
90+ }
4091
41- // Get config file path from environment variable
42- const configPath = path . resolve ( __dirname , configFilePath ) ;
92+ return labelsToRemove ;
93+ }
4394
44- let labelMappings ;
95+ /**
96+ * Remove labels from the PR
97+ */
98+ async function removeLabels ( octokit , owner , repo , issueNumber , labelsToRemove ) {
99+ for ( const labelToRemove of labelsToRemove ) {
45100 try {
46- const configContent = fs . readFileSync ( configPath , 'utf8' ) ;
47- labelMappings = yaml . load ( configContent ) ;
101+ await octokit . rest . issues . removeLabel ( {
102+ owner,
103+ repo,
104+ issue_number : issueNumber ,
105+ name : labelToRemove
106+ } ) ;
107+ console . log ( `🗑️ Removed old label: ${ labelToRemove } ` ) ;
48108 } catch ( error ) {
49- console . error (
50- `❌ Error reading config file "${ configFilePath } ": ${ error . message } `
51- ) ;
52- process . exit ( 1 ) ;
53- }
54-
55- const title = pr . title . toLowerCase ( ) ;
56-
57- // Find matching prefix and label
58- let label = null ;
59- for ( const mapping of labelMappings ) {
60- for ( const prefix of mapping . prefixes ) {
61- // Try both with and without colon for flexibility
62- const prefixWithColon = `${ prefix } :` ;
63- if ( title . startsWith ( prefixWithColon ) ) {
64- label = mapping . label ;
65- console . log (
66- `Found prefix "${ prefixWithColon } " -> applying label "${ label } "`
67- ) ;
68- break ;
69- }
70- }
71- if ( label ) break ;
109+ console . log ( `⚠️ Could not remove label ${ labelToRemove } :` , error . message ) ;
72110 }
111+ }
112+ }
73113
74- // Create GitHub client
75- const octokit = new Octokit ( {
76- auth : githubToken
114+ /**
115+ * Add label to the PR
116+ */
117+ async function addLabel ( octokit , owner , repo , issueNumber , label ) {
118+ try {
119+ await octokit . rest . issues . addLabels ( {
120+ owner,
121+ repo,
122+ issue_number : issueNumber ,
123+ labels : [ label ]
77124 } ) ;
125+ console . log ( `✅ Successfully applied label: ${ label } ` ) ;
126+ } catch ( error ) {
127+ console . log ( `⚠️ Could not add label ${ label } :` , error . message ) ;
128+ }
129+ }
130+
131+ /**
132+ * Log information when no matching prefix is found
133+ */
134+ function logNoMatchingPrefix ( title , labelMappings ) {
135+ console . log ( '❓ No matching prefix found in PR title' ) ;
136+ console . log ( `📝 PR title: "${ title } "` ) ;
137+ const allPrefixes = labelMappings . flatMap ( mapping => mapping . prefixes ) ;
138+ console . log ( '📋 Available prefixes:' , allPrefixes . join ( ', ' ) ) ;
139+ }
140+
141+ /**
142+ * Load configuration from YAML file
143+ */
144+ function loadConfiguration ( configFilePath ) {
145+ const configPath = path . resolve ( __dirname , configFilePath ) ;
146+
147+ try {
148+ const configContent = fs . readFileSync ( configPath , 'utf8' ) ;
149+ return yaml . load ( configContent ) ;
150+ } catch ( error ) {
151+ console . error (
152+ `❌ Error reading config file "${ configFilePath } ": ${ error . message } `
153+ ) ;
154+ process . exit ( 1 ) ;
155+ }
156+ }
157+
158+ /**
159+ * Validate required environment variables
160+ */
161+ function validateEnvironment ( ) {
162+ const githubToken = process . env . GITHUB_TOKEN ;
163+ const eventPath = process . env . GITHUB_EVENT_PATH ;
164+
165+ if ( ! githubToken ) {
166+ console . error ( '❌ GITHUB_TOKEN is required' ) ;
167+ process . exit ( 1 ) ;
168+ }
169+
170+ if ( ! eventPath ) {
171+ console . error ( '❌ GITHUB_EVENT_PATH is required' ) ;
172+ process . exit ( 1 ) ;
173+ }
174+
175+ return { githubToken, eventPath } ;
176+ }
177+
178+ /**
179+ * Parse PR data from GitHub event
180+ */
181+ function parsePullRequestData ( eventPath ) {
182+ const eventData = JSON . parse ( fs . readFileSync ( eventPath , 'utf8' ) ) ;
183+ const pr = eventData . pull_request ;
184+
185+ if ( ! pr ) {
186+ console . error ( '❌ No pull request found in event data' ) ;
187+ process . exit ( 1 ) ;
188+ }
189+
190+ return pr ;
191+ }
78192
79- // Parse repository owner and name
193+ /**
194+ * Main execution function
195+ */
196+ async function run ( ) {
197+ try {
198+ // Validate environment and load configuration
199+ const { githubToken, eventPath } = validateEnvironment ( ) ;
200+ const configFilePath =
201+ process . env . CONFIG_FILE || 'pr-title-labeler-config.yml' ;
202+ const repository = process . env . GITHUB_REPOSITORY ;
203+
204+ const labelMappings = loadConfiguration ( configFilePath ) ;
205+ const pr = parsePullRequestData ( eventPath ) ;
206+
207+ // Create GitHub client and parse repository info
208+ const octokit = new Octokit ( { auth : githubToken } ) ;
80209 const [ owner , repo ] = repository . split ( '/' ) ;
81210
82- // Apply label if found
83- if ( label ) {
84- await octokit . rest . issues . addLabels ( {
85- owner,
86- repo,
87- issue_number : pr . number ,
88- labels : [ label ]
89- } ) ;
211+ // Find matching label based on title
212+ const targetLabel = findMatchingLabel ( pr . title , labelMappings ) ;
90213
91- console . log ( `✅ Successfully applied label: ${ label } ` ) ;
92- } else {
93- console . log ( 'ℹ️ No matching prefix found in PR title' ) ;
94- console . log ( `PR title: "${ title } "` ) ;
95- const allPrefixes = labelMappings . flatMap ( mapping => mapping . prefixes ) ;
96- console . log ( 'Available prefixes:' , allPrefixes . join ( ', ' ) ) ;
214+ if ( targetLabel ) {
215+ console . log (
216+ `🎯 Found matching prefix -> applying label "${ targetLabel } "`
217+ ) ;
218+ }
219+
220+ // Get current state
221+ const [ currentLabels , labelsAddedByAction ] = await Promise . all ( [
222+ getCurrentLabels ( octokit , owner , repo , pr . number ) ,
223+ getLabelsAddedByAction ( octokit , owner , repo , pr . number )
224+ ] ) ;
225+
226+ // Check if target label already exists
227+ if ( targetLabel && currentLabels . has ( targetLabel ) ) {
228+ console . log (
229+ `⏭️ Skipping label "${ targetLabel } " because it's already added to the PR`
230+ ) ;
97231 }
232+
233+ // Determine which labels to remove
234+ const labelsToRemove = getLabelsToRemove (
235+ labelsAddedByAction ,
236+ currentLabels ,
237+ targetLabel
238+ ) ;
239+
240+ // Remove old labels if any
241+ if ( labelsToRemove . size > 0 ) {
242+ await removeLabels ( octokit , owner , repo , pr . number , labelsToRemove ) ;
243+ }
244+
245+ // Add new label if needed
246+ if ( targetLabel && ! currentLabels . has ( targetLabel ) ) {
247+ await addLabel ( octokit , owner , repo , pr . number , targetLabel ) ;
248+ } else if ( ! targetLabel ) {
249+ logNoMatchingPrefix ( pr . title , labelMappings ) ;
250+ }
251+
252+ console . log ( '🎉 PR title labeler completed successfully!' ) ;
98253 } catch ( error ) {
99- console . error ( `❌ Error labeling PR: ${ error . message } ` ) ;
254+ console . error ( `💥 Error labeling PR: ${ error . message } ` ) ;
100255 process . exit ( 1 ) ;
101256 }
102257}
0 commit comments