Skip to content

Commit adb9725

Browse files
ci: add conventional PR title bot for automated feedback (hiero-ledger#1767)
Signed-off-by: Ashhar Ahmad Khan <145142826+AshharAhmadKhan@users.noreply.github.com>
1 parent d2f17ed commit adb9725

File tree

3 files changed

+393
-41
lines changed

3 files changed

+393
-41
lines changed
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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+
// Skip if title already follows conventional commit format
207+
const conventionalRegex = /^(feat|fix|docs|style|refactor|test|build|ci|chore|revert)(\(.+\))?: .+/;
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

Comments
 (0)