gitpadi 2.0.0 → 2.0.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/README.md +4 -0
- package/dist/applicant-scorer.js +256 -0
- package/dist/cli.js +789 -0
- package/dist/commands/contribute.js +291 -0
- package/dist/commands/contributors.js +101 -0
- package/dist/commands/issues.js +319 -0
- package/dist/commands/prs.js +229 -0
- package/dist/commands/releases.js +53 -0
- package/dist/commands/repos.js +128 -0
- package/dist/core/github.js +106 -0
- package/dist/core/scorer.js +95 -0
- package/dist/create-issues.js +179 -0
- package/dist/pr-review.js +117 -0
- package/package.json +6 -3
- package/src/applicant-scorer.ts +1 -1
- package/src/cli.ts +345 -170
- package/src/commands/contribute.ts +331 -0
- package/src/commands/contributors.ts +1 -1
- package/src/commands/issues.ts +76 -9
- package/src/commands/prs.ts +1 -1
- package/src/commands/releases.ts +1 -1
- package/src/commands/repos.ts +41 -26
- package/src/core/github.ts +99 -15
- package/src/create-issues.ts +1 -1
- package/src/pr-review.ts +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// commands/repos.ts — Repository management commands for GitPadi
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import Table from 'cli-table3';
|
|
5
|
+
import { getOctokit, getOwner } from '../core/github.js';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
export async function createRepo(name, opts) {
|
|
8
|
+
const spinner = ora(`Creating repo ${chalk.cyan(name)}...`).start();
|
|
9
|
+
const octokit = getOctokit();
|
|
10
|
+
try {
|
|
11
|
+
let data;
|
|
12
|
+
if (opts.org) {
|
|
13
|
+
({ data } = await octokit.repos.createInOrg({
|
|
14
|
+
org: opts.org, name,
|
|
15
|
+
description: opts.description || '',
|
|
16
|
+
private: opts.private || false,
|
|
17
|
+
has_issues: true, has_wiki: false, auto_init: true,
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
({ data } = await octokit.repos.createForAuthenticatedUser({
|
|
22
|
+
name,
|
|
23
|
+
description: opts.description || '',
|
|
24
|
+
private: opts.private || false,
|
|
25
|
+
has_issues: true, has_wiki: false, auto_init: true,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
spinner.succeed(`Created ${chalk.green(data.full_name)} ${data.private ? chalk.dim('(private)') : chalk.dim('(public)')}`);
|
|
29
|
+
console.log(chalk.dim(` → ${data.html_url}\n`));
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
spinner.fail(e.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function deleteRepo(name, opts) {
|
|
36
|
+
const owner = opts.org || getOwner();
|
|
37
|
+
const spinner = ora(`Deleting ${chalk.red(`${owner}/${name}`)}...`).start();
|
|
38
|
+
try {
|
|
39
|
+
await getOctokit().repos.delete({ owner, repo: name });
|
|
40
|
+
spinner.succeed(`Deleted ${chalk.red(`${owner}/${name}`)}`);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
spinner.fail(e.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function cloneRepo(name, opts) {
|
|
47
|
+
const owner = opts.org || getOwner();
|
|
48
|
+
const url = `https://github.com/${owner}/${name}.git`;
|
|
49
|
+
const dir = opts.dir || name;
|
|
50
|
+
const spinner = ora(`Cloning ${chalk.cyan(`${owner}/${name}`)}...`).start();
|
|
51
|
+
try {
|
|
52
|
+
execSync(`git clone ${url} ${dir}`, { stdio: 'pipe' });
|
|
53
|
+
spinner.succeed(`Cloned to ${chalk.green(`./${dir}`)}`);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
spinner.fail(`Clone failed: ${e.message}`);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function repoInfo(name, opts) {
|
|
62
|
+
const owner = opts.org || getOwner();
|
|
63
|
+
const spinner = ora(`Fetching info for ${owner}/${name}...`).start();
|
|
64
|
+
try {
|
|
65
|
+
const { data: repo } = await getOctokit().repos.get({ owner, repo: name });
|
|
66
|
+
spinner.stop();
|
|
67
|
+
console.log(`\n${chalk.bold(`📦 ${repo.full_name}`)}\n`);
|
|
68
|
+
const table = new Table({ style: { head: [], border: [] } });
|
|
69
|
+
table.push({ [chalk.cyan('Description')]: repo.description || chalk.dim('none') }, { [chalk.cyan('Stars')]: `⭐ ${repo.stargazers_count}` }, { [chalk.cyan('Forks')]: `🍴 ${repo.forks_count}` }, { [chalk.cyan('Issues')]: `📋 ${repo.open_issues_count} open` }, { [chalk.cyan('Language')]: repo.language || chalk.dim('none') }, { [chalk.cyan('Visibility')]: repo.private ? chalk.yellow('Private') : chalk.green('Public') }, { [chalk.cyan('Default Branch')]: repo.default_branch }, { [chalk.cyan('Created')]: new Date(repo.created_at).toLocaleDateString() }, { [chalk.cyan('URL')]: repo.html_url });
|
|
70
|
+
console.log(table.toString());
|
|
71
|
+
console.log('');
|
|
72
|
+
return repo;
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
spinner.fail(e.message);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function setTopics(name, topics, opts) {
|
|
80
|
+
const owner = opts.org || getOwner();
|
|
81
|
+
const spinner = ora('Updating topics...').start();
|
|
82
|
+
try {
|
|
83
|
+
await getOctokit().repos.replaceAllTopics({ owner, repo: name, names: topics });
|
|
84
|
+
spinner.succeed(`Topics set: ${chalk.cyan(topics.join(', '))}`);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
spinner.fail(e.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function listRepos(opts) {
|
|
91
|
+
const spinner = !opts.silent ? ora('Fetching repos...').start() : null;
|
|
92
|
+
const octokit = getOctokit();
|
|
93
|
+
try {
|
|
94
|
+
let repos;
|
|
95
|
+
if (opts.org) {
|
|
96
|
+
({ data: repos } = await octokit.repos.listForOrg({ org: opts.org, per_page: opts.limit || 100, sort: 'updated' }));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
({ data: repos } = await octokit.repos.listForAuthenticatedUser({ per_page: opts.limit || 100, sort: 'updated' }));
|
|
100
|
+
}
|
|
101
|
+
if (spinner)
|
|
102
|
+
spinner.stop();
|
|
103
|
+
if (!opts.silent) {
|
|
104
|
+
const table = new Table({
|
|
105
|
+
head: ['Name', 'Stars', 'Language', 'Visibility', 'Updated'].map((h) => chalk.cyan(h)),
|
|
106
|
+
style: { head: [], border: [] },
|
|
107
|
+
});
|
|
108
|
+
repos.forEach((r) => {
|
|
109
|
+
table.push([
|
|
110
|
+
r.full_name,
|
|
111
|
+
`⭐ ${r.stargazers_count}`,
|
|
112
|
+
r.language || '-',
|
|
113
|
+
r.private ? chalk.yellow('private') : chalk.green('public'),
|
|
114
|
+
new Date(r.updated_at).toLocaleDateString(),
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
console.log(`\n${chalk.bold('📦 Repositories')} (${repos.length})\n`);
|
|
118
|
+
console.log(table.toString());
|
|
119
|
+
console.log('');
|
|
120
|
+
}
|
|
121
|
+
return repos;
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
if (spinner)
|
|
125
|
+
spinner.fail(e.message);
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Octokit } from '@octokit/rest';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.gitpadi');
|
|
8
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
let _octokit = null;
|
|
10
|
+
let _owner = '';
|
|
11
|
+
let _repo = '';
|
|
12
|
+
let _token = '';
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function saveConfig(config) {
|
|
24
|
+
if (!fs.existsSync(CONFIG_DIR))
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Attempts to detect owner/repo from local git remotes
|
|
30
|
+
*/
|
|
31
|
+
export function detectLocalRepo() {
|
|
32
|
+
try {
|
|
33
|
+
const remotes = execSync('git remote -v', { encoding: 'utf-8', stdio: 'pipe' });
|
|
34
|
+
const match = remotes.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?/);
|
|
35
|
+
if (match) {
|
|
36
|
+
return { owner: match[1], repo: match[2] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Not a git repo or git not found
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export function initGitHub(token, owner, repo) {
|
|
45
|
+
const config = loadConfig();
|
|
46
|
+
const detected = detectLocalRepo();
|
|
47
|
+
_token = token || process.env.GITHUB_TOKEN || process.env.GITPADI_TOKEN || config?.token || '';
|
|
48
|
+
// Priority: Explicit > Env > Config > Local Git detection
|
|
49
|
+
_owner = owner || process.env.GITHUB_OWNER || config?.owner || detected?.owner || process.env.GITHUB_REPOSITORY?.split('/')[0] || '';
|
|
50
|
+
_repo = repo || process.env.GITHUB_REPO || config?.repo || detected?.repo || process.env.GITHUB_REPOSITORY?.split('/')[1] || '';
|
|
51
|
+
if (_token) {
|
|
52
|
+
_octokit = new Octokit({ auth: _token });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function getOctokit() {
|
|
56
|
+
if (!_octokit) {
|
|
57
|
+
throw new Error('GitHub client not initialized. Call initGitHub() first.');
|
|
58
|
+
}
|
|
59
|
+
return _octokit;
|
|
60
|
+
}
|
|
61
|
+
export function getOwner() { return _owner; }
|
|
62
|
+
export function getRepo() { return _repo; }
|
|
63
|
+
export function getToken() { return _token; }
|
|
64
|
+
export function setRepo(owner, repo) {
|
|
65
|
+
_owner = owner;
|
|
66
|
+
_repo = repo;
|
|
67
|
+
// If we have a token, PERSIST the new repo selection
|
|
68
|
+
if (_token) {
|
|
69
|
+
saveConfig({ token: _token, owner, repo });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function getFullRepo() { return `${_owner}/${_repo}`; }
|
|
73
|
+
export function requireRepo() {
|
|
74
|
+
if (!_owner || !_repo) {
|
|
75
|
+
console.error(chalk.red('\n❌ Repository not set.'));
|
|
76
|
+
console.error(chalk.dim(' Use: gitpadi --owner <org> --repo <name> <command>'));
|
|
77
|
+
console.error(chalk.dim(' Or set: GITHUB_OWNER and GITHUB_REPO env vars\n'));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* ── Phase 1: Contributor Support Helpers ──
|
|
83
|
+
*/
|
|
84
|
+
export async function getAuthenticatedUser() {
|
|
85
|
+
const { data } = await getOctokit().users.getAuthenticated();
|
|
86
|
+
return data.login;
|
|
87
|
+
}
|
|
88
|
+
export async function forkRepo(owner, repo) {
|
|
89
|
+
const octokit = getOctokit();
|
|
90
|
+
const { data } = await octokit.repos.createFork({ owner, repo });
|
|
91
|
+
return data.full_name; // e.g. "myuser/original-repo"
|
|
92
|
+
}
|
|
93
|
+
export async function getRepoDetails(owner, repo) {
|
|
94
|
+
const { data } = await getOctokit().repos.get({ owner, repo });
|
|
95
|
+
return data;
|
|
96
|
+
}
|
|
97
|
+
export async function getLatestCheckRuns(owner, repo, ref) {
|
|
98
|
+
const octokit = getOctokit();
|
|
99
|
+
const { data: checks } = await octokit.checks.listForRef({ owner, repo, ref });
|
|
100
|
+
const { data: status } = await octokit.repos.getCombinedStatusForRef({ owner, repo, ref });
|
|
101
|
+
return {
|
|
102
|
+
checkRuns: checks.check_runs,
|
|
103
|
+
combinedState: status.state, // 'success', 'failure', 'pending'
|
|
104
|
+
statuses: status.statuses
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// core/scorer.ts — Applicant scoring algorithm (extracted for reuse)
|
|
2
|
+
import { getOctokit, getOwner, getRepo } from './github.js';
|
|
3
|
+
export const TIER_EMOJI = { S: '🏆', A: '🟢', B: '🟡', C: '🟠', D: '🔴' };
|
|
4
|
+
export const APPLICATION_PATTERNS = [
|
|
5
|
+
/i('d| would) (like|love|want) to (work on|tackle|take|pick up|handle)/i,
|
|
6
|
+
/can i (work on|take|pick up|handle|be assigned)/i,
|
|
7
|
+
/assign (this |it )?(to )?me/i,
|
|
8
|
+
/i('m| am) interested/i,
|
|
9
|
+
/let me (work on|take|handle)/i,
|
|
10
|
+
/i('ll| will) (work on|take|handle|do)/i,
|
|
11
|
+
/i want to contribute/i,
|
|
12
|
+
/please assign/i,
|
|
13
|
+
/i can (do|handle|take care of|work on)/i,
|
|
14
|
+
/picking this up/i,
|
|
15
|
+
/claiming this/i,
|
|
16
|
+
];
|
|
17
|
+
export function isApplicationComment(body) {
|
|
18
|
+
return APPLICATION_PATTERNS.some((p) => p.test(body));
|
|
19
|
+
}
|
|
20
|
+
export async function fetchProfile(username, commentBody = '') {
|
|
21
|
+
const octokit = getOctokit();
|
|
22
|
+
const OWNER = getOwner();
|
|
23
|
+
const REPO = getRepo();
|
|
24
|
+
const { data: user } = await octokit.users.getByUsername({ username });
|
|
25
|
+
const accountAge = Math.floor((Date.now() - new Date(user.created_at).getTime()) / 86400000);
|
|
26
|
+
let totalContributions = 0;
|
|
27
|
+
try {
|
|
28
|
+
const { data: events } = await octokit.activity.listPublicEventsForUser({ username, per_page: 100 });
|
|
29
|
+
totalContributions = events.length;
|
|
30
|
+
}
|
|
31
|
+
catch { /* continue */ }
|
|
32
|
+
let prsMerged = 0, prsOpen = 0, issuesCreated = 0;
|
|
33
|
+
try {
|
|
34
|
+
prsMerged = (await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:pr author:${username} is:merged` })).data.total_count;
|
|
35
|
+
prsOpen = (await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:pr author:${username} is:open` })).data.total_count;
|
|
36
|
+
issuesCreated = (await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:issue author:${username}` })).data.total_count;
|
|
37
|
+
}
|
|
38
|
+
catch { /* rate limit */ }
|
|
39
|
+
let relevantLanguages = [];
|
|
40
|
+
try {
|
|
41
|
+
const { data: repos } = await octokit.repos.listForUser({ username, sort: 'updated', per_page: 20 });
|
|
42
|
+
const langs = new Set();
|
|
43
|
+
repos.forEach((r) => { if (r.language)
|
|
44
|
+
langs.add(r.language); });
|
|
45
|
+
relevantLanguages = Array.from(langs);
|
|
46
|
+
}
|
|
47
|
+
catch { /* continue */ }
|
|
48
|
+
let hasReadme = false;
|
|
49
|
+
try {
|
|
50
|
+
await octokit.repos.get({ owner: username, repo: username });
|
|
51
|
+
hasReadme = true;
|
|
52
|
+
}
|
|
53
|
+
catch { /* no readme */ }
|
|
54
|
+
return {
|
|
55
|
+
username, avatarUrl: user.avatar_url, accountAge,
|
|
56
|
+
publicRepos: user.public_repos, followers: user.followers,
|
|
57
|
+
totalContributions, repoContributions: prsMerged + prsOpen + issuesCreated,
|
|
58
|
+
relevantLanguages, hasReadme, prsMerged, prsOpen, issuesCreated, commentBody,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function scoreApplicant(p, issueLabels = []) {
|
|
62
|
+
const b = { accountMaturity: 0, repoExperience: 0, githubPresence: 0, activityLevel: 0, applicationQuality: 0, languageRelevance: 0, total: 0 };
|
|
63
|
+
b.accountMaturity = p.accountAge > 730 ? 15 : p.accountAge > 365 ? 12 : p.accountAge > 180 ? 9 : p.accountAge > 90 ? 6 : p.accountAge > 30 ? 3 : 1;
|
|
64
|
+
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);
|
|
65
|
+
b.githubPresence = Math.min(5, Math.floor(p.publicRepos / 5)) + Math.min(5, Math.floor(p.followers / 10)) + (p.hasReadme ? 5 : 0);
|
|
66
|
+
b.activityLevel = p.totalContributions >= 80 ? 15 : p.totalContributions >= 50 ? 12 : p.totalContributions >= 30 ? 9 : p.totalContributions >= 15 ? 6 : p.totalContributions >= 5 ? 3 : 1;
|
|
67
|
+
const words = p.commentBody.split(/\s+/).length;
|
|
68
|
+
const hasApproach = /approach|plan|implement|would|will|by|using|step/i.test(p.commentBody);
|
|
69
|
+
const hasExp = /experience|worked|built|familiar|know|background/i.test(p.commentBody);
|
|
70
|
+
b.applicationQuality = (words >= 50 && hasApproach && hasExp) ? 15 : (words >= 30 && (hasApproach || hasExp)) ? 12 : (words >= 20 && hasApproach) ? 9 : words >= 15 ? 6 : words >= 8 ? 3 : 1;
|
|
71
|
+
b.languageRelevance = 5;
|
|
72
|
+
if (issueLabels.length > 0 && p.relevantLanguages.length > 0) {
|
|
73
|
+
const labelLower = issueLabels.map((l) => l.toLowerCase()).join(' ');
|
|
74
|
+
const hasRust = p.relevantLanguages.includes('Rust');
|
|
75
|
+
const hasTS = p.relevantLanguages.includes('TypeScript') || p.relevantLanguages.includes('JavaScript');
|
|
76
|
+
const needsRust = /contract|rust|wasm|soroban/i.test(labelLower);
|
|
77
|
+
const needsTS = /backend|frontend|api|websocket|typescript/i.test(labelLower);
|
|
78
|
+
let matches = 0, needed = 0;
|
|
79
|
+
if (needsRust) {
|
|
80
|
+
needed++;
|
|
81
|
+
if (hasRust)
|
|
82
|
+
matches++;
|
|
83
|
+
}
|
|
84
|
+
if (needsTS) {
|
|
85
|
+
needed++;
|
|
86
|
+
if (hasTS)
|
|
87
|
+
matches++;
|
|
88
|
+
}
|
|
89
|
+
if (needed > 0)
|
|
90
|
+
b.languageRelevance = Math.round((matches / needed) * 10);
|
|
91
|
+
}
|
|
92
|
+
b.total = b.accountMaturity + b.repoExperience + b.githubPresence + b.activityLevel + b.applicationQuality + b.languageRelevance;
|
|
93
|
+
const tier = b.total >= 75 ? 'S' : b.total >= 55 ? 'A' : b.total >= 40 ? 'B' : b.total >= 25 ? 'C' : 'D';
|
|
94
|
+
return { ...p, score: b.total, breakdown: b, tier };
|
|
95
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-issues.ts — Generic issue creator from JSON data file
|
|
3
|
+
//
|
|
4
|
+
// Usage via GitHub Action:
|
|
5
|
+
// action: create-issues
|
|
6
|
+
// issues-file: path/to/issues.json
|
|
7
|
+
//
|
|
8
|
+
// Usage (CLI):
|
|
9
|
+
// GITHUB_TOKEN=xxx npx tsx src/create-issues.ts --file issues.json
|
|
10
|
+
// GITHUB_TOKEN=xxx npx tsx src/create-issues.ts --file issues.json --dry-run
|
|
11
|
+
// GITHUB_TOKEN=xxx npx tsx src/create-issues.ts --file issues.json --start 1 --end 10
|
|
12
|
+
import { Octokit } from '@octokit/rest';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
16
|
+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
|
17
|
+
const OWNER = process.env.GITHUB_OWNER || '';
|
|
18
|
+
const REPO = process.env.GITHUB_REPO || '';
|
|
19
|
+
// ── Parse CLI args ─────────────────────────────────────────────────────
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const dryRun = args.includes('--dry-run') || process.env.DRY_RUN === 'true';
|
|
22
|
+
const startIdx = args.includes('--start')
|
|
23
|
+
? parseInt(args[args.indexOf('--start') + 1])
|
|
24
|
+
: parseInt(process.env.ISSUE_START || '1');
|
|
25
|
+
const endIdx = args.includes('--end')
|
|
26
|
+
? parseInt(args[args.indexOf('--end') + 1])
|
|
27
|
+
: parseInt(process.env.ISSUE_END || '999');
|
|
28
|
+
const issuesFilePath = args.includes('--file')
|
|
29
|
+
? args[args.indexOf('--file') + 1]
|
|
30
|
+
: process.env.ISSUES_FILE || '';
|
|
31
|
+
// ── Validation ─────────────────────────────────────────────────────────
|
|
32
|
+
if (!GITHUB_TOKEN && !dryRun) {
|
|
33
|
+
console.error('❌ GITHUB_TOKEN environment variable is required.');
|
|
34
|
+
console.error(' Or use --dry-run to preview without creating.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (!issuesFilePath) {
|
|
38
|
+
console.error('❌ Issues file required. Use --file <path> or set ISSUES_FILE env var.');
|
|
39
|
+
console.error('\nExample issues.json:');
|
|
40
|
+
console.error(JSON.stringify({
|
|
41
|
+
labels: { 'bug': 'd73a49', 'feature': '0e8a16' },
|
|
42
|
+
milestones: { 'v1.0': 'First release' },
|
|
43
|
+
issues: [{ number: 1, title: 'Fix login bug', body: '## Description\n...', labels: ['bug'], milestone: 'v1.0' }]
|
|
44
|
+
}, null, 2));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
if (!OWNER || !REPO) {
|
|
48
|
+
console.error('❌ GITHUB_OWNER and GITHUB_REPO are required.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
// ── Load issues file ───────────────────────────────────────────────────
|
|
52
|
+
const resolvedPath = path.resolve(process.env.GITHUB_WORKSPACE || '.', issuesFilePath);
|
|
53
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
54
|
+
console.error(`❌ File not found: ${resolvedPath}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const config = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
|
|
58
|
+
const ISSUES = config.issues;
|
|
59
|
+
const LABEL_COLORS = config.labels || {};
|
|
60
|
+
const MILESTONES = config.milestones || {};
|
|
61
|
+
const octokit = new Octokit({ auth: GITHUB_TOKEN });
|
|
62
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
63
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
const log = (emoji, msg) => console.log(`${emoji} ${msg}`);
|
|
65
|
+
// ── Ensure labels exist ────────────────────────────────────────────────
|
|
66
|
+
async function ensureLabels() {
|
|
67
|
+
log('🏷️', 'Checking labels...');
|
|
68
|
+
try {
|
|
69
|
+
const existing = new Set();
|
|
70
|
+
let page = 1;
|
|
71
|
+
while (true) {
|
|
72
|
+
const { data } = await octokit.issues.listLabelsForRepo({ owner: OWNER, repo: REPO, per_page: 100, page });
|
|
73
|
+
if (data.length === 0)
|
|
74
|
+
break;
|
|
75
|
+
data.forEach((l) => existing.add(l.name));
|
|
76
|
+
page++;
|
|
77
|
+
}
|
|
78
|
+
let created = 0;
|
|
79
|
+
const allLabels = new Set();
|
|
80
|
+
ISSUES.forEach((i) => i.labels.forEach((l) => allLabels.add(l)));
|
|
81
|
+
for (const label of allLabels) {
|
|
82
|
+
if (!existing.has(label)) {
|
|
83
|
+
const color = LABEL_COLORS[label] || 'ededed';
|
|
84
|
+
try {
|
|
85
|
+
await octokit.issues.createLabel({ owner: OWNER, repo: REPO, name: label, color });
|
|
86
|
+
created++;
|
|
87
|
+
log(' ✅', `Created label: ${label}`);
|
|
88
|
+
await sleep(300);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e?.status !== 422)
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
log('🏷️', `Labels ready (${created} new)`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (e?.status === 403)
|
|
100
|
+
log('⚠️', 'No label write permission — skipping');
|
|
101
|
+
else
|
|
102
|
+
log('⚠️', `Label setup failed — continuing`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ── Ensure milestones ──────────────────────────────────────────────────
|
|
106
|
+
async function ensureMilestones() {
|
|
107
|
+
log('🎯', 'Checking milestones...');
|
|
108
|
+
const map = new Map();
|
|
109
|
+
try {
|
|
110
|
+
const { data } = await octokit.issues.listMilestones({ owner: OWNER, repo: REPO, state: 'open', per_page: 100 });
|
|
111
|
+
data.forEach((m) => map.set(m.title, m.number));
|
|
112
|
+
for (const [title, desc] of Object.entries(MILESTONES)) {
|
|
113
|
+
if (!map.has(title)) {
|
|
114
|
+
try {
|
|
115
|
+
const { data: ms } = await octokit.issues.createMilestone({ owner: OWNER, repo: REPO, title, description: desc });
|
|
116
|
+
map.set(title, ms.number);
|
|
117
|
+
log(' ✅', `Created milestone: ${title}`);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (e?.status !== 422)
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
log('🎯', `Milestones ready (${map.size} total)`);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
if (e?.status === 403)
|
|
129
|
+
log('⚠️', 'No milestone permission — skipping');
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
134
|
+
async function main() {
|
|
135
|
+
console.log('\n╔════════════════════════════════════════╗');
|
|
136
|
+
console.log('║ Contributor Agent — Issue Creator ║');
|
|
137
|
+
console.log('╚════════════════════════════════════════╝\n');
|
|
138
|
+
console.log(` Repo: ${OWNER}/${REPO}`);
|
|
139
|
+
console.log(` File: ${issuesFilePath}`);
|
|
140
|
+
console.log(` Range: #${startIdx} - #${endIdx}`);
|
|
141
|
+
console.log(` Dry Run: ${dryRun ? 'YES' : 'NO'}\n`);
|
|
142
|
+
const filtered = ISSUES.filter((i) => i.number >= startIdx && i.number <= endIdx);
|
|
143
|
+
log('📋', `${filtered.length} issues to create\n`);
|
|
144
|
+
if (dryRun) {
|
|
145
|
+
for (const issue of filtered) {
|
|
146
|
+
console.log(` #${String(issue.number).padStart(2, '0')} ${issue.title}`);
|
|
147
|
+
console.log(` Labels: [${issue.labels.join(', ')}]`);
|
|
148
|
+
if (issue.milestone)
|
|
149
|
+
console.log(` Milestone: ${issue.milestone}`);
|
|
150
|
+
console.log('');
|
|
151
|
+
}
|
|
152
|
+
console.log(`\n✅ Dry run: ${filtered.length} issues would be created.\n`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await ensureLabels();
|
|
156
|
+
const milestoneMap = await ensureMilestones();
|
|
157
|
+
console.log('');
|
|
158
|
+
log('🚀', 'Creating issues...\n');
|
|
159
|
+
let created = 0, failed = 0;
|
|
160
|
+
for (const issue of filtered) {
|
|
161
|
+
try {
|
|
162
|
+
const milestone = issue.milestone ? milestoneMap.get(issue.milestone) : undefined;
|
|
163
|
+
const { data } = await octokit.issues.create({
|
|
164
|
+
owner: OWNER, repo: REPO, title: issue.title, body: issue.body,
|
|
165
|
+
labels: issue.labels, milestone,
|
|
166
|
+
});
|
|
167
|
+
created++;
|
|
168
|
+
log(' ✅', `#${String(issue.number).padStart(2, '0')} → GitHub #${data.number}: ${issue.title}`);
|
|
169
|
+
await sleep(1200);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
failed++;
|
|
173
|
+
log(' ❌', `#${String(issue.number).padStart(2, '0')} FAILED: ${e?.message || e}`);
|
|
174
|
+
await sleep(2000);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
console.log(`\n ✅ Created: ${created} ❌ Failed: ${failed}\n`);
|
|
178
|
+
}
|
|
179
|
+
main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pr-review.ts — Generic PR review agent for any repository
|
|
3
|
+
//
|
|
4
|
+
// Checks: linked issues, PR size, file scope, test coverage, commit messages, sensitive files
|
|
5
|
+
//
|
|
6
|
+
// Usage via Action: action: review-pr
|
|
7
|
+
// Usage (CLI): GITHUB_TOKEN=xxx PR_NUMBER=5 GITHUB_OWNER=org GITHUB_REPO=repo npx tsx src/pr-review.ts
|
|
8
|
+
import { Octokit } from '@octokit/rest';
|
|
9
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
10
|
+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
|
11
|
+
const OWNER = process.env.GITHUB_OWNER || process.env.GITHUB_REPOSITORY?.split('/')[0] || '';
|
|
12
|
+
const REPO = process.env.GITHUB_REPO || process.env.GITHUB_REPOSITORY?.split('/')[1] || '';
|
|
13
|
+
const PR_NUMBER = parseInt(process.env.PR_NUMBER || '0');
|
|
14
|
+
if (!GITHUB_TOKEN || !OWNER || !REPO || !PR_NUMBER) {
|
|
15
|
+
console.error('❌ Required: GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO, PR_NUMBER');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const octokit = new Octokit({ auth: GITHUB_TOKEN });
|
|
19
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
20
|
+
async function main() {
|
|
21
|
+
console.log(`\n🤖 PR Review Agent — ${OWNER}/${REPO} #${PR_NUMBER}\n`);
|
|
22
|
+
const { data: pr } = await octokit.pulls.get({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
|
|
23
|
+
const { data: files } = await octokit.pulls.listFiles({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER, per_page: 100 });
|
|
24
|
+
const { data: commits } = await octokit.pulls.listCommits({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER, per_page: 100 });
|
|
25
|
+
const checks = [];
|
|
26
|
+
// ── 1. Linked Issues ─────────────────────────────────────────────
|
|
27
|
+
const issuePattern = /(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi;
|
|
28
|
+
const linkedIssues = pr.body?.match(issuePattern) || [];
|
|
29
|
+
if (linkedIssues.length > 0) {
|
|
30
|
+
checks.push({ name: 'Linked Issues', status: '✅', detail: `References: ${linkedIssues.join(', ')}` });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
checks.push({ name: 'Linked Issues', status: '⚠️', detail: 'No linked issues found. Use `Fixes #N` or `Closes #N` in the PR description.' });
|
|
34
|
+
}
|
|
35
|
+
// ── 2. PR Size ────────────────────────────────────────────────────
|
|
36
|
+
const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
|
|
37
|
+
if (totalChanges > 1000) {
|
|
38
|
+
checks.push({ name: 'PR Size', status: '❌', detail: `${totalChanges} lines changed. Consider splitting into smaller PRs.` });
|
|
39
|
+
}
|
|
40
|
+
else if (totalChanges > 500) {
|
|
41
|
+
checks.push({ name: 'PR Size', status: '⚠️', detail: `${totalChanges} lines changed. Large PR — review carefully.` });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
checks.push({ name: 'PR Size', status: '✅', detail: `${totalChanges} lines changed — good size.` });
|
|
45
|
+
}
|
|
46
|
+
// ── 3. File Scope ─────────────────────────────────────────────────
|
|
47
|
+
const dirs = new Set(files.map((f) => f.filename.split('/')[0]));
|
|
48
|
+
if (dirs.size > 5) {
|
|
49
|
+
checks.push({ name: 'File Scope', status: '⚠️', detail: `Touches ${dirs.size} top-level directories: ${[...dirs].join(', ')}` });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
checks.push({ name: 'File Scope', status: '✅', detail: `Focused on ${dirs.size} area(s): ${[...dirs].join(', ')}` });
|
|
53
|
+
}
|
|
54
|
+
// ── 4. Test Coverage ──────────────────────────────────────────────
|
|
55
|
+
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')));
|
|
56
|
+
const testFiles = files.filter((f) => f.filename.includes('test') || f.filename.includes('spec'));
|
|
57
|
+
if (srcFiles.length > 0 && testFiles.length === 0) {
|
|
58
|
+
checks.push({ name: 'Test Coverage', status: '⚠️', detail: `${srcFiles.length} source file(s) changed but no tests included.` });
|
|
59
|
+
}
|
|
60
|
+
else if (testFiles.length > 0) {
|
|
61
|
+
checks.push({ name: 'Test Coverage', status: '✅', detail: `${testFiles.length} test file(s) included.` });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
checks.push({ name: 'Test Coverage', status: '✅', detail: 'No source code changes requiring tests.' });
|
|
65
|
+
}
|
|
66
|
+
// ── 5. Commit Messages ────────────────────────────────────────────
|
|
67
|
+
const conventionalRegex = /^(feat|fix|docs|style|refactor|test|chore|ci|perf|build|revert)(\(.+\))?!?:\s/;
|
|
68
|
+
const badCommits = commits.filter((c) => !conventionalRegex.test(c.commit.message));
|
|
69
|
+
if (badCommits.length > 0) {
|
|
70
|
+
const examples = badCommits.slice(0, 3).map((c) => `\`${c.commit.message.split('\n')[0]}\``).join(', ');
|
|
71
|
+
checks.push({ name: 'Commit Messages', status: '⚠️', detail: `${badCommits.length} commit(s) don't follow conventional format. Examples: ${examples}` });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
checks.push({ name: 'Commit Messages', status: '✅', detail: 'All commits follow conventional format.' });
|
|
75
|
+
}
|
|
76
|
+
// ── 6. Sensitive Files ────────────────────────────────────────────
|
|
77
|
+
const sensitivePatterns = ['.env', 'secret', 'credential', 'password', 'private_key', '.pem', '.key', 'token'];
|
|
78
|
+
const flaggedFiles = files.filter((f) => sensitivePatterns.some((p) => f.filename.toLowerCase().includes(p)));
|
|
79
|
+
if (flaggedFiles.length > 0) {
|
|
80
|
+
checks.push({ name: 'Sensitive Files', status: '❌', detail: `Potentially sensitive: ${flaggedFiles.map((f) => f.filename).join(', ')}` });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
checks.push({ name: 'Sensitive Files', status: '✅', detail: 'No sensitive files detected.' });
|
|
84
|
+
}
|
|
85
|
+
// ── Build Comment ─────────────────────────────────────────────────
|
|
86
|
+
const hasCritical = checks.some((c) => c.status === '❌');
|
|
87
|
+
const hasWarning = checks.some((c) => c.status === '⚠️');
|
|
88
|
+
const headerEmoji = hasCritical ? '🚨' : hasWarning ? '⚠️' : '✅';
|
|
89
|
+
let body = `## ${headerEmoji} Contributor Agent — PR Review\n\n`;
|
|
90
|
+
body += `| Check | Status | Details |\n|-------|--------|--------|\n`;
|
|
91
|
+
checks.forEach((c) => { body += `| ${c.name} | ${c.status} | ${c.detail} |\n`; });
|
|
92
|
+
body += `\n`;
|
|
93
|
+
if (hasCritical)
|
|
94
|
+
body += `> 🚨 **Action Required:** Critical issues found. Please address before merging.\n`;
|
|
95
|
+
else if (hasWarning)
|
|
96
|
+
body += `> ⚠️ **Note:** Some warnings detected. Review recommended.\n`;
|
|
97
|
+
else
|
|
98
|
+
body += `> ✅ **Looking good!** All checks passed.\n`;
|
|
99
|
+
body += `\n---\n<sub>🤖 Contributor Agent — PR Review</sub>`;
|
|
100
|
+
// ── Post/Update Comment ───────────────────────────────────────────
|
|
101
|
+
const MARKER = '## ';
|
|
102
|
+
const markerText = 'Contributor Agent — PR Review';
|
|
103
|
+
const { data: comments } = await octokit.issues.listComments({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, per_page: 100 });
|
|
104
|
+
const existing = comments.find((c) => c.body?.includes(markerText));
|
|
105
|
+
if (existing) {
|
|
106
|
+
await octokit.issues.updateComment({ owner: OWNER, repo: REPO, comment_id: existing.id, body });
|
|
107
|
+
console.log('✅ Updated existing review comment');
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, body });
|
|
111
|
+
console.log('✅ Posted review comment');
|
|
112
|
+
}
|
|
113
|
+
checks.forEach((c) => console.log(` ${c.status} ${c.name}: ${c.detail}`));
|
|
114
|
+
if (hasCritical)
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitpadi",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "🤖 GitPadi — Your AI-powered GitHub management CLI. Create repos, manage issues & PRs, score contributors, and automate everything.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"gitpadi": "./
|
|
7
|
+
"gitpadi": "./dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
+
"dist/",
|
|
10
11
|
"src/",
|
|
11
12
|
"action.yml",
|
|
12
13
|
"examples/",
|
|
@@ -14,6 +15,8 @@
|
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"prepublishOnly": "npm run build",
|
|
17
20
|
"start": "tsx src/cli.ts",
|
|
18
21
|
"dev": "tsx watch src/cli.ts",
|
|
19
22
|
"create-issues": "tsx src/create-issues.ts",
|
|
@@ -49,4 +52,4 @@
|
|
|
49
52
|
"@types/node": "^22.10.5",
|
|
50
53
|
"typescript": "^5.9.3"
|
|
51
54
|
}
|
|
52
|
-
}
|
|
55
|
+
}
|
package/src/applicant-scorer.ts
CHANGED