gitpadi 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/action.yml +92 -0
- package/examples/applicant-scorer.yml +30 -0
- package/examples/create-issues.yml +42 -0
- package/examples/issues.json +48 -0
- package/examples/pr-review.yml +23 -0
- package/package.json +52 -0
- package/src/applicant-scorer.ts +285 -0
- package/src/cli.ts +648 -0
- package/src/commands/contributors.ts +114 -0
- package/src/commands/issues.ts +267 -0
- package/src/commands/prs.ts +243 -0
- package/src/commands/releases.ts +54 -0
- package/src/commands/repos.ts +129 -0
- package/src/core/github.ts +43 -0
- package/src/core/scorer.ts +127 -0
- package/src/create-issues.ts +203 -0
- package/src/pr-review.ts +132 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
// applicant-scorer.ts — Detects issue applicants, scores them, and recommends the best fit
|
|
3
|
+
//
|
|
4
|
+
// Works for ANY repository — no hardcoded values.
|
|
5
|
+
//
|
|
6
|
+
// Usage via Action: action: score-applicant
|
|
7
|
+
// Usage (CLI): GITHUB_TOKEN=xxx ISSUE_NUMBER=5 COMMENT_ID=123 npx tsx src/applicant-scorer.ts
|
|
8
|
+
|
|
9
|
+
import { Octokit } from '@octokit/rest';
|
|
10
|
+
|
|
11
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
12
|
+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN!;
|
|
13
|
+
const OWNER = process.env.GITHUB_OWNER || process.env.GITHUB_REPOSITORY?.split('/')[0] || '';
|
|
14
|
+
const REPO = process.env.GITHUB_REPO || process.env.GITHUB_REPOSITORY?.split('/')[1] || '';
|
|
15
|
+
const ISSUE_NUMBER = parseInt(process.env.ISSUE_NUMBER || '0');
|
|
16
|
+
const COMMENT_ID = parseInt(process.env.COMMENT_ID || '0');
|
|
17
|
+
const NOTIFY_USER = process.env.NOTIFY_USER || '';
|
|
18
|
+
|
|
19
|
+
if (!GITHUB_TOKEN) { console.error('❌ GITHUB_TOKEN required'); process.exit(1); }
|
|
20
|
+
|
|
21
|
+
const octokit = new Octokit({ auth: GITHUB_TOKEN });
|
|
22
|
+
|
|
23
|
+
// ── Application Detection ──────────────────────────────────────────────
|
|
24
|
+
const APPLICATION_PATTERNS = [
|
|
25
|
+
/i('d| would) (like|love|want) to (work on|tackle|take|pick up|handle)/i,
|
|
26
|
+
/can i (work on|take|pick up|handle|be assigned)/i,
|
|
27
|
+
/assign (this |it )?(to )?me/i,
|
|
28
|
+
/i('m| am) interested/i,
|
|
29
|
+
/let me (work on|take|handle)/i,
|
|
30
|
+
/i('ll| will) (work on|take|handle|do)/i,
|
|
31
|
+
/i want to contribute/i,
|
|
32
|
+
/please assign/i,
|
|
33
|
+
/i can (do|handle|take care of|work on)/i,
|
|
34
|
+
/picking this up/i,
|
|
35
|
+
/claiming this/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function isApplicationComment(body: string): boolean {
|
|
39
|
+
return APPLICATION_PATTERNS.some((p) => p.test(body));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
43
|
+
interface ApplicantProfile {
|
|
44
|
+
username: string;
|
|
45
|
+
avatarUrl: string;
|
|
46
|
+
accountAge: number;
|
|
47
|
+
publicRepos: number;
|
|
48
|
+
followers: number;
|
|
49
|
+
totalContributions: number;
|
|
50
|
+
repoContributions: number;
|
|
51
|
+
relevantLanguages: string[];
|
|
52
|
+
hasReadme: boolean;
|
|
53
|
+
prsMerged: number;
|
|
54
|
+
prsOpen: number;
|
|
55
|
+
issuesCreated: number;
|
|
56
|
+
commentBody: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ScoreBreakdown {
|
|
60
|
+
accountMaturity: number; // 0-15
|
|
61
|
+
repoExperience: number; // 0-30
|
|
62
|
+
githubPresence: number; // 0-15
|
|
63
|
+
activityLevel: number; // 0-15
|
|
64
|
+
applicationQuality: number; // 0-15
|
|
65
|
+
languageRelevance: number; // 0-10
|
|
66
|
+
total: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ScoredApplicant extends ApplicantProfile {
|
|
70
|
+
score: number;
|
|
71
|
+
breakdown: ScoreBreakdown;
|
|
72
|
+
tier: 'S' | 'A' | 'B' | 'C' | 'D';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Fetch Profile ──────────────────────────────────────────────────────
|
|
76
|
+
async function fetchProfile(username: string, commentBody: string): Promise<ApplicantProfile> {
|
|
77
|
+
const { data: user } = await octokit.users.getByUsername({ username });
|
|
78
|
+
const accountAge = Math.floor((Date.now() - new Date(user.created_at).getTime()) / 86400000);
|
|
79
|
+
|
|
80
|
+
let totalContributions = 0;
|
|
81
|
+
try {
|
|
82
|
+
const { data: events } = await octokit.activity.listPublicEventsForUser({ username, per_page: 100 });
|
|
83
|
+
totalContributions = events.length;
|
|
84
|
+
} catch { /* continue */ }
|
|
85
|
+
|
|
86
|
+
let prsMerged = 0, prsOpen = 0, issuesCreated = 0;
|
|
87
|
+
try {
|
|
88
|
+
const { data: mp } = await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:pr author:${username} is:merged` });
|
|
89
|
+
prsMerged = mp.total_count;
|
|
90
|
+
const { data: op } = await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:pr author:${username} is:open` });
|
|
91
|
+
prsOpen = op.total_count;
|
|
92
|
+
const { data: ci } = await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:issue author:${username}` });
|
|
93
|
+
issuesCreated = ci.total_count;
|
|
94
|
+
} catch { /* rate limit */ }
|
|
95
|
+
|
|
96
|
+
let relevantLanguages: string[] = [];
|
|
97
|
+
try {
|
|
98
|
+
const { data: repos } = await octokit.repos.listForUser({ username, sort: 'updated', per_page: 20 });
|
|
99
|
+
const langs = new Set<string>();
|
|
100
|
+
repos.forEach((r) => { if (r.language) langs.add(r.language); });
|
|
101
|
+
relevantLanguages = Array.from(langs);
|
|
102
|
+
} catch { /* continue */ }
|
|
103
|
+
|
|
104
|
+
let hasReadme = false;
|
|
105
|
+
try { await octokit.repos.get({ owner: username, repo: username }); hasReadme = true; } catch { /* no readme */ }
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
username, avatarUrl: user.avatar_url, accountAge,
|
|
109
|
+
publicRepos: user.public_repos, followers: user.followers,
|
|
110
|
+
totalContributions, repoContributions: prsMerged + prsOpen + issuesCreated,
|
|
111
|
+
relevantLanguages, hasReadme, prsMerged, prsOpen, issuesCreated, commentBody,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Scoring Algorithm ──────────────────────────────────────────────────
|
|
116
|
+
function scoreApplicant(p: ApplicantProfile, issueLabels: string[]): ScoredApplicant {
|
|
117
|
+
const b: ScoreBreakdown = { accountMaturity: 0, repoExperience: 0, githubPresence: 0, activityLevel: 0, applicationQuality: 0, languageRelevance: 0, total: 0 };
|
|
118
|
+
|
|
119
|
+
// 1. Account Maturity (0-15)
|
|
120
|
+
b.accountMaturity = p.accountAge > 730 ? 15 : p.accountAge > 365 ? 12 : p.accountAge > 180 ? 9 : p.accountAge > 90 ? 6 : p.accountAge > 30 ? 3 : 1;
|
|
121
|
+
|
|
122
|
+
// 2. Repo Experience (0-30) — highest weight
|
|
123
|
+
b.repoExperience = Math.min(15, p.prsMerged * 5) + Math.min(5, p.prsOpen * 3) + Math.min(5, p.issuesCreated * 2) + Math.min(5, p.repoContributions);
|
|
124
|
+
|
|
125
|
+
// 3. GitHub Presence (0-15)
|
|
126
|
+
b.githubPresence = Math.min(5, Math.floor(p.publicRepos / 5)) + Math.min(5, Math.floor(p.followers / 10)) + (p.hasReadme ? 5 : 0);
|
|
127
|
+
|
|
128
|
+
// 4. Activity Level (0-15)
|
|
129
|
+
b.activityLevel = p.totalContributions >= 80 ? 15 : p.totalContributions >= 50 ? 12 : p.totalContributions >= 30 ? 9 : p.totalContributions >= 15 ? 6 : p.totalContributions >= 5 ? 3 : 1;
|
|
130
|
+
|
|
131
|
+
// 5. Application Quality (0-15)
|
|
132
|
+
const words = p.commentBody.split(/\s+/).length;
|
|
133
|
+
const hasApproach = /approach|plan|implement|would|will|by|using|step/i.test(p.commentBody);
|
|
134
|
+
const hasExp = /experience|worked|built|familiar|know|background/i.test(p.commentBody);
|
|
135
|
+
b.applicationQuality = (words >= 50 && hasApproach && hasExp) ? 15 : (words >= 30 && (hasApproach || hasExp)) ? 12 : (words >= 20 && hasApproach) ? 9 : words >= 15 ? 6 : words >= 8 ? 3 : 1;
|
|
136
|
+
|
|
137
|
+
// 6. Language Relevance (0-10) — auto-detect from repo languages
|
|
138
|
+
b.languageRelevance = 5; // default neutral
|
|
139
|
+
if (issueLabels.length > 0 && p.relevantLanguages.length > 0) {
|
|
140
|
+
// Approximate: check if user knows popular labels' likely languages
|
|
141
|
+
const labelLower = issueLabels.map((l) => l.toLowerCase()).join(' ');
|
|
142
|
+
const hasRust = p.relevantLanguages.includes('Rust');
|
|
143
|
+
const hasTS = p.relevantLanguages.includes('TypeScript') || p.relevantLanguages.includes('JavaScript');
|
|
144
|
+
const needsRust = /contract|rust|wasm|soroban/i.test(labelLower);
|
|
145
|
+
const needsTS = /backend|frontend|api|websocket|typescript/i.test(labelLower);
|
|
146
|
+
|
|
147
|
+
let matches = 0, needed = 0;
|
|
148
|
+
if (needsRust) { needed++; if (hasRust) matches++; }
|
|
149
|
+
if (needsTS) { needed++; if (hasTS) matches++; }
|
|
150
|
+
if (needed > 0) b.languageRelevance = Math.round((matches / needed) * 10);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
b.total = b.accountMaturity + b.repoExperience + b.githubPresence + b.activityLevel + b.applicationQuality + b.languageRelevance;
|
|
154
|
+
|
|
155
|
+
const tier: ScoredApplicant['tier'] = b.total >= 75 ? 'S' : b.total >= 55 ? 'A' : b.total >= 40 ? 'B' : b.total >= 25 ? 'C' : 'D';
|
|
156
|
+
|
|
157
|
+
return { ...p, score: b.total, breakdown: b, tier };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Build Comments ─────────────────────────────────────────────────────
|
|
161
|
+
const TIER_EMOJI: Record<string, string> = { S: '🏆', A: '🟢', B: '🟡', C: '🟠', D: '🔴' };
|
|
162
|
+
|
|
163
|
+
function buildReviewComment(s: ScoredApplicant, count: number): string {
|
|
164
|
+
let c = `## 🤖 Contributor Agent — Applicant Review\n\n`;
|
|
165
|
+
c += `**Applicant:** @${s.username} ${TIER_EMOJI[s.tier]} **Tier ${s.tier}** (${s.score}/100)\n\n`;
|
|
166
|
+
c += `<details>\n<summary>📊 Score Breakdown</summary>\n\n`;
|
|
167
|
+
c += `| Category | Score | Max |\n|----------|-------|-----|\n`;
|
|
168
|
+
c += `| 🏛️ Account Maturity | ${s.breakdown.accountMaturity} | 15 |\n`;
|
|
169
|
+
c += `| 🔧 Repo Experience | ${s.breakdown.repoExperience} | 30 |\n`;
|
|
170
|
+
c += `| 🌐 GitHub Presence | ${s.breakdown.githubPresence} | 15 |\n`;
|
|
171
|
+
c += `| ⚡ Activity Level | ${s.breakdown.activityLevel} | 15 |\n`;
|
|
172
|
+
c += `| 📝 Application Quality | ${s.breakdown.applicationQuality} | 15 |\n`;
|
|
173
|
+
c += `| 💻 Language Relevance | ${s.breakdown.languageRelevance} | 10 |\n`;
|
|
174
|
+
c += `| **Total** | **${s.score}** | **100** |\n\n`;
|
|
175
|
+
c += `**Profile:** ${s.publicRepos} repos · ${s.followers} followers · ${Math.round(s.accountAge / 30)} months\n`;
|
|
176
|
+
c += `**Languages:** ${s.relevantLanguages.slice(0, 8).join(', ') || 'None detected'}\n`;
|
|
177
|
+
c += `**Repo history:** ${s.prsMerged} merged PRs · ${s.prsOpen} open PRs · ${s.issuesCreated} issues\n\n`;
|
|
178
|
+
c += `</details>\n\n`;
|
|
179
|
+
|
|
180
|
+
if (s.tier === 'S' || s.tier === 'A') c += `> ✅ **Strong candidate.** Consider assigning.\n`;
|
|
181
|
+
else if (s.tier === 'B') c += `> 🟡 **Decent candidate.** Review profile before assigning.\n`;
|
|
182
|
+
else c += `> ⚠️ **Low score.** Wait for stronger candidates or review manually.\n`;
|
|
183
|
+
|
|
184
|
+
if (count > 1) c += `\n📋 **${count} applicant(s)** — see comparison below.\n`;
|
|
185
|
+
if (NOTIFY_USER) c += `\n🔔 cc @${NOTIFY_USER}\n`;
|
|
186
|
+
c += `\n---\n<sub>🤖 Contributor Agent · Tier ${s.tier} · ${s.score}/100</sub>`;
|
|
187
|
+
return c;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildComparisonComment(applicants: ScoredApplicant[]): string {
|
|
191
|
+
const sorted = [...applicants].sort((a, b) => b.score - a.score);
|
|
192
|
+
let c = `## 🤖 Contributor Agent — Applicant Comparison\n\n`;
|
|
193
|
+
c += `| Rank | Applicant | Tier | Score | Repo PRs | Languages | Activity |\n`;
|
|
194
|
+
c += `|------|-----------|------|-------|----------|-----------|----------|\n`;
|
|
195
|
+
sorted.forEach((a, i) => {
|
|
196
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
197
|
+
c += `| ${medal} | @${a.username} | ${TIER_EMOJI[a.tier]} ${a.tier} | **${a.score}**/100 | ${a.prsMerged} merged | ${a.relevantLanguages.slice(0, 3).join(', ') || '-'} | ${a.totalContributions} events |\n`;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (sorted.length >= 2) {
|
|
201
|
+
const gap = sorted[0].score - sorted[1].score;
|
|
202
|
+
if (gap >= 20) c += `\n> 🏆 **Clear winner:** @${sorted[0].username} leads by ${gap} points.\n`;
|
|
203
|
+
else if (gap >= 5) c += `\n> 🏆 **Top pick:** @${sorted[0].username} leads by ${gap} points.\n`;
|
|
204
|
+
else c += `\n> ⚖️ **Close match:** Top candidates within ${gap} points. Manual review recommended.\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (NOTIFY_USER) c += `\n🔔 cc @${NOTIFY_USER}\n`;
|
|
208
|
+
c += `\n---\n<sub>🤖 Contributor Agent · ${sorted.length} candidates ranked</sub>`;
|
|
209
|
+
return c;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
213
|
+
async function main(): Promise<void> {
|
|
214
|
+
console.log(`\n🤖 Applicant Scorer — ${OWNER}/${REPO} #${ISSUE_NUMBER}\n`);
|
|
215
|
+
if (!ISSUE_NUMBER) { console.error('❌ ISSUE_NUMBER required'); process.exit(1); }
|
|
216
|
+
|
|
217
|
+
// Get trigger comment
|
|
218
|
+
let trigger: { body: string; user: string };
|
|
219
|
+
if (COMMENT_ID) {
|
|
220
|
+
const { data: c } = await octokit.issues.getComment({ owner: OWNER, repo: REPO, comment_id: COMMENT_ID });
|
|
221
|
+
trigger = { body: c.body || '', user: c.user?.login || '' };
|
|
222
|
+
} else {
|
|
223
|
+
const { data: cs } = await octokit.issues.listComments({ owner: OWNER, repo: REPO, issue_number: ISSUE_NUMBER, per_page: 1, direction: 'desc' });
|
|
224
|
+
if (!cs.length) { console.log('No comments.'); return; }
|
|
225
|
+
trigger = { body: cs[0].body || '', user: cs[0].user?.login || '' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log(` Commenter: ${trigger.user}`);
|
|
229
|
+
if (!isApplicationComment(trigger.body)) { console.log('\n⏭️ Not an application — skipping.'); return; }
|
|
230
|
+
console.log('✅ Application detected!\n');
|
|
231
|
+
|
|
232
|
+
// Get issue labels
|
|
233
|
+
const { data: issue } = await octokit.issues.get({ owner: OWNER, repo: REPO, issue_number: ISSUE_NUMBER });
|
|
234
|
+
const labels = issue.labels.map((l) => (typeof l === 'string' ? l : l.name || '')).filter(Boolean);
|
|
235
|
+
|
|
236
|
+
// Score current applicant
|
|
237
|
+
console.log(`📊 Scoring @${trigger.user}...`);
|
|
238
|
+
const profile = await fetchProfile(trigger.user, trigger.body);
|
|
239
|
+
const scored = scoreApplicant(profile, labels);
|
|
240
|
+
console.log(` Score: ${scored.score}/100 (Tier ${scored.tier})\n`);
|
|
241
|
+
|
|
242
|
+
// Find all applicants
|
|
243
|
+
const { data: allComments } = await octokit.issues.listComments({ owner: OWNER, repo: REPO, issue_number: ISSUE_NUMBER, per_page: 100 });
|
|
244
|
+
const BOT_MARKER = '## 🤖 Contributor Agent';
|
|
245
|
+
const apps = allComments.filter((c) => c.user?.login !== 'github-actions[bot]' && !c.body?.includes(BOT_MARKER) && c.body && isApplicationComment(c.body));
|
|
246
|
+
|
|
247
|
+
const byUser = new Map<string, typeof apps[0]>();
|
|
248
|
+
apps.forEach((c) => { if (c.user?.login) byUser.set(c.user.login, c); });
|
|
249
|
+
const unique = Array.from(byUser.values());
|
|
250
|
+
console.log(` ${unique.length} unique applicant(s)\n`);
|
|
251
|
+
|
|
252
|
+
// Score all
|
|
253
|
+
const allScored: ScoredApplicant[] = [];
|
|
254
|
+
for (const c of unique) {
|
|
255
|
+
if (c.user?.login === trigger.user) { allScored.push(scored); continue; }
|
|
256
|
+
console.log(` 📊 Scoring @${c.user?.login}...`);
|
|
257
|
+
const p = await fetchProfile(c.user!.login, c.body || '');
|
|
258
|
+
allScored.push(scoreApplicant(p, labels));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Post individual review
|
|
262
|
+
const review = buildReviewComment(scored, unique.length);
|
|
263
|
+
const existingReview = allComments.find((c) => c.body?.includes(BOT_MARKER) && c.body?.includes(`@${trigger.user}`));
|
|
264
|
+
if (existingReview) {
|
|
265
|
+
await octokit.issues.updateComment({ owner: OWNER, repo: REPO, comment_id: existingReview.id, body: review });
|
|
266
|
+
} else {
|
|
267
|
+
await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: ISSUE_NUMBER, body: review });
|
|
268
|
+
}
|
|
269
|
+
console.log(`✅ Review posted for @${trigger.user}`);
|
|
270
|
+
|
|
271
|
+
// Post comparison if multiple
|
|
272
|
+
if (allScored.length >= 2) {
|
|
273
|
+
const comparison = buildComparisonComment(allScored);
|
|
274
|
+
const COMP_MARKER = '## 🤖 Contributor Agent — Applicant Comparison';
|
|
275
|
+
const existingComp = allComments.find((c) => c.body?.includes(COMP_MARKER));
|
|
276
|
+
if (existingComp) {
|
|
277
|
+
await octokit.issues.updateComment({ owner: OWNER, repo: REPO, comment_id: existingComp.id, body: comparison });
|
|
278
|
+
} else {
|
|
279
|
+
await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: ISSUE_NUMBER, body: comparison });
|
|
280
|
+
}
|
|
281
|
+
console.log(`✅ Comparison posted (${allScored.length} candidates)\n`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
|