1+ import { readFile } from 'node:fs/promises';
2+
3+ const CONFIG = {
4+ FILE: 'MEMBERS.md',
5+ HEADER: '## Node.js Website Team (`@nodejs/nodejs-website`)',
6+ INACTIVE_MONTHS: 12,
7+ ISSUE_TITLE: 'Inactive Collaborator Report',
8+ ISSUE_LABELS: ['meta', 'inactive-collaborator-report'],
9+ };
10+
11+ // Get date N months ago in YYYY-MM-DD format
12+ const getDateMonthsAgo = (months = CONFIG.INACTIVE_MONTHS) => {
13+ const date = new Date();
14+ date.setMonth(date.getMonth() - months);
15+ return date.toISOString().split('T')[0];
16+ };
17+
18+ // Check if there's already an open issue
19+ async function hasOpenIssue(github, context) {
20+ const { owner, repo } = context.repo;
21+ const { data: issues } = await github.rest.issues.listForRepo({
22+ owner,
23+ repo,
24+ state: 'open',
25+ labels: CONFIG.ISSUE_LABELS[1],
26+ per_page: 1,
27+ });
28+
29+ return issues.length > 0;
30+ }
31+
32+ // Parse collaborator usernames from governance file
33+ async function parseCollaborators() {
34+ const content = await readFile(CONFIG.FILE, 'utf8');
35+ const lines = content.split('\n');
36+ const collaborators = [];
37+
38+ const startIndex =
39+ lines.findIndex(l => l.startsWith(CONFIG.HEADER)) + 1;
40+ if (startIndex <= 0) return collaborators;
41+
42+ for (let i = startIndex; i < lines.length; i++) {
43+ const line = lines[i];
44+ if (line.startsWith('#')) break;
45+
46+ const match = line.match(/^\s*-\s*\[([^\]]+)\]/);
47+ if (match) collaborators.push(match[1]);
48+ }
49+
50+ return collaborators;
51+ }
52+
53+ // Check if users have been active since cutoff date
54+ async function getInactiveUsers(github, usernames, repo, cutoffDate) {
55+ const inactiveUsers = [];
56+
57+ for (const username of usernames) {
58+ // Check commits
59+ const { data: commits } = await github.rest.search.commits({
60+ q: `author:${username} repo:${repo} committer-date:>=${cutoffDate}`,
61+ per_page: 1,
62+ });
63+
64+ // Check issues and PRs
65+ const { data: issues } = await github.rest.search.issuesAndPullRequests({
66+ q: `involves:${username} repo:${repo} updated:>=${cutoffDate}`,
67+ per_page: 1,
68+ });
69+
70+ // User is inactive if they have no commits AND no issues/PRs
71+ if (commits.total_count === 0 && issues.total_count === 0) {
72+ inactiveUsers.push(username);
73+ }
74+ }
75+
76+ return inactiveUsers;
77+ }
78+
79+ // Generate report for inactive members
80+ function formatReport(inactiveMembers, cutoffDate) {
81+ if (!inactiveMembers.length) return null;
82+
83+ const today = getDateMonthsAgo(0);
84+ return `# Inactive Collaborators Report
85+
86+ Last updated: ${today}
87+ Checking for inactivity since: ${cutoffDate}
88+
89+ ## Inactive Collaborators (${inactiveMembers.length})
90+
91+ | Login |
92+ | ----- |
93+ ${inactiveMembers.map(m => `| @${m} |`).join('\n')}
94+
95+ ## What happens next?
96+
97+ @nodejs/nodejs-website should review this list and contact inactive collaborators to confirm their continued interest in participating in the project.`;
98+ }
99+
100+ async function createIssue(github, context, report) {
101+ if (!report) return;
102+
103+ const { owner, repo } = context.repo;
104+ await github.rest.issues.create({
105+ owner,
106+ repo,
107+ title: CONFIG.ISSUE_TITLE,
108+ body: report,
109+ labels: CONFIG.ISSUE_LABELS,
110+ });
111+ }
112+
113+ export default async function (github, context) {
114+ // Check for existing open issue first - exit early if one exists
115+ if (await hasOpenIssue(github, context)) {
116+ return;
117+ }
118+
119+ const cutoffDate = getDateMonthsAgo();
120+ const collaborators = await parseCollaborators();
121+
122+ const inactiveMembers = await getInactiveUsers(
123+ github,
124+ collaborators,
125+ `${context.repo.owner}/${context.repo.repo}`,
126+ cutoffDate
127+ );
128+ const report = formatReport(inactiveMembers, cutoffDate);
129+
130+ await createIssue(github, context, report);
131+ }
0 commit comments