diff --git a/.github/mentor_roster.json b/.github/mentor_roster.json new file mode 100644 index 000000000..9e7989ad5 --- /dev/null +++ b/.github/mentor_roster.json @@ -0,0 +1,12 @@ +{ + "order": [ + "emiliyank", + "MonaaEid", + "aceppaluni", + "AntonioCeppellini", + "exploreriii", + "Adityarya11", + "tech0priyanshu", + "Akshat8510" + ] +} diff --git a/.github/scripts/bot-mentor-assignment.js b/.github/scripts/bot-mentor-assignment.js new file mode 100644 index 000000000..b6a8eae78 --- /dev/null +++ b/.github/scripts/bot-mentor-assignment.js @@ -0,0 +1,183 @@ +const fs = require('fs'); +const path = require('path'); + +const COMMENT_MARKER = process.env.COMMENT_MARKER || ''; +const MENTOR_TEAM_ALIAS = process.env.MENTOR_TEAM_ALIAS || '@hiero-ledger/hiero-sdk-python-triage'; +const SUPPORT_TEAM_ALIAS = process.env.SUPPORT_TEAM_ALIAS || '@hiero-ledger/hiero-sdk-good-first-issue-support'; +const DEFAULT_ROSTER_FILE = '.github/mentor_roster.json'; + +function loadMentorRoster() { + const rosterPath = path.resolve( + process.cwd(), + process.env.MENTOR_ROSTER_PATH || DEFAULT_ROSTER_FILE, + ); + + let fileContents; + try { + fileContents = fs.readFileSync(rosterPath, 'utf8'); + } catch (error) { + throw new Error(`Failed to read mentor roster at ${rosterPath}: ${error.message}`); + } + + try { + const parsed = JSON.parse(fileContents); + const rawOrder = Array.isArray(parsed?.order) ? parsed.order : []; + const roster = rawOrder + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter(Boolean); + + if (!roster.length) { + throw new Error('Mentor roster is empty after filtering.'); + } + + return roster; + } catch (error) { + throw new Error(`Failed to parse mentor roster JSON: ${error.message}`); + } +} + +function selectMentor(roster) { + if (!Array.isArray(roster) || roster.length === 0) { + throw new Error('Mentor roster must contain at least one entry.'); + } + + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + const dayNumber = Math.floor(Date.now() / MILLISECONDS_PER_DAY); // UTC day index + const index = dayNumber % roster.length; + + return roster[index]; +} + +function hasGoodFirstIssueLabel(issue) { + return (issue.labels || []).some((label) => { + const name = typeof label === 'string' ? label : label?.name; + return typeof name === 'string' && name.toLowerCase() === 'good first issue'; + }); +} + +async function isNewContributor(github, owner, repo, login) { + const query = `repo:${owner}/${repo} type:pr state:closed is:merged author:${login}`; + + const hasToken = Boolean(process.env.GITHUB_TOKEN || process.env.GH_TOKEN); + console.log(`Mentor assignment search query: ${query}`); + console.log(`GitHub token present: ${hasToken}`); + + try { + const response = await github.rest.search.issuesAndPullRequests({ + q: query, + per_page: 1, + }); + const totalCount = response?.data?.total_count || 0; + console.log(`Merged PR count for ${login}: ${totalCount}`); + const isNewStarter = totalCount === 0; + console.log(`Is ${login} considered a new starter? ${isNewStarter}`); + return isNewStarter; + } catch (error) { + console.log(`Unable to determine merged PRs for ${login}:`, error.message || error); + // Return null (skip assignment) on API errors to avoid workflow failure while preserving accurate logging + return null; + } +} + +function buildComment({ mentee, mentor, owner, repo }) { + const repoUrl = `https://github.com/${owner}/${repo}`; + + return `${COMMENT_MARKER} +👋 Hi @${mentee}, welcome to the Hiero Python SDK community! + +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. + +**How to get started** +- Review the issue description and any linked docs +- Share updates early and ask @${mentor} anything right here +- Keep the feedback loop short so we can support you quickly + +Need more backup? ${SUPPORT_TEAM_ALIAS} is also on standby to cheer you on. + +**Mentor:** @${mentor} +**Mentee:** @${mentee} + +If you're enjoying the SDK, consider ⭐️ [starring the repository](${repoUrl}) so it's easy to find later. + +Happy building! +— Python SDK Team`; +} + +module.exports = async ({ github, context }) => { + try { + const issue = context.payload.issue; + const assignee = context.payload.assignee; + + if (!issue?.number || !assignee?.login) { + return console.log('No issue or assignee found in payload. Skipping.'); + } + + if (assignee.type === 'Bot') { + return console.log(`Assignee ${assignee.login} is a bot. Skipping.`); + } + + if (!hasGoodFirstIssueLabel(issue)) { + return console.log(`Issue #${issue.number} is not labeled as Good First Issue. Skipping.`); + } + + const { owner, repo } = context.repo; + const mentee = assignee.login; + + // Ensure we haven't already posted a mentor assignment comment + const existingComments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issue.number, + per_page: 100, + }); + + if (existingComments.some((comment) => comment.body?.includes(COMMENT_MARKER))) { + return console.log(`Mentor assignment comment already exists on issue #${issue.number}. Skipping.`); + } + + const isNewStarter = await isNewContributor(github, owner, repo, mentee); + + if (isNewStarter === null) { + return console.log(`Unable to confirm whether ${mentee} is a new contributor due to API error. Skipping mentor assignment.`); + } + + if (!isNewStarter) { + return console.log(`${mentee} already has merged contributions. Skipping mentor assignment.`); + } + + const roster = loadMentorRoster(); + const mentor = selectMentor(roster); + + console.log(`Assigning mentor @${mentor} to mentee @${mentee} for issue #${issue.number}.`); + + const comment = buildComment({ mentee, mentor, owner, repo }); + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: comment, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + const freshComments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issue.number, + per_page: 100, + }); + + if (freshComments.some((existing) => existing.body?.includes(COMMENT_MARKER))) { + return console.log(`Mentor assignment comment already exists on issue #${issue.number} after concurrent run. Skipping. (${message})`); + } + + throw error; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`❌ Mentor assignment failed: ${message}`); + throw error; + } +}; diff --git a/.github/workflows/bot-mentor-assignment.yml b/.github/workflows/bot-mentor-assignment.yml new file mode 100644 index 000000000..cf45b81f3 --- /dev/null +++ b/.github/workflows/bot-mentor-assignment.yml @@ -0,0 +1,33 @@ +# Automatically pairs first-time contributors on Good First Issues with a human mentor. +name: Mentor Assignment Bot + +on: + issues: + types: + - assigned + +permissions: + issues: write + contents: read + +jobs: + assign-mentor: + if: contains(github.event.issue.labels.*.name, 'Good First Issue') + runs-on: ubuntu-latest + steps: + - name: Harden the runner + uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Assign mentor to new starter + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const script = require('./.github/scripts/bot-mentor-assignment.js'); + await script({ github, context }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db4298b7..986fc3c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Added a GitHub Actions workflow to validate broken Markdown links in pull requests. - Added method chaining examples to the developer training guide (`docs/sdk_developers/training/coding_token_transactions.md`) (#1194) - Added documentation explaining how to pin GitHub Actions to specific commit SHAs (`docs/sdk_developers/how-to-pin-github-actions.md`)(#1211) +- Added mentor assignment workflow and script to pair new Good First Issue assignees with on-call mentors. - examples/mypy.ini for stricter type checking in example scripts - Added a GitHub Actions workflow that reminds contributors to link pull requests to issues. - Added `__str__` and `__repr__` methods to `AccountInfo` class for improved logging and debugging experience (#1098)