gitpadi 2.0.7 → 2.1.2
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/.gitlab/duo/chat-rules.md +40 -0
- package/.gitlab/duo/mr-review-instructions.md +44 -0
- package/.gitlab-ci.yml +136 -0
- package/README.md +585 -57
- package/action.yml +21 -2
- package/dist/applicant-scorer.js +27 -105
- package/dist/cli.js +1045 -34
- package/dist/commands/apply-for-issue.js +396 -0
- package/dist/commands/bounty-hunter.js +441 -0
- package/dist/commands/contribute.js +245 -51
- package/dist/commands/drips.js +351 -0
- package/dist/commands/gitlab-issues.js +87 -0
- package/dist/commands/gitlab-mrs.js +163 -0
- package/dist/commands/gitlab-pipelines.js +95 -0
- package/dist/commands/prs.js +3 -3
- package/dist/core/github.js +24 -0
- package/dist/core/gitlab.js +233 -0
- package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
- package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
- package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
- package/dist/gitlab-agents/mr-review-agent.js +200 -0
- package/dist/gitlab-agents/reminder-agent.js +164 -0
- package/dist/grade-assignment.js +262 -0
- package/dist/remind-contributors.js +127 -0
- package/dist/review-and-merge.js +125 -0
- package/examples/gitpadi.yml +152 -0
- package/package.json +20 -4
- package/src/applicant-scorer.ts +33 -141
- package/src/cli.ts +1078 -34
- package/src/commands/apply-for-issue.ts +452 -0
- package/src/commands/bounty-hunter.ts +529 -0
- package/src/commands/contribute.ts +264 -50
- package/src/commands/drips.ts +408 -0
- package/src/commands/gitlab-issues.ts +87 -0
- package/src/commands/gitlab-mrs.ts +185 -0
- package/src/commands/gitlab-pipelines.ts +104 -0
- package/src/commands/prs.ts +3 -3
- package/src/core/github.ts +24 -0
- package/src/core/gitlab.ts +397 -0
- package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
- package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
- package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
- package/src/gitlab-agents/mr-review-agent.ts +231 -0
- package/src/gitlab-agents/reminder-agent.ts +203 -0
- package/src/grade-assignment.ts +283 -0
- package/src/remind-contributors.ts +159 -0
- package/src/review-and-merge.ts +143 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// review-and-merge.ts — Reviews a PR, checks CI, and auto-merges if all pass
|
|
3
|
+
//
|
|
4
|
+
// Usage via Action: action: review-and-merge
|
|
5
|
+
// Usage via CLI: npx tsx src/review-and-merge.ts
|
|
6
|
+
import { Octokit } from '@octokit/rest';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
const TOKEN = process.env.GITHUB_TOKEN || '';
|
|
9
|
+
const OWNER = process.env.GITHUB_OWNER || '';
|
|
10
|
+
const REPO = process.env.GITHUB_REPO || '';
|
|
11
|
+
const PR_NUMBER = parseInt(process.env.PR_NUMBER || '0');
|
|
12
|
+
const AUTO_MERGE = process.env.AUTO_MERGE !== 'false'; // default: true
|
|
13
|
+
const octokit = new Octokit({ auth: TOKEN });
|
|
14
|
+
async function main() {
|
|
15
|
+
if (!PR_NUMBER) {
|
|
16
|
+
console.error('❌ PR_NUMBER is required');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
console.log(`\n🤖 GitPadi Review & Merge — ${OWNER}/${REPO} #${PR_NUMBER}\n`);
|
|
20
|
+
const { data: pr } = await octokit.pulls.get({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
|
|
21
|
+
const checks = [];
|
|
22
|
+
// ── 1. PR Size Check ──────────────────────────────────────────────
|
|
23
|
+
const { data: files } = await octokit.pulls.listFiles({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
|
|
24
|
+
const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
|
|
25
|
+
if (totalChanges > 1000) {
|
|
26
|
+
checks.push({ name: 'PR Size', status: '❌', detail: `${totalChanges} lines. Consider splitting.` });
|
|
27
|
+
}
|
|
28
|
+
else if (totalChanges > 500) {
|
|
29
|
+
checks.push({ name: 'PR Size', status: '⚠️', detail: `${totalChanges} lines. Large PR.` });
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
checks.push({ name: 'PR Size', status: '✅', detail: `${totalChanges} lines changed.` });
|
|
33
|
+
}
|
|
34
|
+
// ── 2. Linked Issues ──────────────────────────────────────────────
|
|
35
|
+
const hasIssueRef = /(?:fixes|closes|resolves)\s+#\d+/i.test(pr.body || '');
|
|
36
|
+
checks.push({
|
|
37
|
+
name: 'Linked Issues',
|
|
38
|
+
status: hasIssueRef ? '✅' : '⚠️',
|
|
39
|
+
detail: hasIssueRef ? 'Issue reference found' : 'No issue reference (Fixes #N)',
|
|
40
|
+
});
|
|
41
|
+
// ── 3. Test Files ─────────────────────────────────────────────────
|
|
42
|
+
const srcFiles = files.filter(f => !f.filename.includes('test') && !f.filename.includes('spec') && (f.filename.endsWith('.ts') || f.filename.endsWith('.rs') || f.filename.endsWith('.js')));
|
|
43
|
+
const testFiles = files.filter(f => f.filename.includes('test') || f.filename.includes('spec'));
|
|
44
|
+
if (srcFiles.length > 0 && testFiles.length === 0) {
|
|
45
|
+
checks.push({ name: 'Test Coverage', status: '⚠️', detail: 'No test files changed.' });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
checks.push({ name: 'Test Coverage', status: '✅', detail: `${testFiles.length} test file(s) included.` });
|
|
49
|
+
}
|
|
50
|
+
// ── 4. CI Status ──────────────────────────────────────────────────
|
|
51
|
+
const sha = pr.head.sha;
|
|
52
|
+
const { data: ciChecks } = await octokit.checks.listForRef({ owner: OWNER, repo: REPO, ref: sha });
|
|
53
|
+
const { data: statuses } = await octokit.repos.getCombinedStatusForRef({ owner: OWNER, repo: REPO, ref: sha });
|
|
54
|
+
const ciPassed = statuses.state === 'success' || (ciChecks.total_count > 0 && ciChecks.check_runs.every(c => c.conclusion === 'success'));
|
|
55
|
+
const ciPending = statuses.state === 'pending' || ciChecks.check_runs.some(c => c.status !== 'completed');
|
|
56
|
+
const ciFailed = statuses.state === 'failure' || ciChecks.check_runs.some(c => c.conclusion === 'failure');
|
|
57
|
+
if (ciFailed) {
|
|
58
|
+
const failedNames = ciChecks.check_runs.filter(c => c.conclusion === 'failure').map(c => c.name).join(', ');
|
|
59
|
+
checks.push({ name: 'CI Status', status: '❌', detail: `Failed: ${failedNames || 'status checks'}` });
|
|
60
|
+
}
|
|
61
|
+
else if (ciPending) {
|
|
62
|
+
checks.push({ name: 'CI Status', status: '⚠️', detail: 'CI is still running...' });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
checks.push({ name: 'CI Status', status: '✅', detail: 'All checks passed.' });
|
|
66
|
+
}
|
|
67
|
+
// ── Build Review Comment ──────────────────────────────────────────
|
|
68
|
+
const hasCritical = checks.some(c => c.status === '❌');
|
|
69
|
+
const hasWarning = checks.some(c => c.status === '⚠️');
|
|
70
|
+
const headerEmoji = hasCritical ? '🚨' : hasWarning ? '⚠️' : '✅';
|
|
71
|
+
let body = `## ${headerEmoji} GitPadi — Automated PR Review\n\n`;
|
|
72
|
+
body += `| Check | Status | Details |\n|-------|--------|--------|\n`;
|
|
73
|
+
checks.forEach(c => { body += `| ${c.name} | ${c.status} | ${c.detail} |\n`; });
|
|
74
|
+
body += `\n`;
|
|
75
|
+
if (ciFailed) {
|
|
76
|
+
body += `> 🚨 **CI Failed.** Please fix the failing checks and push again. Use \`gitpadi\` → \`Fix & Re-push\` for a guided workflow.\n`;
|
|
77
|
+
}
|
|
78
|
+
else if (hasCritical) {
|
|
79
|
+
body += `> 🚨 **Action Required:** Critical issues found. Please address before merging.\n`;
|
|
80
|
+
}
|
|
81
|
+
else if (ciPassed && !hasCritical) {
|
|
82
|
+
body += `> ✅ **All checks passed.** ${AUTO_MERGE ? 'Auto-merging...' : 'Ready for manual merge.'}\n`;
|
|
83
|
+
}
|
|
84
|
+
body += `\n---\n_Review by [GitPadi](https://github.com/Netwalls/contributor-agent) 🤖_`;
|
|
85
|
+
// Post the review comment
|
|
86
|
+
await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, body });
|
|
87
|
+
console.log('✅ Posted review comment');
|
|
88
|
+
// ── Auto-Merge Logic ──────────────────────────────────────────────
|
|
89
|
+
if (ciPassed && !hasCritical && AUTO_MERGE) {
|
|
90
|
+
console.log('🔀 CI passed. Attempting auto-merge...');
|
|
91
|
+
try {
|
|
92
|
+
await octokit.pulls.merge({
|
|
93
|
+
owner: OWNER, repo: REPO, pull_number: PR_NUMBER,
|
|
94
|
+
merge_method: 'squash',
|
|
95
|
+
commit_title: `${pr.title} (#${PR_NUMBER})`,
|
|
96
|
+
});
|
|
97
|
+
console.log(chalk.green(`✅ PR #${PR_NUMBER} merged successfully!`));
|
|
98
|
+
// Close any linked issues (handles non-default-branch merges too)
|
|
99
|
+
const issueRefs = (pr.body || '').matchAll(/(?:fixes|closes|resolves)\s+#(\d+)/gi);
|
|
100
|
+
for (const match of issueRefs) {
|
|
101
|
+
const issueNumber = parseInt(match[1]);
|
|
102
|
+
try {
|
|
103
|
+
await octokit.issues.update({ owner: OWNER, repo: REPO, issue_number: issueNumber, state: 'closed' });
|
|
104
|
+
await octokit.issues.createComment({
|
|
105
|
+
owner: OWNER, repo: REPO, issue_number: issueNumber,
|
|
106
|
+
body: `✅ Closed by PR #${PR_NUMBER} — merged via GitPadi 🤖`,
|
|
107
|
+
});
|
|
108
|
+
console.log(`✅ Closed linked issue #${issueNumber}`);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
console.log(`⚠️ Could not close issue #${issueNumber}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
console.log(chalk.yellow(`⚠️ Auto-merge failed: ${e.message}`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (ciFailed) {
|
|
120
|
+
console.log(chalk.red(`❌ CI failed. Contributor notified to fix.`));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
checks.forEach(c => console.log(` ${c.status} ${c.name}: ${c.detail}`));
|
|
124
|
+
}
|
|
125
|
+
main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# GitPadi — Drop-in GitHub Automation
|
|
3
|
+
# Copy this file to .github/workflows/gitpadi.yml in your repo.
|
|
4
|
+
#
|
|
5
|
+
# What this does automatically:
|
|
6
|
+
# ✅ Reviews every PR (size, tests, security, linked issues)
|
|
7
|
+
# ✅ Scores contributors when they apply on issues + assigns the best one
|
|
8
|
+
# ✅ Sends escalating reminders for stale PRs (24h → 48h → 72h)
|
|
9
|
+
# ✅ Grades assignment submissions and auto-merges passing ones
|
|
10
|
+
# ✅ Closes linked issues when PRs are merged
|
|
11
|
+
#
|
|
12
|
+
# Optional manual triggers:
|
|
13
|
+
# - Bulk create issues from a JSON/MD file
|
|
14
|
+
# - Force review-and-merge any PR
|
|
15
|
+
#
|
|
16
|
+
# Setup:
|
|
17
|
+
# No secrets needed — uses the built-in GITHUB_TOKEN.
|
|
18
|
+
# For assignment grading, set GITPADI_ASSIGNMENT_MODE: true (see below).
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
name: GitPadi
|
|
22
|
+
|
|
23
|
+
on:
|
|
24
|
+
# Fires on every PR open, update, or reopen
|
|
25
|
+
pull_request:
|
|
26
|
+
types: [opened, synchronize, reopened]
|
|
27
|
+
|
|
28
|
+
# Fires when someone comments on an issue (contributor applications)
|
|
29
|
+
issue_comment:
|
|
30
|
+
types: [created]
|
|
31
|
+
|
|
32
|
+
# Fires daily at 9am UTC — sends reminders for stale PRs
|
|
33
|
+
schedule:
|
|
34
|
+
- cron: '0 9 * * *'
|
|
35
|
+
|
|
36
|
+
# Manual triggers — run from Actions tab
|
|
37
|
+
workflow_dispatch:
|
|
38
|
+
inputs:
|
|
39
|
+
action:
|
|
40
|
+
description: 'Action to run manually'
|
|
41
|
+
required: true
|
|
42
|
+
type: choice
|
|
43
|
+
options:
|
|
44
|
+
- review-and-merge
|
|
45
|
+
- create-issues
|
|
46
|
+
- remind-contributors
|
|
47
|
+
pr_number:
|
|
48
|
+
description: 'PR number (for review-and-merge)'
|
|
49
|
+
required: false
|
|
50
|
+
issues_file:
|
|
51
|
+
description: 'Path to issues file (for create-issues)'
|
|
52
|
+
required: false
|
|
53
|
+
default: 'issues.json'
|
|
54
|
+
|
|
55
|
+
permissions:
|
|
56
|
+
contents: write # needed to merge PRs
|
|
57
|
+
issues: write # needed to assign, close, comment on issues
|
|
58
|
+
pull-requests: write # needed to review and comment on PRs
|
|
59
|
+
|
|
60
|
+
jobs:
|
|
61
|
+
|
|
62
|
+
# ── Review every PR ──────────────────────────────────────────────────────
|
|
63
|
+
review-pr:
|
|
64
|
+
name: Review PR
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
if: github.event_name == 'pull_request'
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
- uses: Netwalls/contributor-agent@main
|
|
70
|
+
with:
|
|
71
|
+
action: review-pr
|
|
72
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
73
|
+
|
|
74
|
+
# ── Grade assignment PRs (only if GITPADI_ASSIGNMENT_MODE is enabled) ───
|
|
75
|
+
# To enable: add GITPADI_ASSIGNMENT_MODE = true to your repo Variables
|
|
76
|
+
# (Settings → Secrets and variables → Actions → Variables)
|
|
77
|
+
grade-assignment:
|
|
78
|
+
name: Grade Assignment
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
if: |
|
|
81
|
+
github.event_name == 'pull_request' &&
|
|
82
|
+
vars.GITPADI_ASSIGNMENT_MODE == 'true'
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@v4
|
|
85
|
+
- uses: Netwalls/contributor-agent@main
|
|
86
|
+
with:
|
|
87
|
+
action: grade-assignment
|
|
88
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
89
|
+
pass-threshold: '40' # score out of 100 needed to auto-merge
|
|
90
|
+
auto-merge: 'true'
|
|
91
|
+
|
|
92
|
+
# ── Score contributors when they apply on issues ─────────────────────────
|
|
93
|
+
# Triggered when someone comments "I want to work on this" (or similar)
|
|
94
|
+
# on any issue. Scores applicant + auto-assigns the best candidate.
|
|
95
|
+
score-applicant:
|
|
96
|
+
name: Score Contributor
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
if: |
|
|
99
|
+
github.event_name == 'issue_comment' &&
|
|
100
|
+
github.event.issue.pull_request == null
|
|
101
|
+
steps:
|
|
102
|
+
- uses: actions/checkout@v4
|
|
103
|
+
- uses: Netwalls/contributor-agent@main
|
|
104
|
+
with:
|
|
105
|
+
action: score-applicant
|
|
106
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
107
|
+
# Uncomment to notify a maintainer on every new applicant:
|
|
108
|
+
# notify-user: 'your-github-username'
|
|
109
|
+
|
|
110
|
+
# ── Daily reminders for stale PRs ────────────────────────────────────────
|
|
111
|
+
remind-contributors:
|
|
112
|
+
name: Remind Contributors
|
|
113
|
+
runs-on: ubuntu-latest
|
|
114
|
+
if: github.event_name == 'schedule'
|
|
115
|
+
steps:
|
|
116
|
+
- uses: actions/checkout@v4
|
|
117
|
+
- uses: Netwalls/contributor-agent@main
|
|
118
|
+
with:
|
|
119
|
+
action: remind-contributors
|
|
120
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
121
|
+
|
|
122
|
+
# ── Manual: review + merge a specific PR ─────────────────────────────────
|
|
123
|
+
review-and-merge:
|
|
124
|
+
name: Review & Merge PR
|
|
125
|
+
runs-on: ubuntu-latest
|
|
126
|
+
if: |
|
|
127
|
+
github.event_name == 'workflow_dispatch' &&
|
|
128
|
+
github.event.inputs.action == 'review-and-merge'
|
|
129
|
+
env:
|
|
130
|
+
PR_NUMBER: ${{ github.event.inputs.pr_number }}
|
|
131
|
+
steps:
|
|
132
|
+
- uses: actions/checkout@v4
|
|
133
|
+
- uses: Netwalls/contributor-agent@main
|
|
134
|
+
with:
|
|
135
|
+
action: review-and-merge
|
|
136
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
137
|
+
auto-merge: 'true'
|
|
138
|
+
|
|
139
|
+
# ── Manual: bulk create issues from a file ───────────────────────────────
|
|
140
|
+
create-issues:
|
|
141
|
+
name: Create Issues
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
if: |
|
|
144
|
+
github.event_name == 'workflow_dispatch' &&
|
|
145
|
+
github.event.inputs.action == 'create-issues'
|
|
146
|
+
steps:
|
|
147
|
+
- uses: actions/checkout@v4
|
|
148
|
+
- uses: Netwalls/contributor-agent@main
|
|
149
|
+
with:
|
|
150
|
+
action: create-issues
|
|
151
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
152
|
+
issues-file: ${{ github.event.inputs.issues_file }}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitpadi",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "GitPadi —
|
|
3
|
+
"version": "2.1.2",
|
|
4
|
+
"description": "GitPadi — AI-powered GitHub & GitLab management CLI. Fork repos, manage issues & PRs, score contributors, grade assignments, and automate everything. Powered by Anthropic Claude via GitLab Duo Agent Platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"gitpadi": "./dist/cli.js"
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"dist/",
|
|
11
11
|
"src/",
|
|
12
12
|
"action.yml",
|
|
13
|
+
".gitlab-ci.yml",
|
|
14
|
+
".gitlab/",
|
|
13
15
|
"examples/",
|
|
14
16
|
"README.md",
|
|
15
17
|
"LICENSE"
|
|
@@ -21,21 +23,35 @@
|
|
|
21
23
|
"dev": "tsx watch src/cli.ts",
|
|
22
24
|
"create-issues": "tsx src/create-issues.ts",
|
|
23
25
|
"review-pr": "tsx src/pr-review.ts",
|
|
24
|
-
"score-applicant": "tsx src/applicant-scorer.ts"
|
|
26
|
+
"score-applicant": "tsx src/applicant-scorer.ts",
|
|
27
|
+
"grade-assignment": "tsx src/grade-assignment.ts",
|
|
28
|
+
"gl:score": "tsx src/gitlab-agents/contributor-scoring-agent.ts",
|
|
29
|
+
"gl:review": "tsx src/gitlab-agents/mr-review-agent.ts",
|
|
30
|
+
"gl:recover": "tsx src/gitlab-agents/ci-recovery-agent.ts",
|
|
31
|
+
"gl:remind": "tsx src/gitlab-agents/reminder-agent.ts",
|
|
32
|
+
"gl:grade": "tsx src/gitlab-agents/grade-assignment-agent.ts"
|
|
25
33
|
},
|
|
26
34
|
"keywords": [
|
|
27
35
|
"github",
|
|
36
|
+
"gitlab",
|
|
28
37
|
"cli",
|
|
29
38
|
"github-action",
|
|
39
|
+
"gitlab-ci",
|
|
30
40
|
"issue-management",
|
|
31
41
|
"pr-review",
|
|
42
|
+
"merge-request",
|
|
32
43
|
"contributor-scoring",
|
|
33
44
|
"automation",
|
|
45
|
+
"ai-agent",
|
|
46
|
+
"anthropic",
|
|
47
|
+
"claude",
|
|
48
|
+
"gitlab-duo",
|
|
34
49
|
"gitpadi"
|
|
35
50
|
],
|
|
36
51
|
"author": "Netwalls",
|
|
37
52
|
"license": "MIT",
|
|
38
53
|
"dependencies": {
|
|
54
|
+
"@anthropic-ai/sdk": "^0.40.1",
|
|
39
55
|
"@octokit/rest": "^21.1.1",
|
|
40
56
|
"boxen": "^8.0.1",
|
|
41
57
|
"chalk": "^5.4.1",
|
|
@@ -52,4 +68,4 @@
|
|
|
52
68
|
"@types/node": "^22.10.5",
|
|
53
69
|
"typescript": "^5.9.3"
|
|
54
70
|
}
|
|
55
|
-
}
|
|
71
|
+
}
|
package/src/applicant-scorer.ts
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
// Usage via Action: action: score-applicant
|
|
7
7
|
// Usage (CLI): GITHUB_TOKEN=xxx ISSUE_NUMBER=5 COMMENT_ID=123 npx tsx src/applicant-scorer.ts
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { initGitHub, getOctokit } from './core/github.js';
|
|
10
|
+
import {
|
|
11
|
+
isApplicationComment,
|
|
12
|
+
fetchProfile,
|
|
13
|
+
scoreApplicant,
|
|
14
|
+
TIER_EMOJI,
|
|
15
|
+
type ScoredApplicant,
|
|
16
|
+
} from './core/scorer.js';
|
|
10
17
|
|
|
11
18
|
// ── Config ─────────────────────────────────────────────────────────────
|
|
12
19
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN!;
|
|
@@ -18,148 +25,10 @@ const NOTIFY_USER = process.env.NOTIFY_USER || '';
|
|
|
18
25
|
|
|
19
26
|
if (!GITHUB_TOKEN) { console.error('❌ GITHUB_TOKEN required'); process.exit(1); }
|
|
20
27
|
|
|
21
|
-
|
|
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
|
-
}
|
|
28
|
+
// Initialise shared GitHub client so core/scorer.ts functions work
|
|
29
|
+
initGitHub(GITHUB_TOKEN, OWNER, REPO);
|
|
159
30
|
|
|
160
31
|
// ── Build Comments ─────────────────────────────────────────────────────
|
|
161
|
-
const TIER_EMOJI: Record<string, string> = { S: '🏆', A: '🟢', B: '🟡', C: '🟠', D: '🔴' };
|
|
162
|
-
|
|
163
32
|
function buildReviewComment(s: ScoredApplicant, count: number): string {
|
|
164
33
|
let c = `## 🤖 Contributor Agent — Applicant Review\n\n`;
|
|
165
34
|
c += `**Applicant:** @${s.username} ${TIER_EMOJI[s.tier]} **Tier ${s.tier}** (${s.score}/100)\n\n`;
|
|
@@ -214,6 +83,8 @@ async function main(): Promise<void> {
|
|
|
214
83
|
console.log(`\n🤖 Applicant Scorer — ${OWNER}/${REPO} #${ISSUE_NUMBER}\n`);
|
|
215
84
|
if (!ISSUE_NUMBER) { console.error('❌ ISSUE_NUMBER required'); process.exit(1); }
|
|
216
85
|
|
|
86
|
+
const octokit = getOctokit();
|
|
87
|
+
|
|
217
88
|
// Get trigger comment
|
|
218
89
|
let trigger: { body: string; user: string };
|
|
219
90
|
if (COMMENT_ID) {
|
|
@@ -280,6 +151,27 @@ async function main(): Promise<void> {
|
|
|
280
151
|
}
|
|
281
152
|
console.log(`✅ Comparison posted (${allScored.length} candidates)\n`);
|
|
282
153
|
}
|
|
154
|
+
|
|
155
|
+
// Auto-assign the top-scoring S or A tier candidate
|
|
156
|
+
const sorted = [...allScored].sort((a, b) => b.score - a.score);
|
|
157
|
+
const top = sorted[0];
|
|
158
|
+
if (top && (top.tier === 'S' || top.tier === 'A')) {
|
|
159
|
+
// Only assign if the issue has no assignees yet
|
|
160
|
+
const currentAssignees = issue.assignees?.map((a) => a.login) || [];
|
|
161
|
+
if (currentAssignees.length === 0) {
|
|
162
|
+
await octokit.issues.addAssignees({
|
|
163
|
+
owner: OWNER,
|
|
164
|
+
repo: REPO,
|
|
165
|
+
issue_number: ISSUE_NUMBER,
|
|
166
|
+
assignees: [top.username],
|
|
167
|
+
});
|
|
168
|
+
console.log(`✅ Auto-assigned @${top.username} to issue #${ISSUE_NUMBER} (Tier ${top.tier}, ${top.score}/100)`);
|
|
169
|
+
} else {
|
|
170
|
+
console.log(`ℹ️ Issue already assigned to: ${currentAssignees.join(', ')} — skipping auto-assign`);
|
|
171
|
+
}
|
|
172
|
+
} else if (top) {
|
|
173
|
+
console.log(`ℹ️ Top candidate @${top.username} is Tier ${top.tier} (${top.score}/100) — below auto-assign threshold (S/A)`);
|
|
174
|
+
}
|
|
283
175
|
}
|
|
284
176
|
|
|
285
177
|
main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
|