Skip to content

Commit 8c80e28

Browse files
authored
Merge branch 'hiero-ledger:main' into main
2 parents 4a43f81 + 5345fd5 commit 8c80e28

24 files changed

Lines changed: 1602 additions & 312 deletions
Lines changed: 108 additions & 240 deletions
Original file line numberDiff line numberDiff line change
@@ -1,280 +1,148 @@
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.
1418

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+
// ---------------------------------------------------------------------------
2232

2333
module.exports = async ({ github, context, core }) => {
2434
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);
2539

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)');
3742
return;
3843
}
3944

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');
5347
return;
5448
}
5549

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}`);
5951

52+
const { owner, repo } = homeRepo;
6053
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');
9056
return;
9157
}
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-
12058
} 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;
12263
}
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}`);
12964

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;
14069
}
141-
}
14270

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}`);
14572

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,
17377
});
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+
}
19283

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;
19488
}
19589

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}`);
19991

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;
20293

203-
// Check for existing comment
94+
let result;
20495
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+
);
217108
} catch (error) {
218-
core.warning(`Error checking existing comments: ${error.message}`);
109+
core.error(`Error generating recommendations: ${error.message}`);
110+
return;
219111
}
220112

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;
233116
}
234-
}
235-
function extractIssueDescription(body) {
236-
if (!body) return '*No description available*';
237117

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}`);
249129
} else {
250-
return '*No description available*';
130+
core.info('No eligible issues found, staying silent');
251131
}
132+
return;
252133
}
253134

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}`);
275137
}
276138

277-
return sanitized;
278-
}
139+
const posted = await postComment(
140+
github, owner, repo, prNumber,
141+
buildRecommendationComment(username, issues, completedDisplayName, unlockedLevelKey),
142+
core,
143+
);
279144

280-
module.exports.extractIssueDescription = extractIssueDescription;
145+
if (!posted) {
146+
core.setFailed(`Failed to post recommendation comment on PR #${prNumber}`);
147+
}
148+
};

.github/scripts/cron-enforcer-pr-linked-issue.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
// A script to closes pull requests without a linked issue after 12 hours automatically.
1+
// A script to closes pull requests without a linked issue after 24 hours automatically.
22

33
// dryRun env var: any case-insensitive 'true' value will enable dry-run
44
const dryRun = (process.env.DRY_RUN || 'false').toString().toLowerCase() === 'true';
5-
const hoursBeforeClose = parseInt(process.env.HOURS_BEFORE_CLOSE || '12', 10);
5+
const hoursBeforeClose = parseInt(process.env.HOURS_BEFORE_CLOSE || '24', 10);
66
const requireAuthorAssigned = (process.env.REQUIRE_AUTHOR_ASSIGNED || 'true').toLowerCase() === 'true';
77

88
const getHoursOpen = (pr) =>

0 commit comments

Comments
 (0)