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+ // Suggest appropriate conventional type
207+ const suggestedType = suggestConventionalType ( prTitle ) ;
208+
209+ // Format the bot message
210+ const message = formatMessage ( prTitle , suggestedType , prNumber ) ;
211+
212+ console . log ( '[Bot] Fetching PR comments with pagination...' ) ;
213+
214+ // Fetch comments with pagination and early exit
215+ let commentCount = 0 ;
216+ let page = 1 ;
217+ let botComment = null ;
218+
219+ while ( commentCount < MAX_COMMENTS_TO_FETCH && ! botComment ) {
220+ const response = await github . rest . issues . listComments ( {
221+ owner : context . repo . owner ,
222+ repo : context . repo . repo ,
223+ issue_number : prNumber ,
224+ per_page : 100 ,
225+ page : page
226+ } ) ;
227+
228+ commentCount += response . data . length ;
229+
230+ // Check if we found the bot comment
231+ botComment = response . data . find ( comment =>
232+ comment . body && comment . body . includes ( COMMENT_IDENTIFIER )
233+ ) ;
234+
235+ // Exit if no more pages or found the comment
236+ if ( response . data . length < 100 || botComment ) {
237+ break ;
238+ }
239+
240+ page ++ ;
241+ }
242+
243+ console . log ( `[Bot] Fetched ${ commentCount } comments across ${ page } page(s)` ) ;
244+
245+ if ( dryRun ) {
246+ console . log ( '=' . repeat ( 60 ) ) ;
247+ console . log ( '[Bot] 🔍 DRY RUN MODE - No changes will be made' ) ;
248+ console . log ( '[Bot] Would suggest type:' , suggestedType ) ;
249+ console . log ( '[Bot] Bot comment exists:' , ! ! botComment ) ;
250+ console . log ( '[Bot] Action that would be taken:' , botComment ? 'UPDATE' : 'CREATE' ) ;
251+ console . log ( '=' . repeat ( 60 ) ) ;
252+ return ;
253+ }
254+
255+ if ( botComment ) {
256+ console . log ( '[Bot] Found existing bot comment, updating...' ) ;
257+ console . log ( '[Bot] Comment ID:' , botComment . id ) ;
258+
259+ await github . rest . issues . updateComment ( {
260+ owner : context . repo . owner ,
261+ repo : context . repo . repo ,
262+ comment_id : botComment . id ,
263+ body : message ,
264+ } ) ;
265+
266+ console . log ( '[Bot] ✅ Successfully updated existing comment' ) ;
267+ } else {
268+ console . log ( '[Bot] No existing bot comment found, creating new one...' ) ;
269+
270+ const response = await github . rest . issues . createComment ( {
271+ owner : context . repo . owner ,
272+ repo : context . repo . repo ,
273+ issue_number : prNumber ,
274+ body : message ,
275+ } ) ;
276+
277+ console . log ( '[Bot] ✅ Successfully created new comment' ) ;
278+ console . log ( '[Bot] Comment ID:' , response . data . id ) ;
279+ }
280+
281+ console . log ( '=' . repeat ( 60 ) ) ;
282+ console . log ( '[Bot] Bot execution completed successfully' ) ;
283+ console . log ( '=' . repeat ( 60 ) ) ;
284+
285+ } catch ( error ) {
286+ console . error ( '=' . repeat ( 60 ) ) ;
287+ console . error ( '[Bot] ❌ Error occurred during bot execution' ) ;
288+
289+ // Handle permission errors gracefully
290+ if ( error . status === 403 ) {
291+ console . error ( '[Bot] Permission denied - bot lacks write access to this repository' ) ;
292+ console . error ( '[Bot] This is expected for fork PRs and will not fail the workflow' ) ;
293+ console . error ( '[Bot] The bot will function normally once the PR is on the main repository' ) ;
294+ console . error ( '=' . repeat ( 60 ) ) ;
295+ return ; // Exit gracefully, don't fail the workflow
296+ }
297+
298+ // Log other errors with details
299+ console . error ( '[Bot] Error name:' , error . name ) ;
300+ console . error ( '[Bot] Error message:' , error . message ) ;
301+ console . error ( '[Bot] Error status:' , error . status ) ;
302+ console . error ( '[Bot] Error stack:' , error . stack ) ;
303+ console . error ( '=' . repeat ( 60 ) ) ;
304+ throw error ;
305+ }
306+ }
307+
308+ // Export functions for testing and workflow usage
309+ module . exports = {
310+ run,
311+ suggestConventionalType,
312+ formatMessage
313+ } ;
0 commit comments