gitpadi 2.0.1 → 2.0.3

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,314 @@
1
+ // commands/contribute.ts — Contributor workflow for GitPadi
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { execSync } from 'child_process';
5
+ import * as fs from 'fs';
6
+ import boxen from 'boxen';
7
+ import { getOctokit, getOwner, getRepo, forkRepo, getAuthenticatedUser, getLatestCheckRuns, setRepo } from '../core/github.js';
8
+ const dim = chalk.dim;
9
+ const yellow = chalk.yellow;
10
+ const green = chalk.green;
11
+ const cyan = chalk.cyan;
12
+ const white = chalk.white;
13
+ /**
14
+ * Parses a GitHub Issue/Repo URL or "owner/repo" string
15
+ */
16
+ function parseTarget(input) {
17
+ const urlPattern = /github\.com\/([^/]+)\/([^/]+)(\/issues\/(\d+))?/;
18
+ const match = input.match(urlPattern);
19
+ if (match) {
20
+ return {
21
+ owner: match[1],
22
+ repo: match[2],
23
+ issue: match[4] ? parseInt(match[4]) : undefined
24
+ };
25
+ }
26
+ const parts = input.split('/');
27
+ if (parts.length === 2) {
28
+ return { owner: parts[0], repo: parts[1] };
29
+ }
30
+ throw new Error('Invalid target. Use a GitHub URL or "owner/repo" format.');
31
+ }
32
+ /**
33
+ * Phase 2: Fork & Clone Workflow (The "Start" Command)
34
+ */
35
+ export async function forkAndClone(target) {
36
+ const { owner, repo, issue } = parseTarget(target);
37
+ // 1. Fork first (idempotent — GitHub returns existing fork if it exists)
38
+ const spinner = ora(`Forking ${cyan(`${owner}/${repo}`)}...`).start();
39
+ let myUser;
40
+ try {
41
+ myUser = await getAuthenticatedUser();
42
+ const forkFullName = await forkRepo(owner, repo);
43
+ spinner.succeed(`Forked to ${green(forkFullName)}`);
44
+ }
45
+ catch (e) {
46
+ spinner.fail(e.message);
47
+ return;
48
+ }
49
+ // 2. Ask where to clone
50
+ const inquirer = (await import('inquirer')).default;
51
+ const path = (await import('path')).default;
52
+ const os = (await import('os')).default;
53
+ const { parentDir } = await inquirer.prompt([{
54
+ type: 'input',
55
+ name: 'parentDir',
56
+ message: cyan('📂 Where to clone? (parent directory):'),
57
+ default: '.',
58
+ }]);
59
+ // Resolve ~ and build full path: parentDir/repoName
60
+ const resolvedParent = parentDir.startsWith('~')
61
+ ? parentDir.replace('~', os.homedir())
62
+ : parentDir;
63
+ let cloneDir = path.resolve(resolvedParent, repo);
64
+ // 3. Handle existing directory
65
+ if (fs.existsSync(cloneDir)) {
66
+ // Check if it's already a valid git clone of this repo
67
+ let isValidClone = false;
68
+ try {
69
+ const remoteUrl = execSync('git remote get-url origin', { cwd: cloneDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
70
+ if (remoteUrl.includes(repo))
71
+ isValidClone = true;
72
+ }
73
+ catch { /* not a git repo */ }
74
+ if (isValidClone) {
75
+ console.log(yellow(`\n 📂 "${cloneDir}" already contains a clone of ${repo}.`));
76
+ console.log(dim(' Syncing with upstream...\n'));
77
+ process.chdir(cloneDir);
78
+ setRepo(owner, repo);
79
+ // Ensure upstream remote exists
80
+ try {
81
+ execSync(`git remote add upstream https://github.com/${owner}/${repo}.git`, { stdio: 'pipe' });
82
+ }
83
+ catch { /* exists */ }
84
+ // 1. Detect default branch
85
+ let defaultBranch = 'main';
86
+ try {
87
+ execSync('git rev-parse upstream/main', { stdio: 'pipe' });
88
+ }
89
+ catch {
90
+ defaultBranch = 'master';
91
+ }
92
+ // 2. Checkout default branch before syncing
93
+ try {
94
+ execSync(`git checkout ${defaultBranch}`, { stdio: 'pipe' });
95
+ }
96
+ catch {
97
+ // If it fails, maybe it's not even a valid default branch locally, but we'll try to sync anyway
98
+ }
99
+ await syncBranch();
100
+ // 3. Create or switch to issue branch
101
+ const branchName = issue ? `fix/issue-${issue}` : null;
102
+ if (branchName) {
103
+ try {
104
+ execSync(`git checkout -b ${branchName}`, { stdio: 'pipe' });
105
+ console.log(green(` ✔ Created branch ${branchName}`));
106
+ }
107
+ catch {
108
+ execSync(`git checkout ${branchName}`, { stdio: 'pipe' });
109
+ console.log(green(` ✔ Switched to branch ${branchName}`));
110
+ }
111
+ }
112
+ console.log(green('\n✨ Workspace ready!'));
113
+ return;
114
+ }
115
+ // Directory exists but isn't a valid clone — ask for new path
116
+ const { newDir } = await inquirer.prompt([{
117
+ type: 'input',
118
+ name: 'newDir',
119
+ message: yellow(`"${cloneDir}" already exists. Enter a different folder name:`),
120
+ default: `${repo}-contrib`,
121
+ }]);
122
+ cloneDir = path.resolve(resolvedParent, newDir);
123
+ }
124
+ // 4. Clone
125
+ const cloneSpinner = ora(`Cloning your fork...`).start();
126
+ const cloneUrl = `https://github.com/${myUser}/${repo}.git`;
127
+ try {
128
+ execSync(`git clone ${cloneUrl} ${cloneDir}`, { stdio: 'pipe' });
129
+ cloneSpinner.succeed(`Cloned into ${green(cloneDir)}`);
130
+ }
131
+ catch (e) {
132
+ cloneSpinner.fail(`Clone failed: ${e.message}`);
133
+ return;
134
+ }
135
+ // 5. Setup Remotes & Branch
136
+ process.chdir(cloneDir);
137
+ execSync(`git remote add upstream https://github.com/${owner}/${repo}.git`, { stdio: 'pipe' });
138
+ const branchName = issue ? `fix/issue-${issue}` : `contrib-${Math.floor(Date.now() / 1000)}`;
139
+ try {
140
+ execSync(`git checkout -b ${branchName}`, { stdio: 'pipe' });
141
+ }
142
+ catch {
143
+ execSync(`git checkout ${branchName}`, { stdio: 'pipe' });
144
+ }
145
+ // Update local GitPadi state
146
+ setRepo(owner, repo);
147
+ console.log(`\n${green('✨ Workspace Ready!')}`);
148
+ console.log(`${dim('Directory:')} ${cloneDir}`);
149
+ console.log(`${dim('Branch:')} ${branchName}`);
150
+ console.log(`${dim('Upstream:')} ${owner}/${repo}`);
151
+ console.log(boxen(green('Next step:\n') +
152
+ white(`cd ${cloneDir}\n\n`) +
153
+ dim('Start coding! When you\'re done, run ') + yellow('gitpadi submit'), { padding: 1, borderColor: 'green', borderStyle: 'round' }));
154
+ }
155
+ /**
156
+ * Phase 2: Sync Fork with Upstream (full flow)
157
+ * 1. Sync fork on GitHub (API)
158
+ * 2. Pull synced changes locally
159
+ * 3. Merge upstream into current branch
160
+ */
161
+ export async function syncBranch() {
162
+ try {
163
+ // Check if we are in a git repo and if upstream exists
164
+ const remotes = execSync('git remote', { encoding: 'utf-8' });
165
+ if (!remotes.includes('upstream'))
166
+ return;
167
+ const spinner = ora('Syncing fork...').start();
168
+ // 1. Detect upstream default branch (main or master)
169
+ execSync('git fetch upstream', { stdio: 'pipe' });
170
+ let upstreamBranch = 'main';
171
+ try {
172
+ execSync('git rev-parse upstream/main', { stdio: 'pipe' });
173
+ }
174
+ catch {
175
+ upstreamBranch = 'master';
176
+ }
177
+ // 2. Sync fork on GitHub via API
178
+ spinner.text = 'Syncing fork on GitHub...';
179
+ try {
180
+ const octokit = getOctokit();
181
+ const myUser = await getAuthenticatedUser();
182
+ const repo = getRepo();
183
+ await octokit.request('POST /repos/{owner}/{repo}/merge-upstream', {
184
+ owner: myUser,
185
+ repo: repo,
186
+ branch: upstreamBranch,
187
+ });
188
+ spinner.succeed(green('Fork synced on GitHub ✓'));
189
+ }
190
+ catch (e) {
191
+ // May fail if already in sync or permissions — continue anyway
192
+ spinner.info(dim('GitHub sync skipped (may already be in sync)'));
193
+ }
194
+ // 3. Pull synced changes locally
195
+ const pullSpinner = ora('Pulling latest changes...').start();
196
+ const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
197
+ // Fetch origin (our fork) with the new synced data
198
+ execSync('git fetch origin', { stdio: 'pipe' });
199
+ execSync('git fetch upstream', { stdio: 'pipe' });
200
+ // If we're on main/master, just pull
201
+ if (currentBranch === upstreamBranch) {
202
+ try {
203
+ execSync(`git pull origin ${upstreamBranch} --no-edit`, { stdio: 'pipe' });
204
+ pullSpinner.succeed(green(`Pulled latest ${upstreamBranch} ✓`));
205
+ }
206
+ catch {
207
+ pullSpinner.warn(yellow('Pull had conflicts — resolve manually'));
208
+ }
209
+ }
210
+ else {
211
+ // We're on a feature branch — merge upstream into it
212
+ pullSpinner.text = `Merging upstream/${upstreamBranch} into ${currentBranch}...`;
213
+ try {
214
+ execSync(`git merge upstream/${upstreamBranch} --no-edit`, { stdio: 'pipe' });
215
+ pullSpinner.succeed(green(`Merged upstream/${upstreamBranch} into ${cyan(currentBranch)} ✓`));
216
+ }
217
+ catch {
218
+ pullSpinner.warn(yellow('Merge conflict detected!'));
219
+ console.log(dim(' Resolve conflicts, then run:'));
220
+ console.log(dim(' git add . && git commit'));
221
+ }
222
+ }
223
+ console.log(green('\n✨ Fork is synced and up to date!'));
224
+ }
225
+ catch (e) {
226
+ // Silently fail if git commands error (likely not in a repo)
227
+ }
228
+ }
229
+ /**
230
+ * Phase 2: Submit PR
231
+ */
232
+ export async function submitPR(opts) {
233
+ const spinner = ora('Preparing submission...').start();
234
+ try {
235
+ const owner = getOwner();
236
+ const repo = getRepo();
237
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
238
+ // 1. Stage and Commit
239
+ spinner.text = 'Staging and committing changes...';
240
+ const commitMsg = opts.message || opts.title || 'Automated contribution via GitPadi';
241
+ try {
242
+ execSync('git add .', { stdio: 'pipe' });
243
+ // Check if there are changes to commit
244
+ const status = execSync('git status --porcelain', { encoding: 'utf-8' });
245
+ if (status.trim()) {
246
+ execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
247
+ }
248
+ }
249
+ catch (e) {
250
+ // If commit fails (e.g. no changes), we might still want to push if there are unpushed commits
251
+ dim(' (Note: No new changes to commit or commit failed)');
252
+ }
253
+ // Auto-infer issue from branch name (e.g. fix/issue-303)
254
+ let linkedIssue = opts.issue;
255
+ if (!linkedIssue) {
256
+ const match = branch.match(/issue-(\d+)/);
257
+ if (match)
258
+ linkedIssue = parseInt(match[1]);
259
+ }
260
+ spinner.text = 'Pushing to your fork...';
261
+ execSync(`git push origin ${branch}`, { stdio: 'pipe' });
262
+ spinner.text = 'Creating Pull Request...';
263
+ const body = opts.body || (linkedIssue ? `Fixes #${linkedIssue}` : 'Automated PR via GitPadi');
264
+ // Detect base branch
265
+ let baseBranch = 'main';
266
+ try {
267
+ execSync('git rev-parse origin/main', { stdio: 'pipe' });
268
+ }
269
+ catch {
270
+ baseBranch = 'master';
271
+ }
272
+ const { data: pr } = await getOctokit().pulls.create({
273
+ owner,
274
+ repo,
275
+ title: opts.title,
276
+ body,
277
+ head: `${await getAuthenticatedUser()}:${branch}`,
278
+ base: baseBranch,
279
+ });
280
+ spinner.succeed(`PR Created: ${green(pr.html_url)}`);
281
+ }
282
+ catch (e) {
283
+ spinner.fail(e.message);
284
+ }
285
+ }
286
+ /**
287
+ * Phase 2: View Logs
288
+ */
289
+ export async function viewLogs() {
290
+ const spinner = ora('Fetching GitHub Action logs...').start();
291
+ try {
292
+ const owner = getOwner();
293
+ const repo = getRepo();
294
+ const sha = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
295
+ const { checkRuns, combinedState } = await getLatestCheckRuns(owner, repo, sha);
296
+ spinner.stop();
297
+ if (checkRuns.length === 0) {
298
+ console.log(dim('\n ℹ️ No active check runs found for this commit.\n'));
299
+ return;
300
+ }
301
+ console.log(`\n${chalk.bold(`📋 GitHub Actions status:`)} ${combinedState === 'success' ? green('✅ Success') : combinedState === 'failure' ? chalk.red('❌ Failure') : yellow('⏳ Pending')}\n`);
302
+ checkRuns.forEach(run => {
303
+ const icon = run.status === 'completed' ? (run.conclusion === 'success' ? green('✅') : chalk.red('❌')) : yellow('⏳');
304
+ console.log(` ${icon} ${chalk.bold(run.name)}: ${dim(run.conclusion || run.status)}`);
305
+ if (run.conclusion === 'failure') {
306
+ console.log(chalk.red(` → Build failed. View details at: ${run.html_url}`));
307
+ }
308
+ });
309
+ console.log('');
310
+ }
311
+ catch (e) {
312
+ spinner.fail(e.message);
313
+ }
314
+ }
@@ -11,16 +11,29 @@ export async function listIssues(opts) {
11
11
  const octokit = getOctokit();
12
12
  const spinner = ora(`Fetching issues from ${chalk.cyan(getFullRepo())}...`).start();
13
13
  try {
14
- const { data: issues } = await octokit.issues.listForRepo({
15
- owner: getOwner(), repo: getRepo(),
16
- state: opts.state || 'open',
17
- labels: opts.labels || undefined,
18
- per_page: opts.limit || 50,
19
- });
20
- // Filter out PRs (GitHub API returns PRs in issues endpoint)
21
- const realIssues = issues.filter((i) => !i.pull_request);
14
+ const requestedLimit = opts.limit || 50;
15
+ const realIssues = [];
16
+ let page = 1;
17
+ // Fetch until we have enough real issues or run out of pages
18
+ while (realIssues.length < requestedLimit) {
19
+ const { data: issues } = await octokit.issues.listForRepo({
20
+ owner: getOwner(), repo: getRepo(),
21
+ state: opts.state || 'open',
22
+ labels: opts.labels || undefined,
23
+ per_page: 100, // Fetch max per page to be efficient
24
+ page: page++,
25
+ });
26
+ if (issues.length === 0)
27
+ break;
28
+ const batch = issues.filter((i) => !i.pull_request);
29
+ realIssues.push(...batch);
30
+ if (issues.length < 100)
31
+ break; // Last page
32
+ }
33
+ // Clip to requested limit
34
+ const finalIssues = realIssues.slice(0, requestedLimit);
22
35
  spinner.stop();
23
- if (realIssues.length === 0) {
36
+ if (finalIssues.length === 0) {
24
37
  console.log(chalk.yellow('\n No issues found.\n'));
25
38
  return;
26
39
  }
@@ -28,13 +41,13 @@ export async function listIssues(opts) {
28
41
  head: ['#', 'Title', 'Labels', 'Assignee', 'State'].map((h) => chalk.cyan(h)),
29
42
  style: { head: [], border: [] },
30
43
  });
31
- realIssues.forEach((i) => {
44
+ finalIssues.forEach((i) => {
32
45
  const labels = i.labels.map((l) => typeof l === 'string' ? l : l.name || '').join(', ');
33
46
  const assignee = i.assignee?.login || chalk.dim('unassigned');
34
47
  const state = i.state === 'open' ? chalk.green('open') : chalk.red('closed');
35
48
  table.push([`#${i.number}`, i.title.substring(0, 60), labels.substring(0, 30), assignee, state]);
36
49
  });
37
- console.log(`\n${chalk.bold(`📋 Issues — ${getFullRepo()}`)} (${realIssues.length})\n`);
50
+ console.log(`\n${chalk.bold(`📋 Issues — ${getFullRepo()}`)} (${finalIssues.length})\n`);
38
51
  console.log(table.toString());
39
52
  console.log('');
40
53
  }
@@ -60,6 +73,42 @@ export async function createIssue(opts) {
60
73
  spinner.fail(`Failed: ${e.message}`);
61
74
  }
62
75
  }
76
+ /**
77
+ * Parse a markdown file into issues.
78
+ * Format:
79
+ * ## Issue Title
80
+ * **Labels:** bug, frontend
81
+ * Body text here...
82
+ */
83
+ function parseMarkdownIssues(content) {
84
+ const issues = [];
85
+ const sections = content.split(/^## /m).filter(s => s.trim());
86
+ let num = 1;
87
+ for (const section of sections) {
88
+ const lines = section.split('\n');
89
+ const title = lines[0].trim();
90
+ if (!title)
91
+ continue;
92
+ let labels = [];
93
+ const bodyLines = [];
94
+ for (let i = 1; i < lines.length; i++) {
95
+ const labelsMatch = lines[i].match(/^\*\*Labels?:\*\*\s*(.+)/i);
96
+ if (labelsMatch) {
97
+ labels = labelsMatch[1].split(',').map(l => l.trim()).filter(Boolean);
98
+ }
99
+ else {
100
+ bodyLines.push(lines[i]);
101
+ }
102
+ }
103
+ issues.push({
104
+ number: num++,
105
+ title,
106
+ body: bodyLines.join('\n').trim(),
107
+ labels,
108
+ });
109
+ }
110
+ return { issues, labels: {} };
111
+ }
63
112
  export async function createIssuesFromFile(filePath, opts) {
64
113
  requireRepo();
65
114
  const resolved = path.resolve(filePath);
@@ -67,28 +116,62 @@ export async function createIssuesFromFile(filePath, opts) {
67
116
  console.error(chalk.red(`\n❌ File not found: ${resolved}\n`));
68
117
  return;
69
118
  }
70
- const config = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
71
- const issues = config.issues || [];
119
+ const raw = fs.readFileSync(resolved, 'utf-8');
120
+ const ext = path.extname(resolved).toLowerCase();
121
+ let config;
122
+ let detectedFormat = 'JSON';
123
+ if (ext === '.md' || ext === '.markdown') {
124
+ config = parseMarkdownIssues(raw);
125
+ detectedFormat = 'Markdown';
126
+ }
127
+ else {
128
+ // Try JSON first, fallback to Markdown if it fails
129
+ try {
130
+ const parsed = JSON.parse(raw);
131
+ config = { issues: parsed.issues || [], labels: parsed.labels };
132
+ }
133
+ catch {
134
+ // Not valid JSON — try markdown parser
135
+ if (raw.trimStart().startsWith('#')) {
136
+ console.log(chalk.yellow(`\n ⚠ File has .json extension but contains Markdown — parsing as Markdown.\n`));
137
+ config = parseMarkdownIssues(raw);
138
+ detectedFormat = 'Markdown (auto-detected)';
139
+ }
140
+ else {
141
+ console.error(chalk.red(`\n ❌ Fatal: File is not valid JSON or Markdown.\n`));
142
+ return;
143
+ }
144
+ }
145
+ }
146
+ const issues = config.issues;
72
147
  const start = opts.start || 1;
73
148
  const end = opts.end || 999;
74
149
  const filtered = issues.filter((i) => i.number >= start && i.number <= end);
75
150
  console.log(`\n${chalk.bold('📋 GitPadi Issue Creator')}`);
76
- console.log(chalk.dim(` Repo: ${getFullRepo()}`));
77
- console.log(chalk.dim(` File: ${filePath}`));
78
- console.log(chalk.dim(` Range: #${start}-#${end} (${filtered.length} issues)`));
79
- console.log(chalk.dim(` Mode: ${opts.dryRun ? 'DRY RUN' : 'LIVE'}\n`));
80
- if (opts.dryRun) {
81
- filtered.forEach((i) => {
82
- console.log(` ${chalk.dim(`#${String(i.number).padStart(2, '0')}`)} ${i.title}`);
83
- console.log(chalk.dim(` [${i.labels.join(', ')}]`));
84
- });
85
- console.log(chalk.green(`\n✅ Dry run: ${filtered.length} issues would be created.\n`));
151
+ console.log(chalk.dim(` Repo: ${getFullRepo()}`));
152
+ console.log(chalk.dim(` File: ${filePath} (${detectedFormat})`));
153
+ console.log(chalk.dim(` Found: ${filtered.length} issues\n`));
154
+ // Always preview first
155
+ filtered.forEach((i) => {
156
+ console.log(` ${chalk.dim(`#${String(i.number).padStart(2, '0')}`)} ${i.title}`);
157
+ console.log(chalk.dim(` [${(i.labels || []).join(', ')}]`));
158
+ });
159
+ // Ask for confirmation
160
+ const inquirer = (await import('inquirer')).default;
161
+ const { proceed } = await inquirer.prompt([{
162
+ type: 'confirm',
163
+ name: 'proceed',
164
+ message: chalk.yellow(`Create these ${filtered.length} issues on GitHub?`),
165
+ default: true,
166
+ }]);
167
+ if (!proceed) {
168
+ console.log(chalk.dim('\n Cancelled.\n'));
86
169
  return;
87
170
  }
88
171
  const octokit = getOctokit();
89
172
  let created = 0, failed = 0;
90
173
  // Create labels if defined
91
- if (config.labels) {
174
+ if (config.labels && Object.keys(config.labels).length > 0) {
92
175
  const spinner = ora('Setting up labels...').start();
93
176
  try {
94
177
  for (const [name, color] of Object.entries(config.labels)) {
@@ -51,9 +51,11 @@ export async function cloneRepo(name, opts) {
51
51
  try {
52
52
  execSync(`git clone ${url} ${dir}`, { stdio: 'pipe' });
53
53
  spinner.succeed(`Cloned to ${chalk.green(`./${dir}`)}`);
54
+ return true;
54
55
  }
55
56
  catch (e) {
56
57
  spinner.fail(`Clone failed: ${e.message}`);
58
+ return false;
57
59
  }
58
60
  }
59
61
  export async function repoInfo(name, opts) {
@@ -67,9 +69,11 @@ export async function repoInfo(name, opts) {
67
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 });
68
70
  console.log(table.toString());
69
71
  console.log('');
72
+ return repo;
70
73
  }
71
74
  catch (e) {
72
75
  spinner.fail(e.message);
76
+ return null;
73
77
  }
74
78
  }
75
79
  export async function setTopics(name, topics, opts) {
@@ -84,35 +88,41 @@ export async function setTopics(name, topics, opts) {
84
88
  }
85
89
  }
86
90
  export async function listRepos(opts) {
87
- const spinner = ora('Fetching repos...').start();
91
+ const spinner = !opts.silent ? ora('Fetching repos...').start() : null;
88
92
  const octokit = getOctokit();
89
93
  try {
90
94
  let repos;
91
95
  if (opts.org) {
92
- ({ data: repos } = await octokit.repos.listForOrg({ org: opts.org, per_page: opts.limit || 50, sort: 'updated' }));
96
+ ({ data: repos } = await octokit.repos.listForOrg({ org: opts.org, per_page: opts.limit || 100, sort: 'updated' }));
93
97
  }
94
98
  else {
95
- ({ data: repos } = await octokit.repos.listForAuthenticatedUser({ per_page: opts.limit || 50, sort: 'updated' }));
99
+ ({ data: repos } = await octokit.repos.listForAuthenticatedUser({ per_page: opts.limit || 100, sort: 'updated' }));
96
100
  }
97
- spinner.stop();
98
- const table = new Table({
99
- head: ['Name', 'Stars', 'Language', 'Visibility', 'Updated'].map((h) => chalk.cyan(h)),
100
- style: { head: [], border: [] },
101
- });
102
- repos.forEach((r) => {
103
- table.push([
104
- r.full_name,
105
- `⭐ ${r.stargazers_count}`,
106
- r.language || '-',
107
- r.private ? chalk.yellow('private') : chalk.green('public'),
108
- new Date(r.updated_at).toLocaleDateString(),
109
- ]);
110
- });
111
- console.log(`\n${chalk.bold('📦 Repositories')} (${repos.length})\n`);
112
- console.log(table.toString());
113
- console.log('');
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;
114
122
  }
115
123
  catch (e) {
116
- spinner.fail(e.message);
124
+ if (spinner)
125
+ spinner.fail(e.message);
126
+ return [];
117
127
  }
118
128
  }
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import os from 'node:os';
6
+ import { execSync } from 'child_process';
6
7
  const CONFIG_DIR = path.join(os.homedir(), '.gitpadi');
7
8
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
9
  let _octokit = null;
@@ -24,11 +25,29 @@ export function saveConfig(config) {
24
25
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
25
26
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
26
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
+ }
27
44
  export function initGitHub(token, owner, repo) {
28
45
  const config = loadConfig();
46
+ const detected = detectLocalRepo();
29
47
  _token = token || process.env.GITHUB_TOKEN || process.env.GITPADI_TOKEN || config?.token || '';
30
- _owner = owner || process.env.GITHUB_OWNER || config?.owner || process.env.GITHUB_REPOSITORY?.split('/')[0] || '';
31
- _repo = repo || process.env.GITHUB_REPO || config?.repo || process.env.GITHUB_REPOSITORY?.split('/')[1] || '';
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] || '';
32
51
  if (_token) {
33
52
  _octokit = new Octokit({ auth: _token });
34
53
  }
@@ -59,3 +78,29 @@ export function requireRepo() {
59
78
  process.exit(1);
60
79
  }
61
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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gitpadi",
3
- "version": "2.0.1",
4
- "description": "🤖 GitPadi — Your AI-powered GitHub management CLI. Create repos, manage issues & PRs, score contributors, and automate everything.",
3
+ "version": "2.0.3",
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
7
  "gitpadi": "./dist/cli.js"