1+ /**
2+ * Bot that provides automated guidance for PR titles.
3+ *
4+ * Provides automated suggestions for fixing non-conventional PR titles.
5+ *
6+ * @module bot-conventional-pr-title
7+ */
8+
9+ const MAX_COMMENTS_TO_FETCH = 500 ;
10+ const COMMENT_IDENTIFIER = '<!-- bot-conventional-pr-title -->' ;
11+
12+ /**
13+ * Suggest appropriate conventional commit type based on PR title keywords
14+ * @param {string } title - The PR title to analyze
15+ * @returns {string } The suggested conventional commit type
16+ */
17+ function suggestConventionalType ( title ) {
18+ console . log ( '[Bot] Analyzing title for type suggestion:' , title ) ;
19+
20+ if ( ! title || typeof title !== 'string' ) {
21+ console . log ( '[Bot] ⚠️ Invalid title, defaulting to "chore"' ) ;
22+ return 'chore' ;
23+ }
24+
25+ const lowerTitle = title . toLowerCase ( ) ;
26+
27+ // Keyword patterns mapped to conventional types (priority order)
28+ const typePatterns = [
29+ { type : 'style' , pattern : / \b ( f o r m a t | f o r m a t t i n g | s t y l e | p r e t t i e r | e s l i n t | l i n t ) \b / , label : 'formatting' } ,
30+ { type : 'docs' , pattern : / \b ( d o c s | d o c u m e n t a t i o n | r e a d m e | c o m m e n t | c o m m e n t s ) \b / , label : 'documentation' } ,
31+ { type : 'fix' , pattern : / \b ( f i x | b u g | i s s u e | e r r o r | c r a s h | p r o b l e m ) \b / , label : 'bug fix' } ,
32+ { type : 'test' , pattern : / \b ( t e s t | t e s t i n g | s p e c | u n i t | i n t e g r a t i o n ) \b / , label : 'test' } ,
33+ { type : 'refactor' , pattern : / \b ( r e f a c t o r | r e f a c t o r i n g | r e s t r u c t u r e | r e o r g a n i z e ) \b / , label : 'refactor' } ,
34+ { type : 'perf' , pattern : / \b ( p e r f | p e r f o r m a n c e | o p t i m i z e | s p e e d ) \b / , label : 'performance' } ,
35+ { type : 'build' , pattern : / \b ( b u i l d | c o m p i l e | d e p e n d e n c y | d e p e n d e n c i e s | d e p s ) \b / , label : 'build' } ,
36+ { type : 'ci' , pattern : / \b ( c i | w o r k f l o w | a c t i o n | p i p e l i n e ) \b / , label : 'CI' } ,
37+ { type : 'revert' , pattern : / \b ( r e v e r t | r e v e r t s | r e v e r t i n g | r o l l b a c k | u n d o ) \b / , label : 'revert' } ,
38+ { type : 'feat' , pattern : / \b ( a d d | a d d s | a d d e d | f e a t u r e | f e a t u r e s | n e w | i m p l e m e n t | i m p l e m e n t s | i m p l e m e n t e d | i n t r o d u c e | i n t r o d u c e d ) \b / , label : 'feature' }
39+ ] ;
40+
41+ // Check each pattern in priority order
42+ for ( const { type, pattern, label } of typePatterns ) {
43+ if ( lowerTitle . match ( pattern ) ) {
44+ console . log ( `[Bot] Detected ${ label } keywords → suggesting "${ type } "` ) ;
45+ return type ;
46+ }
47+ }
48+
49+ console . log ( '[Bot] No specific keywords matched → suggesting "chore"' ) ;
50+ return 'chore' ;
51+ }
52+
53+ /**
54+ * Generate the header section of the bot message
55+ * @param {string } safeTitle - Current PR title (markdown-escaped)
56+ * @param {string } suggestedType - Suggested conventional type
57+ * @returns {string } Formatted header
58+ */
59+ function generateMessageHeader ( safeTitle , suggestedType ) {
60+ return `${ COMMENT_IDENTIFIER }
61+ ## PR Title Needs Conventional Format
62+
63+ **Your current title is:**
64+ \`\`\`
65+ ${ safeTitle }
66+ \`\`\`
67+
68+ **It needs to have a type prefix like:**
69+ \`\`\`
70+ ${ suggestedType } : ${ safeTitle }
71+ \`\`\`
72+
73+ ---
74+ ` ;
75+ }
76+
77+ /**
78+ * Generate the fix instructions section
79+ * @param {string } suggestedType - Suggested conventional type
80+ * @param {string } escapedTitle - Shell-escaped title
81+ * @param {number } prNumber - PR number
82+ * @returns {string } Formatted instructions
83+ */
84+ function generateFixInstructions ( suggestedType , escapedTitle , prNumber ) {
85+ return `### How to Fix This
86+
87+ #### Option 1: Via GitHub UI
88+ 1. Go to the top of this PR page
89+ 2. Click the **edit button** (✏️) next to the PR title at the top of the page
90+ 3. Add the type prefix (e.g., \`${ suggestedType } :\`) before your current title
91+ 4. Save the changes
92+
93+ #### Option 2: Via Command Line
94+ \`\`\`bash
95+ # Note: Adjust the title as needed if it contains special characters
96+ gh pr edit ${ prNumber } --title "${ suggestedType } : ${ escapedTitle } "
97+ \`\`\`
98+
99+ ---
100+ ` ;
101+ }
102+
103+ /**
104+ * Generate the valid types reference section
105+ * @returns {string } Formatted types list
106+ */
107+ function generateValidTypesList ( ) {
108+ return `### Valid Conventional Commit Types
109+ - \`feat\` - New feature
110+ - \`fix\` - Bug fix
111+ - \`docs\` - Documentation changes
112+ - \`style\` - Code style changes (formatting, missing semi-colons, etc)
113+ - \`refactor\` - Code refactoring
114+ - \`perf\` - Performance improvements
115+ - \`test\` - Adding or updating tests
116+ - \`build\` - Build system changes
117+ - \`ci\` - CI configuration changes
118+ - \`chore\` - Other changes that don't modify src or test files
119+ - \`revert\` - Reverts a previous commit
120+
121+ 📖 Learn more: [Conventional Commits](https://www.conventionalcommits.org/)
122+ ` ;
123+ }
124+
125+ /**
126+ * Escape user-provided text for safe markdown display
127+ * Prevents markdown injection without altering meaning
128+ * @param {string } text
129+ * @returns {string }
130+ */
131+ function escapeForMarkdown ( text ) {
132+ return text
133+ . replace ( / ` ` ` / g, "'''" )
134+ . replace ( / \r ? \n | \r / g, ' ' )
135+ . trim ( ) ;
136+ }
137+
138+ /**
139+ * Compose the complete bot message
140+ * @param {Object } params - Message parameters
141+ * @param {string } params.safeTitle - Markdown-escaped title
142+ * @param {string } params.escapedTitle - Shell-escaped title
143+ * @param {string } params.suggestedType - Suggested conventional type
144+ * @param {number } params.prNumber - PR number
145+ * @returns {string } Complete formatted message
146+ */
147+ function composeBotMessage ( { safeTitle, escapedTitle, suggestedType, prNumber } ) {
148+ return (
149+ generateMessageHeader ( safeTitle , suggestedType ) +
150+ generateFixInstructions ( suggestedType , escapedTitle , prNumber ) +
151+ generateValidTypesList ( )
152+ ) ;
153+ }
154+
155+ /**
156+ * Format the bot comment message with title guidance
157+ * @param {string } currentTitle - The current PR title
158+ * @param {string } suggestedType - The suggested conventional type
159+ * @param {number } prNumber - The PR number
160+ * @returns {string } Formatted markdown message
161+ */
162+ function formatMessage ( currentTitle , suggestedType , prNumber ) {
163+ // Escape shell-sensitive characters for the CLI example
164+ const escapedTitle = currentTitle . replace ( / [ " $ ` \\ ] / g, '\\$&' ) ;
165+ const safeTitle = escapeForMarkdown ( currentTitle ) ;
166+
167+ return composeBotMessage ( {
168+ safeTitle,
169+ escapedTitle,
170+ suggestedType,
171+ prNumber,
172+ } ) ;
173+ }
174+
175+ /**
176+ * Main bot execution function
177+ * @param {Object } params - Function parameters
178+ * @param {Object } params.github - GitHub API client
179+ * @param {Object } params.context - GitHub Actions context
180+ * @param {number } params.prNumber - Pull request number
181+ * @param {string } params.prTitle - Pull request title
182+ * @param {boolean } [params.dryRun=false] - Dry run mode flag
183+ * @returns {Promise<void> }
184+ */
185+ async function run ( { github, context, prNumber, prTitle, dryRun = false } ) {
186+ try {
187+ console . log ( '=' . repeat ( 60 ) ) ;
188+ console . log ( '[Bot] Starting conventional PR title bot' ) ;
189+ console . log ( '[Bot] Dry Run Mode:' , dryRun ) ;
190+ console . log ( '[Bot] PR Number:' , prNumber ) ;
191+ console . log ( '[Bot] PR Title:' , prTitle ) ;
192+ console . log ( '[Bot] Repository:' , `${ context . repo . owner } /${ context . repo . repo } ` ) ;
193+ console . log ( '=' . repeat ( 60 ) ) ;
194+
195+ // Validate inputs
196+ if ( ! prNumber || typeof prNumber !== 'number' ) {
197+ console . error ( '[Bot] ❌ Invalid PR number:' , prNumber ) ;
198+ throw new Error ( 'Invalid PR number provided' ) ;
199+ }
200+
201+ if ( ! prTitle || typeof prTitle !== 'string' ) {
202+ console . error ( '[Bot] ❌ Invalid PR title:' , prTitle ) ;
203+ throw new Error ( 'Invalid PR title provided' ) ;
204+ }
205+
206+ // Skip if title already follows conventional commit format
207+ const conventionalRegex = / ^ ( f e a t | f i x | d o c s | s t y l e | r e f a c t o r | t e s t | b u i l d | c i | c h o r e | r e v e r t ) ( \( .+ \) ) ? : .+ / ;
208+ if ( conventionalRegex . test ( prTitle ) ) {
209+ console . log ( "[Bot] ✅ Title already follows conventional commit format, skipping comment" ) ;
210+ return ;
211+ }
212+
213+ // Suggest appropriate conventional type
214+ const suggestedType = suggestConventionalType ( prTitle ) ;
215+
216+ // Format the bot message
217+ const message = formatMessage ( prTitle , suggestedType , prNumber ) ;
218+
219+ console . log ( '[Bot] Fetching PR comments with pagination...' ) ;
220+
221+ // Fetch comments with pagination and early exit
222+ let commentCount = 0 ;
223+ let page = 1 ;
224+ let botComment = null ;
225+
226+ while ( commentCount < MAX_COMMENTS_TO_FETCH && ! botComment ) {
227+ const response = await github . rest . issues . listComments ( {
228+ owner : context . repo . owner ,
229+ repo : context . repo . repo ,
230+ issue_number : prNumber ,
231+ per_page : 100 ,
232+ page : page
233+ } ) ;
234+
235+ commentCount += response . data . length ;
236+
237+ // Check if we found the bot comment
238+ botComment = response . data . find ( comment =>
239+ comment . body && comment . body . includes ( COMMENT_IDENTIFIER )
240+ ) ;
241+
242+ // Exit if no more pages or found the comment
243+ if ( response . data . length < 100 || botComment ) {
244+ break ;
245+ }
246+
247+ page ++ ;
248+ }
249+
250+ console . log ( `[Bot] Fetched ${ commentCount } comments across ${ page } page(s)` ) ;
251+
252+ if ( dryRun ) {
253+ console . log ( '=' . repeat ( 60 ) ) ;
254+ console . log ( '[Bot] 🔍 DRY RUN MODE - No changes will be made' ) ;
255+ console . log ( '[Bot] Would suggest type:' , suggestedType ) ;
256+ console . log ( '[Bot] Bot comment exists:' , ! ! botComment ) ;
257+ console . log ( '[Bot] Action that would be taken:' , botComment ? 'UPDATE' : 'CREATE' ) ;
258+ console . log ( '=' . repeat ( 60 ) ) ;
259+ return ;
260+ }
261+
262+ if ( botComment ) {
263+ console . log ( '[Bot] Found existing bot comment, updating...' ) ;
264+ console . log ( '[Bot] Comment ID:' , botComment . id ) ;
265+
266+ await github . rest . issues . updateComment ( {
267+ owner : context . repo . owner ,
268+ repo : context . repo . repo ,
269+ comment_id : botComment . id ,
270+ body : message ,
271+ } ) ;
272+
273+ console . log ( '[Bot] ✅ Successfully updated existing comment' ) ;
274+ } else {
275+ console . log ( '[Bot] No existing bot comment found, creating new one...' ) ;
276+
277+ const response = await github . rest . issues . createComment ( {
278+ owner : context . repo . owner ,
279+ repo : context . repo . repo ,
280+ issue_number : prNumber ,
281+ body : message ,
282+ } ) ;
283+
284+ console . log ( '[Bot] ✅ Successfully created new comment' ) ;
285+ console . log ( '[Bot] Comment ID:' , response . data . id ) ;
286+ }
287+
288+ console . log ( '=' . repeat ( 60 ) ) ;
289+ console . log ( '[Bot] Bot execution completed successfully' ) ;
290+ console . log ( '=' . repeat ( 60 ) ) ;
291+
292+ } catch ( error ) {
293+ console . error ( '=' . repeat ( 60 ) ) ;
294+ console . error ( '[Bot] ❌ Error occurred during bot execution' ) ;
295+
296+ // Handle permission errors gracefully
297+ if ( error . status === 403 ) {
298+ console . error ( '[Bot] Permission denied - bot lacks write access to this repository' ) ;
299+ console . error ( '[Bot] This is expected for fork PRs and will not fail the workflow' ) ;
300+ console . error ( '[Bot] The bot will function normally once the PR is on the main repository' ) ;
301+ console . error ( '=' . repeat ( 60 ) ) ;
302+ return ; // Exit gracefully, don't fail the workflow
303+ }
304+
305+ // Log other errors with details
306+ console . error ( '[Bot] Error name:' , error . name ) ;
307+ console . error ( '[Bot] Error message:' , error . message ) ;
308+ console . error ( '[Bot] Error status:' , error . status ) ;
309+ console . error ( '[Bot] Error stack:' , error . stack ) ;
310+ console . error ( '=' . repeat ( 60 ) ) ;
311+ throw error ;
312+ }
313+ }
314+
315+ // Export functions for testing and workflow usage
316+ module . exports = {
317+ run,
318+ suggestConventionalType,
319+ formatMessage
320+ } ;
0 commit comments