Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/mentor_roster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"order": [
"emiliyank",
"MonaaEid",
"aceppaluni",
"AntonioCeppellini",
"exploreriii",
"Adityarya11",
"tech0priyanshu",
"Akshat8510"
]
}
183 changes: 183 additions & 0 deletions .github/scripts/bot-mentor-assignment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
const fs = require('fs');
const path = require('path');

const COMMENT_MARKER = process.env.COMMENT_MARKER || '<!-- Mentor Assignment Bot -->';
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;
}
};
33 changes: 33 additions & 0 deletions .github/workflows/bot-mentor-assignment.yml
Original file line number Diff line number Diff line change
@@ -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 });
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading