Skip to content

Commit b3b4c6f

Browse files
committed
Add intermediate issue assignment guard workflow
Signed-off-by: Mounil <mounilkankhara@gmail.com>
1 parent 19a3a61 commit b3b4c6f

File tree

3 files changed

+193
-0
lines changed

3 files changed

+193
-0
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
const COMMENT_MARKER = process.env.INTERMEDIATE_COMMENT_MARKER || '<!-- Intermediate Issue Guard -->';
2+
const INTERMEDIATE_LABEL = process.env.INTERMEDIATE_LABEL?.trim() || 'intermediate';
3+
const GFI_LABEL = process.env.GFI_LABEL?.trim() || 'Good First Issue';
4+
const ORG_SLUG = process.env.GITHUB_ORG?.trim() || 'hiero-ledger';
5+
const EXEMPT_TEAM_SLUGS = (process.env.INTERMEDIATE_EXEMPT_TEAM_SLUGS || 'hiero-sdk-python-triage,hiero-sdk-python-committers,hiero-sdk-python-maintainers')
6+
.split(',')
7+
.map((entry) => entry.trim())
8+
.filter(Boolean);
9+
10+
function hasLabel(issue, labelName) {
11+
if (!issue?.labels?.length) {
12+
return false;
13+
}
14+
15+
return issue.labels.some((label) => {
16+
const name = typeof label === 'string' ? label : label?.name;
17+
return typeof name === 'string' && name.toLowerCase() === labelName.toLowerCase();
18+
});
19+
}
20+
21+
async function isMemberOfAnyTeam(github, username) {
22+
if (!EXEMPT_TEAM_SLUGS.length) {
23+
return false;
24+
}
25+
26+
for (const teamSlug of EXEMPT_TEAM_SLUGS) {
27+
try {
28+
const response = await github.rest.teams.getMembershipForUserInOrg({
29+
org: ORG_SLUG,
30+
team_slug: teamSlug,
31+
username,
32+
});
33+
34+
if (response?.data?.state === 'active') {
35+
return true;
36+
}
37+
} catch (error) {
38+
if (error?.status === 404) {
39+
continue;
40+
}
41+
42+
const message = error instanceof Error ? error.message : String(error);
43+
console.log(`Unable to verify ${username} on team ${teamSlug}: ${message}`);
44+
}
45+
}
46+
47+
return false;
48+
}
49+
50+
async function countCompletedGfiIssues(github, owner, repo, username) {
51+
const query = `repo:${owner}/${repo} label:"${GFI_LABEL}" state:closed assignee:${username}`;
52+
53+
try {
54+
const response = await github.rest.search.issuesAndPullRequests({
55+
q: query,
56+
per_page: 1,
57+
});
58+
59+
return response?.data?.total_count || 0;
60+
} catch (error) {
61+
const message = error instanceof Error ? error.message : String(error);
62+
console.log(`Unable to count completed GFIs for ${username}: ${message}`);
63+
return 0;
64+
}
65+
}
66+
67+
async function hasExistingGuardComment(github, owner, repo, issueNumber) {
68+
const comments = await github.paginate(github.rest.issues.listComments, {
69+
owner,
70+
repo,
71+
issue_number: issueNumber,
72+
per_page: 100,
73+
});
74+
75+
return comments.some((comment) => comment.body?.includes(COMMENT_MARKER));
76+
}
77+
78+
function buildRejectionComment({ mentee, completedCount }) {
79+
const plural = completedCount === 1 ? '' : 's';
80+
81+
return `${COMMENT_MARKER}
82+
Hi @${mentee}! Thanks for your interest in contributing 💡
83+
84+
This issue is labeled as intermediate, which means it requires a bit more familiarity with the SDK.
85+
Before you can take it on, please complete at least one Good First Issue so we can make sure you have a smooth on-ramp.
86+
87+
You've completed **${completedCount}** Good First Issue${plural} so far.
88+
Once you wrap up your first GFI, feel free to come back and we’ll gladly help you get rolling here!`;
89+
}
90+
91+
module.exports = async ({ github, context }) => {
92+
try {
93+
const issue = context.payload.issue;
94+
const assignee = context.payload.assignee;
95+
96+
if (!issue?.number || !assignee?.login) {
97+
return console.log('Missing issue or assignee in payload. Skipping intermediate guard.');
98+
}
99+
100+
if (!hasLabel(issue, INTERMEDIATE_LABEL)) {
101+
return console.log(`Issue #${issue.number} is not labeled '${INTERMEDIATE_LABEL}'. Skipping.`);
102+
}
103+
104+
if (assignee.type === 'Bot') {
105+
return console.log(`Assignee ${assignee.login} is a bot. Skipping.`);
106+
}
107+
108+
const { owner, repo } = context.repo;
109+
const mentee = assignee.login;
110+
111+
if (await isMemberOfAnyTeam(github, mentee)) {
112+
return console.log(`${mentee} is a member of an exempt team. Skipping guard.`);
113+
}
114+
115+
const completedCount = await countCompletedGfiIssues(github, owner, repo, mentee);
116+
117+
if (completedCount >= 1) {
118+
return console.log(`${mentee} has completed ${completedCount} GFI(s). Assignment allowed.`);
119+
}
120+
121+
try {
122+
await github.rest.issues.removeAssignees({
123+
owner,
124+
repo,
125+
issue_number: issue.number,
126+
assignees: [mentee],
127+
});
128+
console.log(`Removed @${mentee} from issue #${issue.number} due to missing GFI completion.`);
129+
} catch (error) {
130+
const message = error instanceof Error ? error.message : String(error);
131+
console.log(`Unable to remove assignee ${mentee} from issue #${issue.number}: ${message}`);
132+
throw error;
133+
}
134+
135+
if (await hasExistingGuardComment(github, owner, repo, issue.number)) {
136+
return console.log(`Guard comment already exists on issue #${issue.number}. Skipping duplicate message.`);
137+
}
138+
139+
const comment = buildRejectionComment({ mentee, completedCount });
140+
141+
await github.rest.issues.createComment({
142+
owner,
143+
repo,
144+
issue_number: issue.number,
145+
body: comment,
146+
});
147+
148+
console.log(`Posted guard comment for @${mentee} on issue #${issue.number}.`);
149+
} catch (error) {
150+
const message = error instanceof Error ? error.message : String(error);
151+
console.log(`❌ Intermediate assignment guard failed: ${message}`);
152+
throw error;
153+
}
154+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Prevents intermediate issues from being assigned to contributors without prior GFI experience.
2+
name: Intermediate Issue Assignment Guard
3+
4+
on:
5+
issues:
6+
types:
7+
- assigned
8+
9+
permissions:
10+
issues: write
11+
contents: read
12+
13+
jobs:
14+
enforce-intermediate-guard:
15+
if: contains(github.event.issue.labels.*.name, 'intermediate')
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: Enforce intermediate assignment guard
27+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
28+
env:
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30+
INTERMEDIATE_LABEL: intermediate
31+
GFI_LABEL: Good First Issue
32+
GITHUB_ORG: hiero-ledger
33+
INTERMEDIATE_EXEMPT_TEAM_SLUGS: hiero-sdk-python-triage,hiero-sdk-python-committers,hiero-sdk-python-maintainers
34+
INTERMEDIATE_COMMENT_MARKER: "<!-- Intermediate Issue Guard -->"
35+
with:
36+
script: |
37+
const script = require('./.github/scripts/bot-intermediate-assignment.js');
38+
await script({ github, context });

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
1414
- Added a GitHub Actions workflow to validate broken Markdown links in pull requests.
1515
- Added method chaining examples to the developer training guide (`docs/sdk_developers/training/coding_token_transactions.md`) (#1194)
1616
- Added documentation explaining how to pin GitHub Actions to specific commit SHAs (`docs/sdk_developers/how-to-pin-github-actions.md`)(#1211)
17+
- Added workflow to prevent assigning intermediate issues to contributors without prior Good First Issue completion.
1718
- examples/mypy.ini for stricter type checking in example scripts
1819
- Added a GitHub Actions workflow that reminds contributors to link pull requests to issues.
1920
- Added `__str__` and `__repr__` methods to `AccountInfo` class for improved logging and debugging experience (#1098)

0 commit comments

Comments
 (0)