gitpadi 2.0.6 ā 2.1.1
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 +1082 -36
- 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/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 +28 -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 +1119 -35
- 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/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 +29 -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,127 @@
|
|
|
1
|
+
import { initGitHub, getOctokit, getOwner, getRepo } from './core/github.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
// Tiered Escalation Thresholds (in hours)
|
|
4
|
+
const TIER_1_HOURS = 24; // Gentle reminder: "Please create a draft PR"
|
|
5
|
+
const TIER_2_HOURS = 48; // Warning: "Will be unassigned in 24h"
|
|
6
|
+
const TIER_3_HOURS = 72; // Auto-unassign
|
|
7
|
+
// Signature markers for each tier (prevents duplicate comments)
|
|
8
|
+
const SIG_TIER_1 = '<!-- gitpadi-reminder-24h -->';
|
|
9
|
+
const SIG_TIER_2 = '<!-- gitpadi-reminder-48h -->';
|
|
10
|
+
const SIG_TIER_3 = '<!-- gitpadi-unassigned -->';
|
|
11
|
+
async function run() {
|
|
12
|
+
console.log(chalk.bold('\nš GitPadi Escalating Reminder Engine\n'));
|
|
13
|
+
try {
|
|
14
|
+
initGitHub();
|
|
15
|
+
const octokit = getOctokit();
|
|
16
|
+
const owner = getOwner();
|
|
17
|
+
const repo = getRepo();
|
|
18
|
+
if (!owner || !repo) {
|
|
19
|
+
console.error(chalk.red('ā Owner or Repo not found in environment/config.'));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
console.log(chalk.dim(`š Scanning ${owner}/${repo} for inactive contributors...\n`));
|
|
23
|
+
// 1. Fetch all open issues
|
|
24
|
+
const { data: issues } = await octokit.issues.listForRepo({
|
|
25
|
+
owner, repo, state: 'open', per_page: 100
|
|
26
|
+
});
|
|
27
|
+
const issuesWithAssignees = issues.filter(i => i.assignees && i.assignees.length > 0 && !i.pull_request);
|
|
28
|
+
if (issuesWithAssignees.length === 0) {
|
|
29
|
+
console.log(chalk.green('ā
No open issues with assignees found.'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const now = new Date();
|
|
33
|
+
let remindCount = 0, warnCount = 0, unassignCount = 0;
|
|
34
|
+
for (const issue of issuesWithAssignees) {
|
|
35
|
+
const issueNumber = issue.number;
|
|
36
|
+
console.log(chalk.cyan(` āø Issue #${issueNumber}: "${issue.title.substring(0, 50)}..."`));
|
|
37
|
+
// 2. Check for existing reminder comments
|
|
38
|
+
const { data: comments } = await octokit.issues.listComments({
|
|
39
|
+
owner, repo, issue_number: issueNumber
|
|
40
|
+
});
|
|
41
|
+
const hasTier1 = comments.some(c => c.body?.includes(SIG_TIER_1));
|
|
42
|
+
const hasTier2 = comments.some(c => c.body?.includes(SIG_TIER_2));
|
|
43
|
+
const hasTier3 = comments.some(c => c.body?.includes(SIG_TIER_3));
|
|
44
|
+
if (hasTier3) {
|
|
45
|
+
console.log(chalk.dim(` ā© Already processed (unassigned). Skipping.`));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// 3. Check for linked draft/open PRs
|
|
49
|
+
const { data: prs } = await octokit.pulls.list({
|
|
50
|
+
owner, repo, state: 'open', per_page: 100
|
|
51
|
+
});
|
|
52
|
+
const linkedPR = prs.find(pr => pr.body?.includes(`#${issueNumber}`) ||
|
|
53
|
+
pr.head.ref.includes(`issue-${issueNumber}`));
|
|
54
|
+
if (linkedPR) {
|
|
55
|
+
console.log(chalk.green(` ā
PR found: #${linkedPR.number} (${linkedPR.draft ? 'draft' : 'open'}). Skipping.`));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// 4. Find when the user was assigned
|
|
59
|
+
const { data: events } = await octokit.issues.listEvents({
|
|
60
|
+
owner, repo, issue_number: issueNumber
|
|
61
|
+
});
|
|
62
|
+
const assignmentEvent = [...events].reverse().find(e => e.event === 'assigned');
|
|
63
|
+
if (!assignmentEvent) {
|
|
64
|
+
console.log(chalk.dim(` ā No assignment event found. Skipping.`));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const assignedAt = new Date(assignmentEvent.created_at);
|
|
68
|
+
const diffHours = Math.floor((now.getTime() - assignedAt.getTime()) / (1000 * 60 * 60));
|
|
69
|
+
const assignee = assignmentEvent.assignee?.login;
|
|
70
|
+
console.log(chalk.dim(` š Assigned ${diffHours}h ago (@${assignee})`));
|
|
71
|
+
// 5. Tiered escalation
|
|
72
|
+
if (diffHours >= TIER_3_HOURS && !hasTier3) {
|
|
73
|
+
// āā TIER 3: Auto-unassign āā
|
|
74
|
+
const message = `ā° Hi @${assignee}, it's been **${diffHours} hours** since you were assigned to this issue, and no draft PR has been created.
|
|
75
|
+
|
|
76
|
+
To keep the project moving, we're **unassigning you** from this issue. You're welcome to re-claim it anytime!
|
|
77
|
+
|
|
78
|
+
${SIG_TIER_3}`;
|
|
79
|
+
await octokit.issues.createComment({
|
|
80
|
+
owner, repo, issue_number: issueNumber, body: message
|
|
81
|
+
});
|
|
82
|
+
await octokit.issues.removeAssignees({
|
|
83
|
+
owner, repo, issue_number: issueNumber,
|
|
84
|
+
assignees: [assignee]
|
|
85
|
+
});
|
|
86
|
+
console.log(chalk.red(` š“ TIER 3: @${assignee} unassigned after ${diffHours}h.`));
|
|
87
|
+
unassignCount++;
|
|
88
|
+
}
|
|
89
|
+
else if (diffHours >= TIER_2_HOURS && !hasTier2) {
|
|
90
|
+
// āā TIER 2: Warning āā
|
|
91
|
+
const message = `ā ļø Hi @${assignee}, it's been **${diffHours} hours** since you were assigned to this issue.
|
|
92
|
+
|
|
93
|
+
We haven't seen a draft PR yet. Please create one to confirm you're actively working on this ā otherwise, you may be **unassigned in 24 hours** to keep things moving.
|
|
94
|
+
|
|
95
|
+
${SIG_TIER_2}`;
|
|
96
|
+
await octokit.issues.createComment({
|
|
97
|
+
owner, repo, issue_number: issueNumber, body: message
|
|
98
|
+
});
|
|
99
|
+
console.log(chalk.yellow(` š” TIER 2: Warning sent to @${assignee} (${diffHours}h).`));
|
|
100
|
+
warnCount++;
|
|
101
|
+
}
|
|
102
|
+
else if (diffHours >= TIER_1_HOURS && !hasTier1) {
|
|
103
|
+
// āā TIER 1: Gentle reminder āā
|
|
104
|
+
const message = `š Hi @${assignee}, you've been assigned to this issue for **${diffHours} hours**.
|
|
105
|
+
|
|
106
|
+
Just a friendly nudge ā please create a **draft PR** when you start working, so we know you're on it! š
|
|
107
|
+
|
|
108
|
+
${SIG_TIER_1}`;
|
|
109
|
+
await octokit.issues.createComment({
|
|
110
|
+
owner, repo, issue_number: issueNumber, body: message
|
|
111
|
+
});
|
|
112
|
+
console.log(chalk.green(` š¢ TIER 1: Gentle reminder sent to @${assignee} (${diffHours}h).`));
|
|
113
|
+
remindCount++;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.log(chalk.dim(` ā³ Within threshold (${diffHours}h). No action needed.`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
console.log(chalk.bold(`\n⨠Reminder sweep complete.`));
|
|
120
|
+
console.log(chalk.dim(` š Reminded: ${remindCount} | Warned: ${warnCount} | Unassigned: ${unassignCount}\n`));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
console.error(chalk.red(`\nā Error during reminder sweep: ${error.message}`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
run();
|
|
@@ -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.1",
|
|
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); });
|