|
| 1 | +const fs = require('fs'); |
| 2 | +const path = require('path'); |
| 3 | + |
| 4 | +const COMMENT_MARKER = process.env.COMMENT_MARKER || '<!-- Mentor Assignment Bot -->'; |
| 5 | +const MENTOR_TEAM_ALIAS = process.env.MENTOR_TEAM_ALIAS || '@hiero-ledger/hiero-sdk-python-triage'; |
| 6 | +const SUPPORT_TEAM_ALIAS = process.env.SUPPORT_TEAM_ALIAS || '@hiero-ledger/hiero-sdk-python-good-first-issue-support'; |
| 7 | +const DEFAULT_ROSTER_FILE = '.github/mentor_roster.json'; |
| 8 | + |
| 9 | +function loadMentorRoster() { |
| 10 | + const rosterPath = path.resolve( |
| 11 | + process.cwd(), |
| 12 | + process.env.MENTOR_ROSTER_PATH || DEFAULT_ROSTER_FILE, |
| 13 | + ); |
| 14 | + |
| 15 | + let fileContents; |
| 16 | + try { |
| 17 | + fileContents = fs.readFileSync(rosterPath, 'utf8'); |
| 18 | + } catch (error) { |
| 19 | + throw new Error(`Failed to read mentor roster at ${rosterPath}: ${error.message}`); |
| 20 | + } |
| 21 | + |
| 22 | + try { |
| 23 | + const parsed = JSON.parse(fileContents); |
| 24 | + const rawOrder = Array.isArray(parsed?.order) ? parsed.order : []; |
| 25 | + const roster = rawOrder |
| 26 | + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) |
| 27 | + .filter(Boolean); |
| 28 | + |
| 29 | + if (!roster.length) { |
| 30 | + throw new Error('Mentor roster is empty after filtering.'); |
| 31 | + } |
| 32 | + |
| 33 | + return roster; |
| 34 | + } catch (error) { |
| 35 | + throw new Error(`Failed to parse mentor roster JSON: ${error.message}`); |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +function selectMentor(roster) { |
| 40 | + if (!Array.isArray(roster) || roster.length === 0) { |
| 41 | + throw new Error('Mentor roster must contain at least one entry.'); |
| 42 | + } |
| 43 | + |
| 44 | + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; |
| 45 | + const dayNumber = Math.floor(Date.now() / MILLISECONDS_PER_DAY); // UTC day index |
| 46 | + const index = dayNumber % roster.length; |
| 47 | + |
| 48 | + return roster[index]; |
| 49 | +} |
| 50 | + |
| 51 | +function hasGoodFirstIssueLabel(issue) { |
| 52 | + return (issue.labels || []).some((label) => { |
| 53 | + const name = typeof label === 'string' ? label : label?.name; |
| 54 | + return typeof name === 'string' && name.toLowerCase() === 'good first issue'; |
| 55 | + }); |
| 56 | +} |
| 57 | + |
| 58 | +async function isNewContributor(github, owner, repo, login) { |
| 59 | + const query = `repo:${owner}/${repo} type:pr state:closed is:merged author:${login}`; |
| 60 | + |
| 61 | + try { |
| 62 | + const response = await github.rest.search.issuesAndPullRequests({ |
| 63 | + q: query, |
| 64 | + per_page: 1, |
| 65 | + }); |
| 66 | + return (response.data.total_count || 0) === 0; |
| 67 | + } catch (error) { |
| 68 | + console.log(`Unable to determine merged PRs for ${login}:`, error.message || error); |
| 69 | + // Return false (skip assignment) on API errors to avoid workflow failure |
| 70 | + return false; |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +function buildComment({ mentee, mentor, owner, repo }) { |
| 75 | + const repoUrl = `https://github.com/${owner}/${repo}`; |
| 76 | + |
| 77 | + return `${COMMENT_MARKER} |
| 78 | +👋 Hi @${mentee}, welcome to the Hiero Python SDK community! |
| 79 | +
|
| 80 | +You've been assigned this Good First Issue, and today’s on-call mentor from ${MENTOR_TEAM_ALIAS} is @${mentor}. They're here to help you land a great first contribution. |
| 81 | +
|
| 82 | +**How to get started** |
| 83 | +- Review the issue description and any linked docs |
| 84 | +- Share updates early and ask @${mentor} anything right here |
| 85 | +- Keep the feedback loop short so we can support you quickly |
| 86 | +
|
| 87 | +Need more backup? ${SUPPORT_TEAM_ALIAS} is also on standby to cheer you on. |
| 88 | +
|
| 89 | +**Mentor:** @${mentor} |
| 90 | +**Mentee:** @${mentee} |
| 91 | +
|
| 92 | +If you're enjoying the SDK, consider ⭐️ [starring the repository](${repoUrl}) so it's easy to find later. |
| 93 | +
|
| 94 | +Happy building! |
| 95 | +— Python SDK Team`; |
| 96 | +} |
| 97 | + |
| 98 | +module.exports = async ({ github, context }) => { |
| 99 | + try { |
| 100 | + const issue = context.payload.issue; |
| 101 | + const assignee = context.payload.assignee; |
| 102 | + |
| 103 | + if (!issue?.number || !assignee?.login) { |
| 104 | + return console.log('No issue or assignee found in payload. Skipping.'); |
| 105 | + } |
| 106 | + |
| 107 | + if (assignee.type === 'Bot') { |
| 108 | + return console.log(`Assignee ${assignee.login} is a bot. Skipping.`); |
| 109 | + } |
| 110 | + |
| 111 | + if (!hasGoodFirstIssueLabel(issue)) { |
| 112 | + return console.log(`Issue #${issue.number} is not labeled as Good First Issue. Skipping.`); |
| 113 | + } |
| 114 | + |
| 115 | + const { owner, repo } = context.repo; |
| 116 | + const mentee = assignee.login; |
| 117 | + |
| 118 | + // Ensure we haven't already posted a mentor assignment comment |
| 119 | + const existingComments = await github.paginate(github.rest.issues.listComments, { |
| 120 | + owner, |
| 121 | + repo, |
| 122 | + issue_number: issue.number, |
| 123 | + per_page: 100, |
| 124 | + }); |
| 125 | + |
| 126 | + if (existingComments.some((comment) => comment.body?.includes(COMMENT_MARKER))) { |
| 127 | + return console.log(`Mentor assignment comment already exists on issue #${issue.number}. Skipping.`); |
| 128 | + } |
| 129 | + |
| 130 | + const isNewStarter = await isNewContributor(github, owner, repo, mentee); |
| 131 | + if (!isNewStarter) { |
| 132 | + return console.log(`${mentee} already has merged contributions. Skipping mentor assignment.`); |
| 133 | + } |
| 134 | + |
| 135 | + const roster = loadMentorRoster(); |
| 136 | + const mentor = selectMentor(roster); |
| 137 | + |
| 138 | + console.log(`Assigning mentor @${mentor} to mentee @${mentee} for issue #${issue.number}.`); |
| 139 | + |
| 140 | + const comment = buildComment({ mentee, mentor, owner, repo }); |
| 141 | + |
| 142 | + await github.rest.issues.createComment({ |
| 143 | + owner, |
| 144 | + repo, |
| 145 | + issue_number: issue.number, |
| 146 | + body: comment, |
| 147 | + }); |
| 148 | + } catch (error) { |
| 149 | + const message = error instanceof Error ? error.message : String(error); |
| 150 | + console.log(`❌ Mentor assignment failed: ${message}`); |
| 151 | + throw error; |
| 152 | + } |
| 153 | +}; |
0 commit comments