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.
Files changed (47) hide show
  1. package/.gitlab/duo/chat-rules.md +40 -0
  2. package/.gitlab/duo/mr-review-instructions.md +44 -0
  3. package/.gitlab-ci.yml +136 -0
  4. package/README.md +585 -57
  5. package/action.yml +21 -2
  6. package/dist/applicant-scorer.js +27 -105
  7. package/dist/cli.js +1045 -34
  8. package/dist/commands/apply-for-issue.js +396 -0
  9. package/dist/commands/bounty-hunter.js +441 -0
  10. package/dist/commands/contribute.js +245 -51
  11. package/dist/commands/drips.js +351 -0
  12. package/dist/commands/gitlab-issues.js +87 -0
  13. package/dist/commands/gitlab-mrs.js +163 -0
  14. package/dist/commands/gitlab-pipelines.js +95 -0
  15. package/dist/commands/prs.js +3 -3
  16. package/dist/core/github.js +24 -0
  17. package/dist/core/gitlab.js +233 -0
  18. package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
  19. package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
  20. package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
  21. package/dist/gitlab-agents/mr-review-agent.js +200 -0
  22. package/dist/gitlab-agents/reminder-agent.js +164 -0
  23. package/dist/grade-assignment.js +262 -0
  24. package/dist/remind-contributors.js +127 -0
  25. package/dist/review-and-merge.js +125 -0
  26. package/examples/gitpadi.yml +152 -0
  27. package/package.json +20 -4
  28. package/src/applicant-scorer.ts +33 -141
  29. package/src/cli.ts +1078 -34
  30. package/src/commands/apply-for-issue.ts +452 -0
  31. package/src/commands/bounty-hunter.ts +529 -0
  32. package/src/commands/contribute.ts +264 -50
  33. package/src/commands/drips.ts +408 -0
  34. package/src/commands/gitlab-issues.ts +87 -0
  35. package/src/commands/gitlab-mrs.ts +185 -0
  36. package/src/commands/gitlab-pipelines.ts +104 -0
  37. package/src/commands/prs.ts +3 -3
  38. package/src/core/github.ts +24 -0
  39. package/src/core/gitlab.ts +397 -0
  40. package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
  41. package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
  42. package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
  43. package/src/gitlab-agents/mr-review-agent.ts +231 -0
  44. package/src/gitlab-agents/reminder-agent.ts +203 -0
  45. package/src/grade-assignment.ts +283 -0
  46. package/src/remind-contributors.ts +159 -0
  47. 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.0.7",
4
- "description": "GitPadi — Your AI-powered GitHub management CLI. Create repos, manage issues & PRs, score contributors, and automate everything.",
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
+ }
@@ -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 { Octokit } from '@octokit/rest';
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
- 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
- }
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); });