Skip to content

Commit c6dd8f1

Browse files
authored
Merge pull request #49 from exploreriii/test-mentor-assignment
test: mentor assignment
2 parents 265917f + 8a35bb9 commit c6dd8f1

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

.github/mentor_roster.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"order": [
3+
"emiliyank",
4+
"MonaaEid",
5+
"aceppaluni",
6+
"AntonioCeppellini",
7+
"exploreriii",
8+
"Adityarya11",
9+
"tech0priyanshu",
10+
"Akshat8510"
11+
]
12+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Automatically pairs first-time contributors on Good First Issues with a human mentor.
2+
name: Mentor Assignment Bot
3+
4+
on:
5+
issues:
6+
types:
7+
- assigned
8+
9+
permissions:
10+
issues: write
11+
contents: read
12+
13+
jobs:
14+
assign-mentor:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Harden the runner
18+
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
19+
with:
20+
egress-policy: audit
21+
22+
- name: Checkout repository
23+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
24+
25+
- name: Assign mentor to new starter
26+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
27+
env:
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
with:
30+
script: |
31+
const script = require('./.github/scripts/mentor_assignment.js');
32+
await script({ github, context });

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
99

1010

1111
### Added
12+
- Added mentor assignment workflow and script to pair new Good First Issue assignees with on-call mentors.
1213
- examples/mypy.ini for stricter type checking in example scripts
1314
- Added a GitHub Actions workflow that reminds contributors to link pull requests to issues.
1415
- Added `__str__` and `__repr__` methods to `AccountInfo` class for improved logging and debugging experience (#1098)

0 commit comments

Comments
 (0)