Skip to content

Commit f055f87

Browse files
ci: add conventional PR title bot for automated feedback
Signed-off-by: Ashhar Ahmad Khan <145142826+AshharAhmadKhan@users.noreply.github.com>
1 parent a7a2182 commit f055f87

3 files changed

Lines changed: 1320 additions & 965 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
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(format|formatting|style|prettier|eslint|lint)\b/, label: 'formatting' },
30+
{ type: 'docs', pattern: /\b(docs|documentation|readme|comment|comments)\b/, label: 'documentation' },
31+
{ type: 'fix', pattern: /\b(fix|bug|issue|error|crash|problem)\b/, label: 'bug fix' },
32+
{ type: 'test', pattern: /\b(test|testing|spec|unit|integration)\b/, label: 'test' },
33+
{ type: 'refactor', pattern: /\b(refactor|refactoring|restructure|reorganize)\b/, label: 'refactor' },
34+
{ type: 'perf', pattern: /\b(perf|performance|optimize|speed)\b/, label: 'performance' },
35+
{ type: 'build', pattern: /\b(build|compile|dependency|dependencies|deps)\b/, label: 'build' },
36+
{ type: 'ci', pattern: /\b(ci|workflow|action|pipeline)\b/, label: 'CI' },
37+
{ type: 'revert', pattern: /\b(revert|reverts|reverting|rollback|undo)\b/, label: 'revert' },
38+
{ type: 'feat', pattern: /\b(add|adds|added|feature|features|new|implement|implements|implemented|introduce|introduced)\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

Comments
 (0)