Skip to content

Commit 95f0ad2

Browse files
authored
feat: add mentor assignment workflow (hiero-ledger#1201)
Signed-off-by: Mounil <[email protected]>
1 parent db04ee4 commit 95f0ad2

File tree

4 files changed

+254
-0
lines changed

4 files changed

+254
-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: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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-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+
let targetOwner = owner;
60+
let targetRepo = repo;
61+
62+
console.log(`Checking contributor status for ${login} in ${owner}/${repo}`);
63+
64+
try {
65+
const repoData = await github.rest.repos.get({ owner, repo });
66+
console.log(`Repository fork status: ${repoData.data.fork}`);
67+
if (repoData.data.fork && repoData.data.parent) {
68+
targetOwner = repoData.data.parent.owner.login;
69+
targetRepo = repoData.data.parent.name;
70+
console.log(`Detected fork. Using parent repository: ${targetOwner}/${targetRepo}`);
71+
} else {
72+
console.log(`Not a fork or no parent found. Using current repository: ${targetOwner}/${targetRepo}`);
73+
}
74+
} catch (error) {
75+
console.log(`Unable to check if repository is a fork: ${error.message || error}`);
76+
}
77+
78+
try {
79+
console.log(`Checking for merged PRs by ${login} in ${targetOwner}/${targetRepo}`);
80+
81+
const iterator = github.paginate.iterator(github.rest.pulls.list, {
82+
owner: targetOwner,
83+
repo: targetRepo,
84+
state: 'closed',
85+
sort: 'updated',
86+
direction: 'desc',
87+
per_page: 100,
88+
});
89+
90+
for await (const { data: pullRequests } of iterator) {
91+
const mergedPR = pullRequests.find(pr => pr.user?.login === login && pr.merged_at !== null);
92+
if (mergedPR) {
93+
console.log(`Found merged PR #${mergedPR.number} by ${login}. Not a new contributor.`);
94+
return false;
95+
}
96+
}
97+
98+
console.log(`No merged PRs found for ${login}. Considered a new starter.`);
99+
return true;
100+
} catch (error) {
101+
console.log(`Unable to determine merged PRs for ${login}:`, error.message || error);
102+
// Return null (skip assignment) on API errors to avoid workflow failure while preserving accurate logging
103+
return null;
104+
}
105+
}
106+
107+
function buildComment({ mentee, mentor, owner, repo }) {
108+
const repoUrl = `https://github.com/${owner}/${repo}`;
109+
110+
return `${COMMENT_MARKER}
111+
👋 Hi @${mentee}, welcome to the Hiero Python SDK community!
112+
113+
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.
114+
115+
**How to get started**
116+
- Review the issue description and any linked docs
117+
- Share updates early and ask @${mentor} anything right here
118+
- Keep the feedback loop short so we can support you quickly
119+
120+
Need more backup? ${SUPPORT_TEAM_ALIAS} is also on standby to cheer you on.
121+
122+
**Mentor:** @${mentor}
123+
**Mentee:** @${mentee}
124+
125+
If you're enjoying the SDK, consider ⭐️ [starring the repository](${repoUrl}) so it's easy to find later.
126+
127+
Happy building!
128+
— Python SDK Team`;
129+
}
130+
131+
module.exports = async ({ github, context }) => {
132+
try {
133+
const issue = context.payload.issue;
134+
const assignee = context.payload.assignee;
135+
136+
if (!issue?.number || !assignee?.login) {
137+
return console.log('No issue or assignee found in payload. Skipping.');
138+
}
139+
140+
if (assignee.type === 'Bot') {
141+
return console.log(`Assignee ${assignee.login} is a bot. Skipping.`);
142+
}
143+
144+
if (!hasGoodFirstIssueLabel(issue)) {
145+
return console.log(`Issue #${issue.number} is not labeled as Good First Issue. Skipping.`);
146+
}
147+
148+
const { owner, repo } = context.repo;
149+
const mentee = assignee.login;
150+
151+
// Ensure we haven't already posted a mentor assignment comment
152+
const existingComments = await github.paginate(github.rest.issues.listComments, {
153+
owner,
154+
repo,
155+
issue_number: issue.number,
156+
per_page: 100,
157+
});
158+
159+
if (existingComments.some((comment) => comment.body?.includes(COMMENT_MARKER))) {
160+
return console.log(`Mentor assignment comment already exists on issue #${issue.number}. Skipping.`);
161+
}
162+
163+
const isNewStarter = await isNewContributor(github, owner, repo, mentee);
164+
165+
if (isNewStarter === null) {
166+
return console.log(`Unable to confirm whether ${mentee} is a new contributor due to API error. Skipping mentor assignment.`);
167+
}
168+
169+
if (!isNewStarter) {
170+
return console.log(`${mentee} already has merged contributions. Skipping mentor assignment.`);
171+
}
172+
173+
const roster = loadMentorRoster();
174+
const mentor = selectMentor(roster);
175+
176+
console.log(`Assigning mentor @${mentor} to mentee @${mentee} for issue #${issue.number}.`);
177+
178+
const comment = buildComment({ mentee, mentor, owner, repo });
179+
180+
try {
181+
await github.rest.issues.createComment({
182+
owner,
183+
repo,
184+
issue_number: issue.number,
185+
body: comment,
186+
});
187+
} catch (error) {
188+
const message = error instanceof Error ? error.message : String(error);
189+
190+
const freshComments = await github.paginate(github.rest.issues.listComments, {
191+
owner,
192+
repo,
193+
issue_number: issue.number,
194+
per_page: 100,
195+
});
196+
197+
if (freshComments.some((existing) => existing.body?.includes(COMMENT_MARKER))) {
198+
return console.log(`Mentor assignment comment already exists on issue #${issue.number} after concurrent run. Skipping. (${message})`);
199+
}
200+
201+
throw error;
202+
}
203+
} catch (error) {
204+
const message = error instanceof Error ? error.message : String(error);
205+
console.log(`❌ Mentor assignment failed: ${message}`);
206+
throw error;
207+
}
208+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
if: contains(github.event.issue.labels.*.name, 'Good First Issue')
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Harden the runner
19+
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
20+
with:
21+
egress-policy: audit
22+
23+
- name: Checkout repository
24+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
25+
26+
- name: Assign mentor to new starter
27+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
28+
env:
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30+
with:
31+
script: |
32+
const script = require('./.github/scripts/bot-mentor-assignment.js');
33+
await script({ github, context });

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
1616
- Added a GitHub Actions workflow to validate broken Markdown links in pull requests.
1717
- Added method chaining examples to the developer training guide (`docs/sdk_developers/training/coding_token_transactions.md`) (#1194)
1818
- Added documentation explaining how to pin GitHub Actions to specific commit SHAs (`docs/sdk_developers/how-to-pin-github-actions.md`)(#1211)
19+
- Added mentor assignment workflow and script to pair new Good First Issue assignees with on-call mentors.
1920
- examples/mypy.ini for stricter type checking in example scripts
2021
- Added a GitHub Actions workflow that reminds contributors to link pull requests to issues.
2122
- Added `__str__` and `__repr__` methods to `AccountInfo` class for improved logging and debugging experience (#1098)

0 commit comments

Comments
 (0)