gitpadi 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ // commands/repos.ts — Repository management commands for GitPadi
2
+
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import Table from 'cli-table3';
6
+ import { getOctokit, getOwner, setRepo } from '../core/github.js';
7
+ import { execSync } from 'child_process';
8
+
9
+ export async function createRepo(name: string, opts: { org?: string; private?: boolean; description?: string }) {
10
+ const spinner = ora(`Creating repo ${chalk.cyan(name)}...`).start();
11
+ const octokit = getOctokit();
12
+
13
+ try {
14
+ let data: any;
15
+ if (opts.org) {
16
+ ({ data } = await octokit.repos.createInOrg({
17
+ org: opts.org, name,
18
+ description: opts.description || '',
19
+ private: opts.private || false,
20
+ has_issues: true, has_wiki: false, auto_init: true,
21
+ }));
22
+ } else {
23
+ ({ data } = await octokit.repos.createForAuthenticatedUser({
24
+ name,
25
+ description: opts.description || '',
26
+ private: opts.private || false,
27
+ has_issues: true, has_wiki: false, auto_init: true,
28
+ }));
29
+ }
30
+
31
+ spinner.succeed(`Created ${chalk.green(data.full_name)} ${data.private ? chalk.dim('(private)') : chalk.dim('(public)')}`);
32
+ console.log(chalk.dim(` → ${data.html_url}\n`));
33
+ } catch (e: any) { spinner.fail(e.message); }
34
+ }
35
+
36
+ export async function deleteRepo(name: string, opts: { org?: string }) {
37
+ const owner = opts.org || getOwner();
38
+ const spinner = ora(`Deleting ${chalk.red(`${owner}/${name}`)}...`).start();
39
+
40
+ try {
41
+ await getOctokit().repos.delete({ owner, repo: name });
42
+ spinner.succeed(`Deleted ${chalk.red(`${owner}/${name}`)}`);
43
+ } catch (e: any) { spinner.fail(e.message); }
44
+ }
45
+
46
+ export async function cloneRepo(name: string, opts: { org?: string; dir?: string }) {
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
+
52
+ try {
53
+ execSync(`git clone ${url} ${dir}`, { stdio: 'pipe' });
54
+ spinner.succeed(`Cloned to ${chalk.green(`./${dir}`)}`);
55
+ } catch (e: any) { spinner.fail(`Clone failed: ${e.message}`); }
56
+ }
57
+
58
+ export async function repoInfo(name: string, opts: { org?: string }) {
59
+ const owner = opts.org || getOwner();
60
+ const spinner = ora(`Fetching info for ${owner}/${name}...`).start();
61
+
62
+ try {
63
+ const { data: repo } = await getOctokit().repos.get({ owner, repo: name });
64
+ spinner.stop();
65
+
66
+ console.log(`\n${chalk.bold(`šŸ“¦ ${repo.full_name}`)}\n`);
67
+
68
+ const table = new Table({ style: { head: [], border: [] } });
69
+ table.push(
70
+ { [chalk.cyan('Description')]: repo.description || chalk.dim('none') },
71
+ { [chalk.cyan('Stars')]: `⭐ ${repo.stargazers_count}` },
72
+ { [chalk.cyan('Forks')]: `šŸ“ ${repo.forks_count}` },
73
+ { [chalk.cyan('Issues')]: `šŸ“‹ ${repo.open_issues_count} open` },
74
+ { [chalk.cyan('Language')]: repo.language || chalk.dim('none') },
75
+ { [chalk.cyan('Visibility')]: repo.private ? chalk.yellow('Private') : chalk.green('Public') },
76
+ { [chalk.cyan('Default Branch')]: repo.default_branch },
77
+ { [chalk.cyan('Created')]: new Date(repo.created_at).toLocaleDateString() },
78
+ { [chalk.cyan('URL')]: repo.html_url },
79
+ );
80
+
81
+ console.log(table.toString());
82
+ console.log('');
83
+ } catch (e: any) { spinner.fail(e.message); }
84
+ }
85
+
86
+ export async function setTopics(name: string, topics: string[], opts: { org?: string }) {
87
+ const owner = opts.org || getOwner();
88
+ const spinner = ora('Updating topics...').start();
89
+
90
+ try {
91
+ await getOctokit().repos.replaceAllTopics({ owner, repo: name, names: topics });
92
+ spinner.succeed(`Topics set: ${chalk.cyan(topics.join(', '))}`);
93
+ } catch (e: any) { spinner.fail(e.message); }
94
+ }
95
+
96
+ export async function listRepos(opts: { org?: string; limit?: number }) {
97
+ const spinner = ora('Fetching repos...').start();
98
+ const octokit = getOctokit();
99
+
100
+ try {
101
+ let repos: any[];
102
+ if (opts.org) {
103
+ ({ data: repos } = await octokit.repos.listForOrg({ org: opts.org, per_page: opts.limit || 25, sort: 'updated' }));
104
+ } else {
105
+ ({ data: repos } = await octokit.repos.listForAuthenticatedUser({ per_page: opts.limit || 25, sort: 'updated' }));
106
+ }
107
+
108
+ spinner.stop();
109
+
110
+ const table = new Table({
111
+ head: ['Name', 'Stars', 'Language', 'Visibility', 'Updated'].map((h) => chalk.cyan(h)),
112
+ style: { head: [], border: [] },
113
+ });
114
+
115
+ repos.forEach((r: any) => {
116
+ table.push([
117
+ r.full_name,
118
+ `⭐ ${r.stargazers_count}`,
119
+ r.language || '-',
120
+ r.private ? chalk.yellow('private') : chalk.green('public'),
121
+ new Date(r.updated_at).toLocaleDateString(),
122
+ ]);
123
+ });
124
+
125
+ console.log(`\n${chalk.bold('šŸ“¦ Repositories')} (${repos.length})\n`);
126
+ console.log(table.toString());
127
+ console.log('');
128
+ } catch (e: any) { spinner.fail(e.message); }
129
+ }
@@ -0,0 +1,43 @@
1
+ // core/github.ts — Shared Octokit client + auth for GitPadi
2
+
3
+ import { Octokit } from '@octokit/rest';
4
+ import chalk from 'chalk';
5
+
6
+ let _octokit: Octokit | null = null;
7
+ let _owner: string = '';
8
+ let _repo: string = '';
9
+
10
+ export function initGitHub(token?: string, owner?: string, repo?: string): void {
11
+ const t = token || process.env.GITHUB_TOKEN || process.env.GITPADI_TOKEN || '';
12
+ if (!t) {
13
+ console.error(chalk.red('\nāŒ GitHub token required.'));
14
+ console.error(chalk.dim(' Set it: export GITHUB_TOKEN=ghp_xxx'));
15
+ console.error(chalk.dim(' Or: export GITPADI_TOKEN=ghp_xxx\n'));
16
+ process.exit(1);
17
+ }
18
+
19
+ _octokit = new Octokit({ auth: t });
20
+ _owner = owner || process.env.GITHUB_OWNER || process.env.GITHUB_REPOSITORY?.split('/')[0] || '';
21
+ _repo = repo || process.env.GITHUB_REPO || process.env.GITHUB_REPOSITORY?.split('/')[1] || '';
22
+ }
23
+
24
+ export function getOctokit(): Octokit {
25
+ if (!_octokit) {
26
+ initGitHub();
27
+ }
28
+ return _octokit!;
29
+ }
30
+
31
+ export function getOwner(): string { return _owner; }
32
+ export function getRepo(): string { return _repo; }
33
+ export function setRepo(owner: string, repo: string): void { _owner = owner; _repo = repo; }
34
+ export function getFullRepo(): string { return `${_owner}/${_repo}`; }
35
+
36
+ export function requireRepo(): void {
37
+ if (!_owner || !_repo) {
38
+ console.error(chalk.red('\nāŒ Repository not set.'));
39
+ console.error(chalk.dim(' Use: gitpadi --owner <org> --repo <name> <command>'));
40
+ console.error(chalk.dim(' Or set: GITHUB_OWNER and GITHUB_REPO env vars\n'));
41
+ process.exit(1);
42
+ }
43
+ }
@@ -0,0 +1,127 @@
1
+ // core/scorer.ts — Applicant scoring algorithm (extracted for reuse)
2
+
3
+ import { getOctokit, getOwner, getRepo } from './github.js';
4
+
5
+ export interface ApplicantProfile {
6
+ username: string;
7
+ avatarUrl: string;
8
+ accountAge: number;
9
+ publicRepos: number;
10
+ followers: number;
11
+ totalContributions: number;
12
+ repoContributions: number;
13
+ relevantLanguages: string[];
14
+ hasReadme: boolean;
15
+ prsMerged: number;
16
+ prsOpen: number;
17
+ issuesCreated: number;
18
+ commentBody: string;
19
+ }
20
+
21
+ export interface ScoreBreakdown {
22
+ accountMaturity: number;
23
+ repoExperience: number;
24
+ githubPresence: number;
25
+ activityLevel: number;
26
+ applicationQuality: number;
27
+ languageRelevance: number;
28
+ total: number;
29
+ }
30
+
31
+ export interface ScoredApplicant extends ApplicantProfile {
32
+ score: number;
33
+ breakdown: ScoreBreakdown;
34
+ tier: 'S' | 'A' | 'B' | 'C' | 'D';
35
+ }
36
+
37
+ export const TIER_EMOJI: Record<string, string> = { S: 'šŸ†', A: '🟢', B: '🟔', C: '🟠', D: 'šŸ”“' };
38
+
39
+ export const APPLICATION_PATTERNS = [
40
+ /i('d| would) (like|love|want) to (work on|tackle|take|pick up|handle)/i,
41
+ /can i (work on|take|pick up|handle|be assigned)/i,
42
+ /assign (this |it )?(to )?me/i,
43
+ /i('m| am) interested/i,
44
+ /let me (work on|take|handle)/i,
45
+ /i('ll| will) (work on|take|handle|do)/i,
46
+ /i want to contribute/i,
47
+ /please assign/i,
48
+ /i can (do|handle|take care of|work on)/i,
49
+ /picking this up/i,
50
+ /claiming this/i,
51
+ ];
52
+
53
+ export function isApplicationComment(body: string): boolean {
54
+ return APPLICATION_PATTERNS.some((p) => p.test(body));
55
+ }
56
+
57
+ export async function fetchProfile(username: string, commentBody: string = ''): Promise<ApplicantProfile> {
58
+ const octokit = getOctokit();
59
+ const OWNER = getOwner();
60
+ const REPO = getRepo();
61
+
62
+ const { data: user } = await octokit.users.getByUsername({ username });
63
+ const accountAge = Math.floor((Date.now() - new Date(user.created_at).getTime()) / 86400000);
64
+
65
+ let totalContributions = 0;
66
+ try {
67
+ const { data: events } = await octokit.activity.listPublicEventsForUser({ username, per_page: 100 });
68
+ totalContributions = events.length;
69
+ } catch { /* continue */ }
70
+
71
+ let prsMerged = 0, prsOpen = 0, issuesCreated = 0;
72
+ try {
73
+ prsMerged = (await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:pr author:${username} is:merged` })).data.total_count;
74
+ prsOpen = (await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:pr author:${username} is:open` })).data.total_count;
75
+ issuesCreated = (await octokit.search.issuesAndPullRequests({ q: `repo:${OWNER}/${REPO} type:issue author:${username}` })).data.total_count;
76
+ } catch { /* rate limit */ }
77
+
78
+ let relevantLanguages: string[] = [];
79
+ try {
80
+ const { data: repos } = await octokit.repos.listForUser({ username, sort: 'updated', per_page: 20 });
81
+ const langs = new Set<string>();
82
+ repos.forEach((r) => { if (r.language) langs.add(r.language); });
83
+ relevantLanguages = Array.from(langs);
84
+ } catch { /* continue */ }
85
+
86
+ let hasReadme = false;
87
+ try { await octokit.repos.get({ owner: username, repo: username }); hasReadme = true; } catch { /* no readme */ }
88
+
89
+ return {
90
+ username, avatarUrl: user.avatar_url, accountAge,
91
+ publicRepos: user.public_repos, followers: user.followers,
92
+ totalContributions, repoContributions: prsMerged + prsOpen + issuesCreated,
93
+ relevantLanguages, hasReadme, prsMerged, prsOpen, issuesCreated, commentBody,
94
+ };
95
+ }
96
+
97
+ export function scoreApplicant(p: ApplicantProfile, issueLabels: string[] = []): ScoredApplicant {
98
+ const b: ScoreBreakdown = { accountMaturity: 0, repoExperience: 0, githubPresence: 0, activityLevel: 0, applicationQuality: 0, languageRelevance: 0, total: 0 };
99
+
100
+ b.accountMaturity = p.accountAge > 730 ? 15 : p.accountAge > 365 ? 12 : p.accountAge > 180 ? 9 : p.accountAge > 90 ? 6 : p.accountAge > 30 ? 3 : 1;
101
+ 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);
102
+ b.githubPresence = Math.min(5, Math.floor(p.publicRepos / 5)) + Math.min(5, Math.floor(p.followers / 10)) + (p.hasReadme ? 5 : 0);
103
+ b.activityLevel = p.totalContributions >= 80 ? 15 : p.totalContributions >= 50 ? 12 : p.totalContributions >= 30 ? 9 : p.totalContributions >= 15 ? 6 : p.totalContributions >= 5 ? 3 : 1;
104
+
105
+ const words = p.commentBody.split(/\s+/).length;
106
+ const hasApproach = /approach|plan|implement|would|will|by|using|step/i.test(p.commentBody);
107
+ const hasExp = /experience|worked|built|familiar|know|background/i.test(p.commentBody);
108
+ b.applicationQuality = (words >= 50 && hasApproach && hasExp) ? 15 : (words >= 30 && (hasApproach || hasExp)) ? 12 : (words >= 20 && hasApproach) ? 9 : words >= 15 ? 6 : words >= 8 ? 3 : 1;
109
+
110
+ b.languageRelevance = 5;
111
+ if (issueLabels.length > 0 && p.relevantLanguages.length > 0) {
112
+ const labelLower = issueLabels.map((l) => l.toLowerCase()).join(' ');
113
+ const hasRust = p.relevantLanguages.includes('Rust');
114
+ const hasTS = p.relevantLanguages.includes('TypeScript') || p.relevantLanguages.includes('JavaScript');
115
+ const needsRust = /contract|rust|wasm|soroban/i.test(labelLower);
116
+ const needsTS = /backend|frontend|api|websocket|typescript/i.test(labelLower);
117
+ let matches = 0, needed = 0;
118
+ if (needsRust) { needed++; if (hasRust) matches++; }
119
+ if (needsTS) { needed++; if (hasTS) matches++; }
120
+ if (needed > 0) b.languageRelevance = Math.round((matches / needed) * 10);
121
+ }
122
+
123
+ b.total = b.accountMaturity + b.repoExperience + b.githubPresence + b.activityLevel + b.applicationQuality + b.languageRelevance;
124
+ const tier: ScoredApplicant['tier'] = b.total >= 75 ? 'S' : b.total >= 55 ? 'A' : b.total >= 40 ? 'B' : b.total >= 25 ? 'C' : 'D';
125
+
126
+ return { ...p, score: b.total, breakdown: b, tier };
127
+ }
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env tsx
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
+
13
+ import { Octokit } from '@octokit/rest';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+
17
+ // ── Config ─────────────────────────────────────────────────────────────
18
+ const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
19
+ const OWNER = process.env.GITHUB_OWNER || '';
20
+ const REPO = process.env.GITHUB_REPO || '';
21
+
22
+ // ── Parse CLI args ─────────────────────────────────────────────────────
23
+ const args = process.argv.slice(2);
24
+ const dryRun = args.includes('--dry-run') || process.env.DRY_RUN === 'true';
25
+
26
+ const startIdx = args.includes('--start')
27
+ ? parseInt(args[args.indexOf('--start') + 1])
28
+ : parseInt(process.env.ISSUE_START || '1');
29
+ const endIdx = args.includes('--end')
30
+ ? parseInt(args[args.indexOf('--end') + 1])
31
+ : parseInt(process.env.ISSUE_END || '999');
32
+
33
+ const issuesFilePath = args.includes('--file')
34
+ ? args[args.indexOf('--file') + 1]
35
+ : process.env.ISSUES_FILE || '';
36
+
37
+ // ── Types ──────────────────────────────────────────────────────────────
38
+ interface IssueData {
39
+ number: number;
40
+ title: string;
41
+ body: string;
42
+ labels: string[];
43
+ milestone?: string;
44
+ }
45
+
46
+ interface IssuesConfig {
47
+ labels?: Record<string, string>; // label name → color hex
48
+ milestones?: Record<string, string>; // milestone title → description
49
+ issues: IssueData[];
50
+ }
51
+
52
+ // ── Validation ─────────────────────────────────────────────────────────
53
+ if (!GITHUB_TOKEN && !dryRun) {
54
+ console.error('āŒ GITHUB_TOKEN environment variable is required.');
55
+ console.error(' Or use --dry-run to preview without creating.');
56
+ process.exit(1);
57
+ }
58
+
59
+ if (!issuesFilePath) {
60
+ console.error('āŒ Issues file required. Use --file <path> or set ISSUES_FILE env var.');
61
+ console.error('\nExample issues.json:');
62
+ console.error(JSON.stringify({
63
+ labels: { 'bug': 'd73a49', 'feature': '0e8a16' },
64
+ milestones: { 'v1.0': 'First release' },
65
+ issues: [{ number: 1, title: 'Fix login bug', body: '## Description\n...', labels: ['bug'], milestone: 'v1.0' }]
66
+ }, null, 2));
67
+ process.exit(1);
68
+ }
69
+
70
+ if (!OWNER || !REPO) {
71
+ console.error('āŒ GITHUB_OWNER and GITHUB_REPO are required.');
72
+ process.exit(1);
73
+ }
74
+
75
+ // ── Load issues file ───────────────────────────────────────────────────
76
+ const resolvedPath = path.resolve(process.env.GITHUB_WORKSPACE || '.', issuesFilePath);
77
+ if (!fs.existsSync(resolvedPath)) {
78
+ console.error(`āŒ File not found: ${resolvedPath}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const config: IssuesConfig = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
83
+ const ISSUES = config.issues;
84
+ const LABEL_COLORS = config.labels || {};
85
+ const MILESTONES = config.milestones || {};
86
+
87
+ const octokit = new Octokit({ auth: GITHUB_TOKEN });
88
+
89
+ // ── Helpers ────────────────────────────────────────────────────────────
90
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
91
+ const log = (emoji: string, msg: string) => console.log(`${emoji} ${msg}`);
92
+
93
+ // ── Ensure labels exist ────────────────────────────────────────────────
94
+ async function ensureLabels(): Promise<void> {
95
+ log('šŸ·ļø', 'Checking labels...');
96
+ try {
97
+ const existing = new Set<string>();
98
+ let page = 1;
99
+ while (true) {
100
+ const { data } = await octokit.issues.listLabelsForRepo({ owner: OWNER, repo: REPO, per_page: 100, page });
101
+ if (data.length === 0) break;
102
+ data.forEach((l) => existing.add(l.name));
103
+ page++;
104
+ }
105
+
106
+ let created = 0;
107
+ const allLabels = new Set<string>();
108
+ ISSUES.forEach((i) => i.labels.forEach((l) => allLabels.add(l)));
109
+
110
+ for (const label of allLabels) {
111
+ if (!existing.has(label)) {
112
+ const color = LABEL_COLORS[label] || 'ededed';
113
+ try {
114
+ await octokit.issues.createLabel({ owner: OWNER, repo: REPO, name: label, color });
115
+ created++;
116
+ log(' āœ…', `Created label: ${label}`);
117
+ await sleep(300);
118
+ } catch (e: any) {
119
+ if (e?.status !== 422) throw e;
120
+ }
121
+ }
122
+ }
123
+ log('šŸ·ļø', `Labels ready (${created} new)`);
124
+ } catch (e: any) {
125
+ if (e?.status === 403) log('āš ļø', 'No label write permission — skipping');
126
+ else log('āš ļø', `Label setup failed — continuing`);
127
+ }
128
+ }
129
+
130
+ // ── Ensure milestones ──────────────────────────────────────────────────
131
+ async function ensureMilestones(): Promise<Map<string, number>> {
132
+ log('šŸŽÆ', 'Checking milestones...');
133
+ const map = new Map<string, number>();
134
+ try {
135
+ const { data } = await octokit.issues.listMilestones({ owner: OWNER, repo: REPO, state: 'open', per_page: 100 });
136
+ data.forEach((m) => map.set(m.title, m.number));
137
+ for (const [title, desc] of Object.entries(MILESTONES)) {
138
+ if (!map.has(title)) {
139
+ try {
140
+ const { data: ms } = await octokit.issues.createMilestone({ owner: OWNER, repo: REPO, title, description: desc });
141
+ map.set(title, ms.number);
142
+ log(' āœ…', `Created milestone: ${title}`);
143
+ } catch (e: any) { if (e?.status !== 422) throw e; }
144
+ }
145
+ }
146
+ log('šŸŽÆ', `Milestones ready (${map.size} total)`);
147
+ } catch (e: any) {
148
+ if (e?.status === 403) log('āš ļø', 'No milestone permission — skipping');
149
+ }
150
+ return map;
151
+ }
152
+
153
+ // ── Main ───────────────────────────────────────────────────────────────
154
+ async function main() {
155
+ console.log('\n╔════════════════════════════════════════╗');
156
+ console.log('ā•‘ Contributor Agent — Issue Creator ā•‘');
157
+ console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n');
158
+ console.log(` Repo: ${OWNER}/${REPO}`);
159
+ console.log(` File: ${issuesFilePath}`);
160
+ console.log(` Range: #${startIdx} - #${endIdx}`);
161
+ console.log(` Dry Run: ${dryRun ? 'YES' : 'NO'}\n`);
162
+
163
+ const filtered = ISSUES.filter((i) => i.number >= startIdx && i.number <= endIdx);
164
+ log('šŸ“‹', `${filtered.length} issues to create\n`);
165
+
166
+ if (dryRun) {
167
+ for (const issue of filtered) {
168
+ console.log(` #${String(issue.number).padStart(2, '0')} ${issue.title}`);
169
+ console.log(` Labels: [${issue.labels.join(', ')}]`);
170
+ if (issue.milestone) console.log(` Milestone: ${issue.milestone}`);
171
+ console.log('');
172
+ }
173
+ console.log(`\nāœ… Dry run: ${filtered.length} issues would be created.\n`);
174
+ return;
175
+ }
176
+
177
+ await ensureLabels();
178
+ const milestoneMap = await ensureMilestones();
179
+ console.log('');
180
+ log('šŸš€', 'Creating issues...\n');
181
+
182
+ let created = 0, failed = 0;
183
+ for (const issue of filtered) {
184
+ try {
185
+ const milestone = issue.milestone ? milestoneMap.get(issue.milestone) : undefined;
186
+ const { data } = await octokit.issues.create({
187
+ owner: OWNER, repo: REPO, title: issue.title, body: issue.body,
188
+ labels: issue.labels, milestone,
189
+ });
190
+ created++;
191
+ log(' āœ…', `#${String(issue.number).padStart(2, '0')} → GitHub #${data.number}: ${issue.title}`);
192
+ await sleep(1200);
193
+ } catch (e: any) {
194
+ failed++;
195
+ log(' āŒ', `#${String(issue.number).padStart(2, '0')} FAILED: ${e?.message || e}`);
196
+ await sleep(2000);
197
+ }
198
+ }
199
+
200
+ console.log(`\n āœ… Created: ${created} āŒ Failed: ${failed}\n`);
201
+ }
202
+
203
+ main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env tsx
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
+
9
+ import { Octokit } from '@octokit/rest';
10
+
11
+ // ── Config ─────────────────────────────────────────────────────────────
12
+ const GITHUB_TOKEN = process.env.GITHUB_TOKEN!;
13
+ const OWNER = process.env.GITHUB_OWNER || process.env.GITHUB_REPOSITORY?.split('/')[0] || '';
14
+ const REPO = process.env.GITHUB_REPO || process.env.GITHUB_REPOSITORY?.split('/')[1] || '';
15
+ const PR_NUMBER = parseInt(process.env.PR_NUMBER || '0');
16
+
17
+ if (!GITHUB_TOKEN || !OWNER || !REPO || !PR_NUMBER) {
18
+ console.error('āŒ Required: GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO, PR_NUMBER');
19
+ process.exit(1);
20
+ }
21
+
22
+ const octokit = new Octokit({ auth: GITHUB_TOKEN });
23
+
24
+ // ── Types ──────────────────────────────────────────────────────────────
25
+ interface CheckResult {
26
+ name: string;
27
+ status: 'āœ…' | 'āš ļø' | 'āŒ';
28
+ detail: string;
29
+ }
30
+
31
+ // ── Main ───────────────────────────────────────────────────────────────
32
+ async function main(): Promise<void> {
33
+ console.log(`\nšŸ¤– PR Review Agent — ${OWNER}/${REPO} #${PR_NUMBER}\n`);
34
+
35
+ const { data: pr } = await octokit.pulls.get({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
36
+ const { data: files } = await octokit.pulls.listFiles({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER, per_page: 100 });
37
+ const { data: commits } = await octokit.pulls.listCommits({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER, per_page: 100 });
38
+
39
+ const checks: CheckResult[] = [];
40
+
41
+ // ── 1. Linked Issues ─────────────────────────────────────────────
42
+ const issuePattern = /(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi;
43
+ const linkedIssues = pr.body?.match(issuePattern) || [];
44
+ if (linkedIssues.length > 0) {
45
+ checks.push({ name: 'Linked Issues', status: 'āœ…', detail: `References: ${linkedIssues.join(', ')}` });
46
+ } else {
47
+ checks.push({ name: 'Linked Issues', status: 'āš ļø', detail: 'No linked issues found. Use `Fixes #N` or `Closes #N` in the PR description.' });
48
+ }
49
+
50
+ // ── 2. PR Size ────────────────────────────────────────────────────
51
+ const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
52
+ if (totalChanges > 1000) {
53
+ checks.push({ name: 'PR Size', status: 'āŒ', detail: `${totalChanges} lines changed. Consider splitting into smaller PRs.` });
54
+ } else if (totalChanges > 500) {
55
+ checks.push({ name: 'PR Size', status: 'āš ļø', detail: `${totalChanges} lines changed. Large PR — review carefully.` });
56
+ } else {
57
+ checks.push({ name: 'PR Size', status: 'āœ…', detail: `${totalChanges} lines changed — good size.` });
58
+ }
59
+
60
+ // ── 3. File Scope ─────────────────────────────────────────────────
61
+ const dirs = new Set(files.map((f) => f.filename.split('/')[0]));
62
+ if (dirs.size > 5) {
63
+ checks.push({ name: 'File Scope', status: 'āš ļø', detail: `Touches ${dirs.size} top-level directories: ${[...dirs].join(', ')}` });
64
+ } else {
65
+ checks.push({ name: 'File Scope', status: 'āœ…', detail: `Focused on ${dirs.size} area(s): ${[...dirs].join(', ')}` });
66
+ }
67
+
68
+ // ── 4. Test Coverage ──────────────────────────────────────────────
69
+ 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')));
70
+ const testFiles = files.filter((f) => f.filename.includes('test') || f.filename.includes('spec'));
71
+ if (srcFiles.length > 0 && testFiles.length === 0) {
72
+ checks.push({ name: 'Test Coverage', status: 'āš ļø', detail: `${srcFiles.length} source file(s) changed but no tests included.` });
73
+ } else if (testFiles.length > 0) {
74
+ checks.push({ name: 'Test Coverage', status: 'āœ…', detail: `${testFiles.length} test file(s) included.` });
75
+ } else {
76
+ checks.push({ name: 'Test Coverage', status: 'āœ…', detail: 'No source code changes requiring tests.' });
77
+ }
78
+
79
+ // ── 5. Commit Messages ────────────────────────────────────────────
80
+ const conventionalRegex = /^(feat|fix|docs|style|refactor|test|chore|ci|perf|build|revert)(\(.+\))?!?:\s/;
81
+ const badCommits = commits.filter((c) => !conventionalRegex.test(c.commit.message));
82
+ if (badCommits.length > 0) {
83
+ const examples = badCommits.slice(0, 3).map((c) => `\`${c.commit.message.split('\n')[0]}\``).join(', ');
84
+ checks.push({ name: 'Commit Messages', status: 'āš ļø', detail: `${badCommits.length} commit(s) don't follow conventional format. Examples: ${examples}` });
85
+ } else {
86
+ checks.push({ name: 'Commit Messages', status: 'āœ…', detail: 'All commits follow conventional format.' });
87
+ }
88
+
89
+ // ── 6. Sensitive Files ────────────────────────────────────────────
90
+ const sensitivePatterns = ['.env', 'secret', 'credential', 'password', 'private_key', '.pem', '.key', 'token'];
91
+ const flaggedFiles = files.filter((f) => sensitivePatterns.some((p) => f.filename.toLowerCase().includes(p)));
92
+ if (flaggedFiles.length > 0) {
93
+ checks.push({ name: 'Sensitive Files', status: 'āŒ', detail: `Potentially sensitive: ${flaggedFiles.map((f) => f.filename).join(', ')}` });
94
+ } else {
95
+ checks.push({ name: 'Sensitive Files', status: 'āœ…', detail: 'No sensitive files detected.' });
96
+ }
97
+
98
+ // ── Build Comment ─────────────────────────────────────────────────
99
+ const hasCritical = checks.some((c) => c.status === 'āŒ');
100
+ const hasWarning = checks.some((c) => c.status === 'āš ļø');
101
+ const headerEmoji = hasCritical ? '🚨' : hasWarning ? 'āš ļø' : 'āœ…';
102
+
103
+ let body = `## ${headerEmoji} Contributor Agent — PR Review\n\n`;
104
+ body += `| Check | Status | Details |\n|-------|--------|--------|\n`;
105
+ checks.forEach((c) => { body += `| ${c.name} | ${c.status} | ${c.detail} |\n`; });
106
+ body += `\n`;
107
+
108
+ if (hasCritical) body += `> 🚨 **Action Required:** Critical issues found. Please address before merging.\n`;
109
+ else if (hasWarning) body += `> āš ļø **Note:** Some warnings detected. Review recommended.\n`;
110
+ else body += `> āœ… **Looking good!** All checks passed.\n`;
111
+
112
+ body += `\n---\n<sub>šŸ¤– Contributor Agent — PR Review</sub>`;
113
+
114
+ // ── Post/Update Comment ───────────────────────────────────────────
115
+ const MARKER = '## ';
116
+ const markerText = 'Contributor Agent — PR Review';
117
+ const { data: comments } = await octokit.issues.listComments({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, per_page: 100 });
118
+ const existing = comments.find((c) => c.body?.includes(markerText));
119
+
120
+ if (existing) {
121
+ await octokit.issues.updateComment({ owner: OWNER, repo: REPO, comment_id: existing.id, body });
122
+ console.log('āœ… Updated existing review comment');
123
+ } else {
124
+ await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, body });
125
+ console.log('āœ… Posted review comment');
126
+ }
127
+
128
+ checks.forEach((c) => console.log(` ${c.status} ${c.name}: ${c.detail}`));
129
+ if (hasCritical) process.exit(1);
130
+ }
131
+
132
+ main().catch((e) => { console.error('Fatal:', e); process.exit(1); });