|
1 | | -const { |
2 | | - GOOD_FIRST_ISSUE_LABEL, |
3 | | - BEGINNER_LABEL, |
4 | | - INTERMEDIATE_LABEL, |
5 | | - ADVANCED_LABEL, |
6 | | - isSafeLabel, |
7 | | -} = require('./shared/labels.js'); |
8 | | - |
9 | | -for (const label of [GOOD_FIRST_ISSUE_LABEL, BEGINNER_LABEL, INTERMEDIATE_LABEL, ADVANCED_LABEL]) { |
10 | | - if (!isSafeLabel(label)) { |
11 | | - throw new Error(`Invalid configured label: ${label}`); |
12 | | - } |
13 | | -} |
| 1 | +// .github/scripts/bot-next-issue-recommendation.js |
| 2 | +// |
| 3 | +// Recommends issues to contributors after a PR is merged. |
| 4 | +// |
| 5 | +// Recommendation priority (per issue description): |
| 6 | +// 1. One level above what they just completed, home repo |
| 7 | +// 2. One level above, other repos |
| 8 | +// 3. Same level, home repo |
| 9 | +// 4. Same level, other repos |
| 10 | +// 5. One level below (never below beginner, never GFI), home repo then other repos |
| 11 | +// 6. Silent — nothing posted |
| 12 | +// |
| 13 | +// Eligibility history caps the ceiling: a contributor who just completed GFI |
| 14 | +// cannot be recommended intermediate even if intermediate issues are available. |
| 15 | +// The target level is driven by what they completed, bounded by eligibility. |
| 16 | +// |
| 17 | +// Each repo declares its own label strings since naming varies across repos. |
14 | 18 |
|
15 | | -const SUPPORTED_GFI_REPOS = [ |
16 | | - 'hiero-sdk-cpp', |
17 | | - 'hiero-sdk-swift', |
18 | | - 'hiero-sdk-python', |
19 | | - 'hiero-website', |
20 | | - 'hiero-sdk-js', |
21 | | -]; |
| 19 | +const { |
| 20 | + CONFIG, |
| 21 | + getHighestSkillLevelKey, |
| 22 | + getRecommendedIssues, |
| 23 | + buildRecommendationComment, |
| 24 | + postComment, |
| 25 | + alreadyCommented, |
| 26 | + extractLinkedIssueNumber, |
| 27 | +} = require('./shared'); |
| 28 | + |
| 29 | +// --------------------------------------------------------------------------- |
| 30 | +// Main entry point |
| 31 | +// --------------------------------------------------------------------------- |
22 | 32 |
|
23 | 33 | module.exports = async ({ github, context, core }) => { |
24 | 34 | const { payload } = context; |
| 35 | + const prNumber = payload.pull_request?.number; |
| 36 | + const prBody = payload.pull_request?.body ?? ''; |
| 37 | + const username = payload.pull_request?.user?.login; |
| 38 | + const homeRepo = CONFIG.repos.find(r => r.isHome); |
25 | 39 |
|
26 | | - // Get PR information from automatic pull_request_target trigger |
27 | | - let prNumber = payload.pull_request?.number; |
28 | | - let prBody = payload.pull_request?.body || ''; |
29 | | - |
30 | | - // Manual workflow_dispatch is no longer supported - inputs were removed |
31 | | - // Only automatic triggers from merged PRs will work |
32 | | - const repoOwner = context.repo.owner; |
33 | | - const repoName = context.repo.repo; |
34 | | - |
35 | | - if (!prNumber) { |
36 | | - core.info('No PR number found, skipping'); |
| 40 | + if (!homeRepo) { |
| 41 | + core.setFailed('No home repo configured (missing isHome: true)'); |
37 | 42 | return; |
38 | 43 | } |
39 | 44 |
|
40 | | - core.info(`Processing PR #${prNumber}`); |
41 | | - |
42 | | - // Parse PR body to find linked issues |
43 | | - const MAX_PR_BODY_LENGTH = 50000; // Reasonable limit for PR body |
44 | | - if (prBody.length > MAX_PR_BODY_LENGTH) { |
45 | | - core.warning(`PR body exceeds ${MAX_PR_BODY_LENGTH} characters, truncating for parsing`); |
46 | | - prBody = prBody.substring(0, MAX_PR_BODY_LENGTH); |
47 | | - } |
48 | | - const issueRegex = /(fixes|closes|resolves|fix|close|resolve)\s+(?:[\w-]+\/[\w-]+)?#(\d+)/gi; |
49 | | - const matches = [...prBody.matchAll(issueRegex)]; |
50 | | - |
51 | | - if (matches.length === 0) { |
52 | | - core.info('No linked issues found in PR body'); |
| 45 | + if (!prNumber || !username) { |
| 46 | + core.info('Missing PR number or username, skipping'); |
53 | 47 | return; |
54 | 48 | } |
55 | 49 |
|
56 | | - // Get the first linked issue number |
57 | | - const issueNumber = parseInt(matches[0][2]); |
58 | | - core.info(`Found linked issue #${issueNumber}`); |
| 50 | + core.info(`Processing PR #${prNumber} by @${username}`); |
59 | 51 |
|
| 52 | + const { owner, repo } = homeRepo; |
60 | 53 | try { |
61 | | - // Fetch issue details |
62 | | - const { data: issue } = await github.rest.issues.get({ |
63 | | - owner: repoOwner, |
64 | | - repo: repoName, |
65 | | - issue_number: issueNumber, |
66 | | - }); |
67 | | - |
68 | | - // Normalize and check issue labels (case-insensitive) |
69 | | - const labelNames = issue.labels.map(label => label.name.toLowerCase()); |
70 | | - const labelSet = new Set(labelNames); |
71 | | - core.info(`Issue labels: ${labelNames.join(', ')}`); |
72 | | - |
73 | | - // Determine issue difficulty level |
74 | | - const difficultyLevels = { |
75 | | - beginner: labelSet.has(BEGINNER_LABEL.toLowerCase()), |
76 | | - goodFirstIssue: labelSet.has(GOOD_FIRST_ISSUE_LABEL.toLowerCase()), |
77 | | - intermediate: labelSet.has(INTERMEDIATE_LABEL.toLowerCase()), |
78 | | - advanced: labelSet.has(ADVANCED_LABEL.toLowerCase()), |
79 | | - }; |
80 | | - |
81 | | - // Skip if intermediate or advanced |
82 | | - if (difficultyLevels.intermediate || difficultyLevels.advanced) { |
83 | | - core.info('Issue is intermediate or advanced level, skipping recommendation'); |
84 | | - return; |
85 | | - } |
86 | | - |
87 | | - // Only proceed for Good First Issue or beginner issues |
88 | | - if (!difficultyLevels.goodFirstIssue && !difficultyLevels.beginner) { |
89 | | - core.info('Issue is not a Good First Issue or beginner issue, skipping'); |
| 54 | + if (await alreadyCommented(github, owner, repo, prNumber)) { |
| 55 | + core.info('Recommendation already posted, skipping'); |
90 | 56 | return; |
91 | 57 | } |
92 | | - |
93 | | - let recommendedIssues = []; |
94 | | - let recommendedLabel = null; |
95 | | - let isFallback = false; |
96 | | - |
97 | | - recommendedIssues = await searchIssues(github, core, repoOwner, repoName, BEGINNER_LABEL); |
98 | | - recommendedLabel = BEGINNER_LABEL; |
99 | | - |
100 | | - if (recommendedIssues.length === 0) { |
101 | | - isFallback = true; |
102 | | - recommendedIssues = await searchIssues(github, core, repoOwner, repoName, GOOD_FIRST_ISSUE_LABEL); |
103 | | - recommendedLabel = GOOD_FIRST_ISSUE_LABEL; |
104 | | - } |
105 | | - |
106 | | - |
107 | | - // Remove the issue they just solved |
108 | | - recommendedIssues = recommendedIssues.filter(i => i.number !== issueNumber); |
109 | | - |
110 | | - // Generate and post comment |
111 | | - const completedLabel = difficultyLevels.goodFirstIssue ? GOOD_FIRST_ISSUE_LABEL : BEGINNER_LABEL; |
112 | | - const completedLabelText = completedLabel === BEGINNER_LABEL ? `${BEGINNER_LABEL} issue` : completedLabel; |
113 | | - const recommendationMeta = { |
114 | | - completedLabelText, |
115 | | - recommendedLabel, |
116 | | - isFallback, |
117 | | - }; |
118 | | - await generateAndPostComment(github, context, core, prNumber, recommendedIssues, recommendationMeta); |
119 | | - |
120 | 58 | } catch (error) { |
121 | | - core.setFailed(`Error processing issue #${issueNumber}: ${error.message}`); |
| 59 | + core.warning( |
| 60 | + `Could not verify existing recommendation comment on PR #${prNumber}: ${error.message}; skipping to avoid duplicate post`, |
| 61 | + ); |
| 62 | + return; |
122 | 63 | } |
123 | | -}; |
124 | | - |
125 | | -async function searchIssues(github, core, owner, repo, label) { |
126 | | - try { |
127 | | - const query = `repo:${owner}/${repo} type:issue state:open label:"${label}" no:assignee`; |
128 | | - core.info(`Searching for issues with query: ${query}`); |
129 | 64 |
|
130 | | - const { data: searchResult } = await github.rest.search.issuesAndPullRequests({ |
131 | | - q: query, |
132 | | - per_page: 6, |
133 | | - }); |
134 | | - |
135 | | - core.info(`Found ${searchResult.items.length} issues with label "${label}"`); |
136 | | - return searchResult.items; |
137 | | - } catch (error) { |
138 | | - core.warning(`Error searching for issues with label "${label}": ${error.message}`); |
139 | | - return []; |
| 65 | + const linkedIssueNumber = extractLinkedIssueNumber(prBody); |
| 66 | + if (!linkedIssueNumber) { |
| 67 | + core.info('No linked issue found in PR body, skipping'); |
| 68 | + return; |
140 | 69 | } |
141 | | -} |
142 | 70 |
|
143 | | -async function generateAndPostComment(github, context, core, prNumber, recommendedIssues, { completedLabelText, recommendedLabel, isFallback }) { |
144 | | - const marker = '<!-- next-issue-bot-marker -->'; |
| 71 | + core.info(`Linked issue: #${linkedIssueNumber}`); |
145 | 72 |
|
146 | | - // Build comment content |
147 | | - let comment = `${marker}\n\n🎉 **Nice work completing a ${completedLabelText}!**\n\n`; |
148 | | - comment += `Thank you for your contribution to the Hiero Python SDK! We're excited to have you as part of our community.\n\n`; |
149 | | - |
150 | | - if (recommendedIssues.length > 0) { |
151 | | - |
152 | | - if (isFallback) { |
153 | | - comment += `Here are some **${recommendedLabel}** issues at a similar level you might be interested in working on next:\n\n`; |
154 | | - } else { |
155 | | - comment += `Here are some issues labeled **${recommendedLabel}** you might be interested in working on next:\n\n`; |
156 | | - } |
157 | | - |
158 | | - // Sanitize title: escape markdown link syntax and special characters |
159 | | - const sanitizeTitle = (title) => title |
160 | | - .replace(/\[/g, '\\[') |
161 | | - .replace(/\]/g, '\\]') |
162 | | - .replace(/\(/g, '\\(') |
163 | | - .replace(/\)/g, '\\)'); |
164 | | - |
165 | | - recommendedIssues.slice(0, 5).forEach((issue, index) => { |
166 | | - comment += `${index + 1}. [${sanitizeTitle(issue.title)}](${issue.html_url})\n`; |
167 | | - if (issue.body && issue.body.length > 0) { |
168 | | - const description = extractIssueDescription(issue.body); |
169 | | - comment += ` ${description}\n\n`; |
170 | | - } else { |
171 | | - comment += ` *No description available*\n\n`; |
172 | | - } |
| 73 | + let issue; |
| 74 | + try { |
| 75 | + const { data } = await github.rest.issues.get({ |
| 76 | + owner, repo, issue_number: linkedIssueNumber, |
173 | 77 | }); |
174 | | - } else { |
175 | | - comment += `There are currently no open issues available at or near the ${completedLabelText} level in this repository.\n\n`; |
176 | | - comment += `You can check out **Good First Issues** in other Hiero repositories:\n\n`; |
177 | | - const repoQuery = SUPPORTED_GFI_REPOS |
178 | | - .map(repo => `repo:${context.repo.owner}/${repo}`) |
179 | | - .join(' OR '); |
180 | | - |
181 | | - const gfiSearchQuery = [ |
182 | | - 'is:open', |
183 | | - 'is:issue', |
184 | | - `org:${context.repo.owner}`, |
185 | | - 'archived:false', |
186 | | - 'no:assignee', |
187 | | - `label:"${GOOD_FIRST_ISSUE_LABEL}"`, |
188 | | - `(${repoQuery})`, |
189 | | - ].join(' '); |
190 | | - |
191 | | - const gfiQuery = `https://github.com/issues?q=${encodeURIComponent(gfiSearchQuery)}`; |
| 78 | + issue = data; |
| 79 | + } catch (error) { |
| 80 | + core.warning(`Could not fetch issue #${linkedIssueNumber}: ${error.message}`); |
| 81 | + return; |
| 82 | + } |
192 | 83 |
|
193 | | - comment += `[View Good First Issues across supported Hiero repositories](${gfiQuery})\n\n`; |
| 84 | + const completedLevelKey = getHighestSkillLevelKey(issue, homeRepo); |
| 85 | + if (!completedLevelKey) { |
| 86 | + core.info(`Issue #${linkedIssueNumber} has no recognised skill level label, skipping`); |
| 87 | + return; |
194 | 88 | } |
195 | 89 |
|
196 | | - comment += `🌟 **Stay connected with the project:**\n`; |
197 | | - comment += `- ⭐ [Star this repository](https://github.com/${context.repo.owner}/${context.repo.repo}) to show your support\n`; |
198 | | - comment += `- 👀 [Watch this repository](https://github.com/${context.repo.owner}/${context.repo.repo}/watchers) to get notified of new issues and releases\n\n`; |
| 90 | + core.info(`Completed level: ${completedLevelKey}`); |
199 | 91 |
|
200 | | - comment += `We look forward to seeing more contributions from you! If you have any questions, feel free to ask in our [Discord community](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md).\n\n`; |
201 | | - comment += `From the Hiero Python SDK Team 🚀`; |
| 92 | + const completedDisplayName = CONFIG.skillPrerequisites[completedLevelKey]?.displayName ?? completedLevelKey; |
202 | 93 |
|
203 | | - // Check for existing comment |
| 94 | + let result; |
204 | 95 | try { |
205 | | - const { data: comments } = await github.rest.issues.listComments({ |
206 | | - owner: context.repo.owner, |
207 | | - repo: context.repo.repo, |
208 | | - issue_number: prNumber, |
209 | | - }); |
210 | | - |
211 | | - const existingComment = comments.find(c => c.body.includes(marker)); |
212 | | - |
213 | | - if (existingComment) { |
214 | | - core.info('Comment already exists, skipping'); |
215 | | - return; |
216 | | - } |
| 96 | + result = await getRecommendedIssues( |
| 97 | + github, |
| 98 | + homeRepo, |
| 99 | + username, |
| 100 | + completedLevelKey, |
| 101 | + { |
| 102 | + owner, |
| 103 | + repo, |
| 104 | + issueNumber: linkedIssueNumber |
| 105 | + }, |
| 106 | + core, |
| 107 | + ); |
217 | 108 | } catch (error) { |
218 | | - core.warning(`Error checking existing comments: ${error.message}`); |
| 109 | + core.error(`Error generating recommendations: ${error.message}`); |
| 110 | + return; |
219 | 111 | } |
220 | 112 |
|
221 | | - // Post the comment |
222 | | - try { |
223 | | - await github.rest.issues.createComment({ |
224 | | - owner: context.repo.owner, |
225 | | - repo: context.repo.repo, |
226 | | - issue_number: prNumber, |
227 | | - body: comment, |
228 | | - }); |
229 | | - |
230 | | - core.info(`Successfully posted comment to PR #${prNumber}`); |
231 | | - } catch (error) { |
232 | | - core.setFailed(`Error posting comment: ${error.message}`); |
| 113 | + if (!result) { |
| 114 | + core.warning('Recommendation engine returned no result'); |
| 115 | + return; |
233 | 116 | } |
234 | | -} |
235 | | -function extractIssueDescription(body) { |
236 | | - if (!body) return '*No description available*'; |
237 | 117 |
|
238 | | - // Guard against massive inputs blocking the regex event loop |
239 | | - const safeBody = body.length > 50000 ? body.substring(0, 50000) : body; |
240 | | - |
241 | | - // Try to find the specific description section first |
242 | | - const sectionRegex = /^##*\s*👾\s*(Description of the issue|Issue description)[^\r\n]*\r?\n([\s\S]*?)(?=^#|$(?![\s\S]))/im; |
243 | | - const match = safeBody.match(sectionRegex); |
244 | | - |
245 | | - let targetText = safeBody; |
246 | | - if (match) { |
247 | | - if (match[2] && match[2].trim()) { |
248 | | - targetText = match[2]; |
| 118 | + const { issues, fromRepo, unlockedLevelKey } = result; |
| 119 | + |
| 120 | + if (issues.length === 0) { |
| 121 | + if (unlockedLevelKey) { |
| 122 | + // Milestone crossed but no issues available — post the unlock banner on its own. |
| 123 | + const posted = await postComment( |
| 124 | + github, owner, repo, prNumber, |
| 125 | + buildRecommendationComment(username, [], completedDisplayName, unlockedLevelKey), |
| 126 | + core, |
| 127 | + ); |
| 128 | + if (!posted) core.setFailed(`Failed to post milestone comment on PR #${prNumber}`); |
249 | 129 | } else { |
250 | | - return '*No description available*'; |
| 130 | + core.info('No eligible issues found, staying silent'); |
251 | 131 | } |
| 132 | + return; |
252 | 133 | } |
253 | 134 |
|
254 | | - // Sanitize: strip HTML, normalize whitespace, escape markdown formatting (links, emphasis, bold) |
255 | | - const sanitized = targetText |
256 | | - .replace(/<[^>]*>/g, '') // Remove HTML tags |
257 | | - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Remove markdown links, keep text |
258 | | - .replace(/(\*\*|__)(.*?)\1/g, '$2') // Remove bold |
259 | | - .replace(/(\*|_)(.*?)\1/g, '$2') // Remove italics |
260 | | - .replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code and codeblocks |
261 | | - .replace(/#{1,6}\s?/g, '') // Remove headings |
262 | | - .replace(/>/g, '') // Remove blockquotes |
263 | | - .replace(/\|/g, '') // Remove table pipes |
264 | | - .replace(/\s+/g, ' ') // Normalize whitespace |
265 | | - .trim(); |
266 | | - |
267 | | - // If we couldn't find a meaningful description, return a default |
268 | | - if (!sanitized) { |
269 | | - return '*No description available*'; |
270 | | - } |
271 | | - |
272 | | - // Truncate to 150 characters |
273 | | - if (sanitized.length > 150) { |
274 | | - return sanitized.substring(0, 150) + '...'; |
| 135 | + if (fromRepo && fromRepo !== `${owner}/${repo}`) { |
| 136 | + core.info(`Recommending from cross-repo: ${fromRepo}`); |
275 | 137 | } |
276 | 138 |
|
277 | | - return sanitized; |
278 | | -} |
| 139 | + const posted = await postComment( |
| 140 | + github, owner, repo, prNumber, |
| 141 | + buildRecommendationComment(username, issues, completedDisplayName, unlockedLevelKey), |
| 142 | + core, |
| 143 | + ); |
279 | 144 |
|
280 | | -module.exports.extractIssueDescription = extractIssueDescription; |
| 145 | + if (!posted) { |
| 146 | + core.setFailed(`Failed to post recommendation comment on PR #${prNumber}`); |
| 147 | + } |
| 148 | +}; |
0 commit comments