gitpadi 2.1.5 → 2.1.8

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/dist/cli.js CHANGED
@@ -23,10 +23,11 @@ import * as contribute from './commands/contribute.js';
23
23
  import * as applyForIssue from './commands/apply-for-issue.js';
24
24
  import { runBountyHunter } from './commands/bounty-hunter.js';
25
25
  import { dripsMenu } from './commands/drips.js';
26
+ import { remindContributors } from './remind-contributors.js';
26
27
  import * as gitlabIssues from './commands/gitlab-issues.js';
27
28
  import * as gitlabMRs from './commands/gitlab-mrs.js';
28
29
  import * as gitlabPipelines from './commands/gitlab-pipelines.js';
29
- const VERSION = '2.1.5';
30
+ const VERSION = '2.1.6';
30
31
  let targetConfirmed = false;
31
32
  let gitlabProjectConfirmed = false;
32
33
  // ── Styling ────────────────────────────────────────────────────────────
@@ -1075,6 +1076,7 @@ async function maintainerMenu() {
1075
1076
  { name: `${green('📦')} ${bold('Repositories')} ${dim('— create, delete, clone, topics, info')}`, value: 'repos' },
1076
1077
  { name: `${red('🚀')} ${bold('Releases')} ${dim('— create, list, tag releases')}`, value: 'releases' },
1077
1078
  { name: `${green('🎯')} ${bold('Bounty Hunter')} ${dim('— auto-apply to Drips Wave & GrantFox')}`, value: 'hunt' },
1079
+ { name: `${cyan('🔔')} ${bold('Remind Contributors')} ${dim('— warn assignees with no PR (12h)')}`, value: 'remind' },
1078
1080
  new inquirer.Separator(dim(' ─────────────────────────────────────────')),
1079
1081
  { name: `${dim('⚙️')} ${dim('Switch Repo')}`, value: 'switch' },
1080
1082
  { name: `${dim('⬅')} ${dim('Back to Mode Selector')}`, value: 'back' },
@@ -1100,6 +1102,8 @@ async function maintainerMenu() {
1100
1102
  await safeMenu(releaseMenu);
1101
1103
  else if (category === 'hunt')
1102
1104
  await runBountyHunter({ platform: 'all', maxApplications: 3, dryRun: false });
1105
+ else if (category === 'remind')
1106
+ await safeMenu(remindContributors);
1103
1107
  else if (category === 'review-merge') {
1104
1108
  await ensureTargetRepo();
1105
1109
  const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number to review:') }]);
@@ -218,6 +218,19 @@ export async function ensureDripsAuth() {
218
218
  else {
219
219
  console.log(dim(`\n Open manually: ${cyan(DRIPS_WEB + '/wave/login')}\n`));
220
220
  }
221
+ console.log(dim(' After logging in, get your token:'));
222
+ console.log();
223
+ console.log(dim(' 1. Press ') + bold('F12') + dim(' → click the ') + bold('Console') + dim(' tab'));
224
+ console.log(dim(' 2. Paste this command and press ') + bold('Enter') + dim(':'));
225
+ console.log();
226
+ console.log(' ' + cyan('document.cookie.match(/wave_access_token=([^;]+)/)?.[1]'));
227
+ console.log();
228
+ console.log(dim(' 3. Copy the value shown (starts with ') + yellow('eyJ...') + dim(')'));
229
+ console.log();
230
+ console.log(dim(' ── If Console shows "undefined", use this instead: ──────────'));
231
+ console.log(dim(' F12 → Application tab → Cookies → www.drips.network'));
232
+ console.log(dim(' Find ') + yellow('wave_access_token') + dim(' → copy the Value column'));
233
+ console.log();
221
234
  const { token } = await inquirer.prompt([{
222
235
  type: 'password',
223
236
  name: 'token',
@@ -456,16 +469,113 @@ async function autoApply(program, profile) {
456
469
  console.log(bold(` Done: ${green(applied + ' applied')}${skipped > 0 ? ', ' + yellow(skipped + ' skipped (org limit)') : ''}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
457
470
  console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
458
471
  }
472
+ /**
473
+ * Uses the Drips repos API to search by org/repo name — fast, covers ALL repos.
474
+ */
475
+ async function searchRepos(program, term) {
476
+ const data = await dripsGet(`/api/wave-programs/${program.id}/repos?search=${encodeURIComponent(term)}&limit=20&status=approved`);
477
+ const items = Array.isArray(data?.data) ? data.data : (data ? [data] : []);
478
+ return items.map((item) => ({
479
+ orgId: item.org?.id || '',
480
+ orgLogin: item.org?.gitHubOrgLogin || '',
481
+ repoFullName: item.repo?.gitHubRepoFullName || '',
482
+ repoName: item.repo?.gitHubRepoName || '',
483
+ issueCount: item.issueCount || 0,
484
+ description: item.description || '',
485
+ })).filter(r => r.orgId && r.repoFullName);
486
+ }
487
+ /**
488
+ * Fetch issues filtered by Drips orgId — returns all issues for that org directly.
489
+ */
490
+ async function getIssuesByOrg(programId, orgId, page, limit = 50) {
491
+ return dripsGet(`/api/issues?waveProgramId=${programId}&orgId=${orgId}&state=open&page=${page}&limit=${limit}&sortBy=points`);
492
+ }
459
493
  // ── Browse by org + track ─────────────────────────────────────────────────────
460
494
  async function browseByTrack(program, profile) {
461
- // Step 1: ask for org name
495
+ // Step 1: ask for org/repo name with search fallback
462
496
  const { orgInput } = await inquirer.prompt([{
463
497
  type: 'input',
464
498
  name: 'orgInput',
465
- message: bold('Enter GitHub org name (e.g. stellar, uniswap, openzeppelin):'),
499
+ message: bold('Enter org name or repo name to search (e.g. stellar, Inheritx, uniswap):'),
466
500
  validate: (v) => v.trim().length > 0 || 'Required',
467
501
  }]);
468
- const targetOrg = orgInput.trim().toLowerCase();
502
+ const term = orgInput.trim();
503
+ // Search for matching repos in the wave program
504
+ const searchSpinner = ora(dim(` Searching for "${term}" in ${program.name} wave…`)).start();
505
+ let matches;
506
+ try {
507
+ matches = await searchRepos(program, term);
508
+ }
509
+ catch (e) {
510
+ searchSpinner.fail(` Search failed: ${e.message}`);
511
+ return;
512
+ }
513
+ if (matches.length === 0) {
514
+ searchSpinner.warn(yellow(` No repos found matching "${term}" in the ${program.name} wave.`));
515
+ console.log(dim(' Try a shorter or different search term.\n'));
516
+ return browseByTrack(program, profile);
517
+ }
518
+ searchSpinner.succeed(` Found ${matches.length} repo(s) matching "${term}"`);
519
+ console.log();
520
+ let targetOrgId;
521
+ let displayLabel;
522
+ const exactOrg = matches.find(m => m.orgLogin.toLowerCase() === term.toLowerCase());
523
+ const exactRepo = matches.find(m => m.repoFullName.toLowerCase() === term.toLowerCase());
524
+ if (matches.length === 1) {
525
+ targetOrgId = matches[0].orgId;
526
+ displayLabel = matches[0].repoFullName;
527
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
528
+ }
529
+ else if (exactRepo) {
530
+ targetOrgId = exactRepo.orgId;
531
+ displayLabel = exactRepo.repoFullName;
532
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
533
+ }
534
+ else if (exactOrg) {
535
+ targetOrgId = exactOrg.orgId;
536
+ displayLabel = exactOrg.orgLogin + '/*';
537
+ console.log(dim(` Using org: ${cyan(exactOrg.orgLogin)} (all repos)\n`));
538
+ }
539
+ else {
540
+ // Multiple partial matches — let user pick
541
+ const repoChoices = [];
542
+ const orgGroups = new Map();
543
+ for (const r of matches) {
544
+ const list = orgGroups.get(r.orgLogin) || [];
545
+ list.push(r);
546
+ orgGroups.set(r.orgLogin, list);
547
+ }
548
+ for (const [org, orgRepos] of orgGroups) {
549
+ if (orgRepos.length > 1) {
550
+ const total = orgRepos.reduce((s, r) => s + r.issueCount, 0);
551
+ repoChoices.push({
552
+ name: ` ${cyan(org + '/*')} ${dim(`all ${orgRepos.length} repos · ${total} issues`)}`,
553
+ value: orgRepos[0].orgId,
554
+ short: org + '/*',
555
+ });
556
+ }
557
+ for (const r of orgRepos) {
558
+ repoChoices.push({
559
+ name: ` ${dim('└')} ${cyan(r.repoFullName)} ${dim(r.issueCount + ' issue(s)')}`,
560
+ value: r.orgId,
561
+ short: r.repoFullName,
562
+ });
563
+ }
564
+ }
565
+ repoChoices.push(new inquirer.Separator(dim(' ───────────────────────────')), { name: ` ${dim('🔍 Search again')}`, value: '__search__', short: 'search' });
566
+ const { picked } = await inquirer.prompt([{
567
+ type: 'list',
568
+ name: 'picked',
569
+ message: bold('Pick the org or repo to browse:'),
570
+ choices: repoChoices,
571
+ pageSize: 18,
572
+ }]);
573
+ if (picked === '__search__')
574
+ return browseByTrack(program, profile);
575
+ targetOrgId = picked;
576
+ const pickedRepo = matches.find(r => r.orgId === picked);
577
+ displayLabel = pickedRepo ? pickedRepo.repoFullName : picked;
578
+ }
469
579
  // Step 2: ask for track
470
580
  const { browseTrack } = await inquirer.prompt([{
471
581
  type: 'list',
@@ -480,18 +590,14 @@ async function browseByTrack(program, profile) {
480
590
  ],
481
591
  }]);
482
592
  const selectedTrack = browseTrack;
483
- // Step 3: scan the wave program and collect ALL unassigned issues from this org
484
- const spinner = ora(dim(` Scanning for unassigned ${selectedTrack} issues in ${bold(targetOrg)}…`)).start();
593
+ // Step 3: fetch issues for the selected org via orgId filter
594
+ const spinner = ora(dim(` Fetching unassigned ${selectedTrack} issues for ${bold(displayLabel)}…`)).start();
485
595
  const collected = [];
486
596
  let apiPage = 1;
487
- const MAX_SCAN_PAGES = 20; // scan up to 1000 issues to find the org's full list
488
597
  try {
489
- while (apiPage <= MAX_SCAN_PAGES) {
490
- const res = await fetchIssuePage(program.id, apiPage, 50);
598
+ while (true) {
599
+ const res = await getIssuesByOrg(program.id, targetOrgId, apiPage, 50);
491
600
  for (const issue of res.data) {
492
- const issueOrg = extractOrg(issue);
493
- if (issueOrg !== targetOrg)
494
- continue;
495
601
  if (issue.assignedApplicant !== null)
496
602
  continue;
497
603
  if (!matchesTrack(issue, selectedTrack))
@@ -500,7 +606,7 @@ async function browseByTrack(program, profile) {
500
606
  continue;
501
607
  collected.push(issue);
502
608
  }
503
- spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} — found ${collected.length} issues in ${targetOrg} so far…`);
609
+ spinner.text = dim(` Fetched page ${apiPage}/${res.pagination.totalPages} — ${collected.length} matching issue(s) so far…`);
504
610
  if (!res.pagination.hasNextPage)
505
611
  break;
506
612
  apiPage++;
@@ -513,11 +619,11 @@ async function browseByTrack(program, profile) {
513
619
  // Sort by points descending
514
620
  collected.sort((a, b) => (b.points || 0) - (a.points || 0));
515
621
  if (collected.length === 0) {
516
- spinner.warn(yellow(` No unassigned ${selectedTrack} issues found for org "${targetOrg}" in the ${program.name} wave.`));
517
- console.log(dim(' Check the org name matches exactly (e.g. "stellar" not "Stellar").\n'));
622
+ spinner.warn(yellow(` No unassigned ${selectedTrack} issues found for "${displayLabel}" in the ${program.name} wave.`));
623
+ console.log(dim(' Try a different track, lower min points, or search again.\n'));
518
624
  return;
519
625
  }
520
- spinner.succeed(` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(targetOrg)} — sorted by points`);
626
+ spinner.succeed(` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(displayLabel)} — sorted by points`);
521
627
  // Step 4: paginate through results (20 per page)
522
628
  const PAGE_SIZE = 20;
523
629
  let viewPage = 0;
@@ -553,7 +659,7 @@ async function browseByTrack(program, profile) {
553
659
  const { selected } = await inquirer.prompt([{
554
660
  type: 'list',
555
661
  name: 'selected',
556
- message: bold(`${targetOrg} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
662
+ message: bold(`${displayLabel} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
557
663
  choices: [...choices, ...nav],
558
664
  pageSize: 20,
559
665
  }]);
@@ -114,7 +114,7 @@ function parseMarkdownIssues(content) {
114
114
  }
115
115
  export async function createIssuesFromFile(filePath, opts) {
116
116
  requireRepo();
117
- const resolved = path.resolve(filePath);
117
+ const resolved = path.resolve(filePath.trim());
118
118
  if (!fs.existsSync(resolved)) {
119
119
  console.error(chalk.red(`\n❌ File not found: ${resolved}\n`));
120
120
  return;
@@ -8,7 +8,7 @@ const TIER_3_HOURS = 72; // Auto-unassign
8
8
  const SIG_TIER_1 = '<!-- gitpadi-reminder-24h -->';
9
9
  const SIG_TIER_2 = '<!-- gitpadi-reminder-48h -->';
10
10
  const SIG_TIER_3 = '<!-- gitpadi-unassigned -->';
11
- async function run() {
11
+ export async function remindContributors() {
12
12
  console.log(chalk.bold('\n🚀 GitPadi Escalating Reminder Engine\n'));
13
13
  try {
14
14
  initGitHub();
@@ -124,4 +124,7 @@ ${SIG_TIER_1}`;
124
124
  process.exit(1);
125
125
  }
126
126
  }
127
- run();
127
+ // Standalone entry point (npx tsx src/remind-contributors.ts)
128
+ if (process.argv[1]?.endsWith('remind-contributors.ts') || process.argv[1]?.endsWith('remind-contributors.js')) {
129
+ remindContributors();
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitpadi",
3
- "version": "2.1.5",
3
+ "version": "2.1.8",
4
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": {
package/src/cli.ts CHANGED
@@ -31,11 +31,12 @@ import * as contribute from './commands/contribute.js';
31
31
  import * as applyForIssue from './commands/apply-for-issue.js';
32
32
  import { runBountyHunter } from './commands/bounty-hunter.js';
33
33
  import { dripsMenu } from './commands/drips.js';
34
+ import { remindContributors } from './remind-contributors.js';
34
35
  import * as gitlabIssues from './commands/gitlab-issues.js';
35
36
  import * as gitlabMRs from './commands/gitlab-mrs.js';
36
37
  import * as gitlabPipelines from './commands/gitlab-pipelines.js';
37
38
 
38
- const VERSION = '2.1.5';
39
+ const VERSION = '2.1.6';
39
40
  let targetConfirmed = false;
40
41
  let gitlabProjectConfirmed = false;
41
42
 
@@ -1120,6 +1121,7 @@ async function maintainerMenu() {
1120
1121
  { name: `${green('📦')} ${bold('Repositories')} ${dim('— create, delete, clone, topics, info')}`, value: 'repos' },
1121
1122
  { name: `${red('🚀')} ${bold('Releases')} ${dim('— create, list, tag releases')}`, value: 'releases' },
1122
1123
  { name: `${green('🎯')} ${bold('Bounty Hunter')} ${dim('— auto-apply to Drips Wave & GrantFox')}`, value: 'hunt' },
1124
+ { name: `${cyan('🔔')} ${bold('Remind Contributors')} ${dim('— warn assignees with no PR (12h)')}`, value: 'remind' },
1123
1125
  new inquirer.Separator(dim(' ─────────────────────────────────────────')),
1124
1126
  { name: `${dim('⚙️')} ${dim('Switch Repo')}`, value: 'switch' },
1125
1127
  { name: `${dim('⬅')} ${dim('Back to Mode Selector')}`, value: 'back' },
@@ -1141,6 +1143,7 @@ async function maintainerMenu() {
1141
1143
  else if (category === 'contributors') await safeMenu(contributorScoringMenu);
1142
1144
  else if (category === 'releases') await safeMenu(releaseMenu);
1143
1145
  else if (category === 'hunt') await runBountyHunter({ platform: 'all', maxApplications: 3, dryRun: false });
1146
+ else if (category === 'remind') await safeMenu(remindContributors);
1144
1147
  else if (category === 'review-merge') {
1145
1148
  await ensureTargetRepo();
1146
1149
  const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number to review:') }]);
@@ -285,6 +285,20 @@ export async function ensureDripsAuth(): Promise<string> {
285
285
  console.log(dim(`\n Open manually: ${cyan(DRIPS_WEB + '/wave/login')}\n`));
286
286
  }
287
287
 
288
+ console.log(dim(' After logging in, get your token:'));
289
+ console.log();
290
+ console.log(dim(' 1. Press ') + bold('F12') + dim(' → click the ') + bold('Console') + dim(' tab'));
291
+ console.log(dim(' 2. Paste this command and press ') + bold('Enter') + dim(':'));
292
+ console.log();
293
+ console.log(' ' + cyan('document.cookie.match(/wave_access_token=([^;]+)/)?.[1]'));
294
+ console.log();
295
+ console.log(dim(' 3. Copy the value shown (starts with ') + yellow('eyJ...') + dim(')'));
296
+ console.log();
297
+ console.log(dim(' ── If Console shows "undefined", use this instead: ──────────'));
298
+ console.log(dim(' F12 → Application tab → Cookies → www.drips.network'));
299
+ console.log(dim(' Find ') + yellow('wave_access_token') + dim(' → copy the Value column'));
300
+ console.log();
301
+
288
302
  const { token } = await inquirer.prompt([{
289
303
  type: 'password',
290
304
  name: 'token',
@@ -554,18 +568,146 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
554
568
  console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
555
569
  }
556
570
 
571
+ // ── Repo/org search ───────────────────────────────────────────────────────────
572
+
573
+ interface DripsRepo {
574
+ orgId: string;
575
+ orgLogin: string;
576
+ repoFullName: string;
577
+ repoName: string;
578
+ issueCount: number;
579
+ description: string;
580
+ }
581
+
582
+ /**
583
+ * Uses the Drips repos API to search by org/repo name — fast, covers ALL repos.
584
+ */
585
+ async function searchRepos(program: DripsProgram, term: string): Promise<DripsRepo[]> {
586
+ const data = await dripsGet(
587
+ `/api/wave-programs/${program.id}/repos?search=${encodeURIComponent(term)}&limit=20&status=approved`,
588
+ );
589
+ const items: any[] = Array.isArray(data?.data) ? data.data : (data ? [data] : []);
590
+ return items.map((item: any) => ({
591
+ orgId: item.org?.id || '',
592
+ orgLogin: item.org?.gitHubOrgLogin || '',
593
+ repoFullName: item.repo?.gitHubRepoFullName || '',
594
+ repoName: item.repo?.gitHubRepoName || '',
595
+ issueCount: item.issueCount || 0,
596
+ description: item.description || '',
597
+ })).filter(r => r.orgId && r.repoFullName);
598
+ }
599
+
600
+ /**
601
+ * Fetch issues filtered by Drips orgId — returns all issues for that org directly.
602
+ */
603
+ async function getIssuesByOrg(
604
+ programId: string,
605
+ orgId: string,
606
+ page: number,
607
+ limit: number = 50,
608
+ ): Promise<{ data: DripsIssue[]; pagination: DripsPagination }> {
609
+ return dripsGet(
610
+ `/api/issues?waveProgramId=${programId}&orgId=${orgId}&state=open&page=${page}&limit=${limit}&sortBy=points`,
611
+ );
612
+ }
613
+
557
614
  // ── Browse by org + track ─────────────────────────────────────────────────────
558
615
 
559
616
  async function browseByTrack(program: DripsProgram, profile: DripsProfile): Promise<void> {
560
- // Step 1: ask for org name
617
+ // Step 1: ask for org/repo name with search fallback
561
618
  const { orgInput } = await inquirer.prompt([{
562
619
  type: 'input',
563
620
  name: 'orgInput',
564
- message: bold('Enter GitHub org name (e.g. stellar, uniswap, openzeppelin):'),
621
+ message: bold('Enter org name or repo name to search (e.g. stellar, Inheritx, uniswap):'),
565
622
  validate: (v: string) => v.trim().length > 0 || 'Required',
566
623
  }]);
567
624
 
568
- const targetOrg = orgInput.trim().toLowerCase();
625
+ const term = orgInput.trim();
626
+
627
+ // Search for matching repos in the wave program
628
+ const searchSpinner = ora(dim(` Searching for "${term}" in ${program.name} wave…`)).start();
629
+ let matches: DripsRepo[];
630
+ try {
631
+ matches = await searchRepos(program, term);
632
+ } catch (e: any) {
633
+ searchSpinner.fail(` Search failed: ${e.message}`);
634
+ return;
635
+ }
636
+
637
+ if (matches.length === 0) {
638
+ searchSpinner.warn(yellow(` No repos found matching "${term}" in the ${program.name} wave.`));
639
+ console.log(dim(' Try a shorter or different search term.\n'));
640
+ return browseByTrack(program, profile);
641
+ }
642
+
643
+ searchSpinner.succeed(` Found ${matches.length} repo(s) matching "${term}"`);
644
+ console.log();
645
+
646
+ let targetOrgId: string;
647
+ let displayLabel: string;
648
+
649
+ const exactOrg = matches.find(m => m.orgLogin.toLowerCase() === term.toLowerCase());
650
+ const exactRepo = matches.find(m => m.repoFullName.toLowerCase() === term.toLowerCase());
651
+
652
+ if (matches.length === 1) {
653
+ targetOrgId = matches[0].orgId;
654
+ displayLabel = matches[0].repoFullName;
655
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
656
+ } else if (exactRepo) {
657
+ targetOrgId = exactRepo.orgId;
658
+ displayLabel = exactRepo.repoFullName;
659
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
660
+ } else if (exactOrg) {
661
+ targetOrgId = exactOrg.orgId;
662
+ displayLabel = exactOrg.orgLogin + '/*';
663
+ console.log(dim(` Using org: ${cyan(exactOrg.orgLogin)} (all repos)\n`));
664
+ } else {
665
+ // Multiple partial matches — let user pick
666
+ const repoChoices: any[] = [];
667
+
668
+ const orgGroups = new Map<string, DripsRepo[]>();
669
+ for (const r of matches) {
670
+ const list = orgGroups.get(r.orgLogin) || [];
671
+ list.push(r);
672
+ orgGroups.set(r.orgLogin, list);
673
+ }
674
+
675
+ for (const [org, orgRepos] of orgGroups) {
676
+ if (orgRepos.length > 1) {
677
+ const total = orgRepos.reduce((s, r) => s + r.issueCount, 0);
678
+ repoChoices.push({
679
+ name: ` ${cyan(org + '/*')} ${dim(`all ${orgRepos.length} repos · ${total} issues`)}`,
680
+ value: orgRepos[0].orgId,
681
+ short: org + '/*',
682
+ });
683
+ }
684
+ for (const r of orgRepos) {
685
+ repoChoices.push({
686
+ name: ` ${dim('└')} ${cyan(r.repoFullName)} ${dim(r.issueCount + ' issue(s)')}`,
687
+ value: r.orgId,
688
+ short: r.repoFullName,
689
+ });
690
+ }
691
+ }
692
+
693
+ repoChoices.push(
694
+ new inquirer.Separator(dim(' ───────────────────────────')) as any,
695
+ { name: ` ${dim('🔍 Search again')}`, value: '__search__', short: 'search' },
696
+ );
697
+
698
+ const { picked } = await inquirer.prompt([{
699
+ type: 'list',
700
+ name: 'picked',
701
+ message: bold('Pick the org or repo to browse:'),
702
+ choices: repoChoices,
703
+ pageSize: 18,
704
+ }]);
705
+
706
+ if (picked === '__search__') return browseByTrack(program, profile);
707
+ targetOrgId = picked;
708
+ const pickedRepo = matches.find(r => r.orgId === picked);
709
+ displayLabel = pickedRepo ? pickedRepo.repoFullName : picked;
710
+ }
569
711
 
570
712
  // Step 2: ask for track
571
713
  const { browseTrack } = await inquirer.prompt([{
@@ -583,27 +725,24 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
583
725
 
584
726
  const selectedTrack: Track = browseTrack;
585
727
 
586
- // Step 3: scan the wave program and collect ALL unassigned issues from this org
587
- const spinner = ora(dim(` Scanning for unassigned ${selectedTrack} issues in ${bold(targetOrg)}…`)).start();
728
+ // Step 3: fetch issues for the selected org via orgId filter
729
+ const spinner = ora(dim(` Fetching unassigned ${selectedTrack} issues for ${bold(displayLabel)}…`)).start();
588
730
 
589
731
  const collected: DripsIssue[] = [];
590
732
  let apiPage = 1;
591
- const MAX_SCAN_PAGES = 20; // scan up to 1000 issues to find the org's full list
592
733
 
593
734
  try {
594
- while (apiPage <= MAX_SCAN_PAGES) {
595
- const res = await fetchIssuePage(program.id, apiPage, 50);
735
+ while (true) {
736
+ const res = await getIssuesByOrg(program.id, targetOrgId, apiPage, 50);
596
737
 
597
738
  for (const issue of res.data) {
598
- const issueOrg = extractOrg(issue);
599
- if (issueOrg !== targetOrg) continue;
600
739
  if (issue.assignedApplicant !== null) continue;
601
740
  if (!matchesTrack(issue, selectedTrack)) continue;
602
741
  if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints) continue;
603
742
  collected.push(issue);
604
743
  }
605
744
 
606
- spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} — found ${collected.length} issues in ${targetOrg} so far…`);
745
+ spinner.text = dim(` Fetched page ${apiPage}/${res.pagination.totalPages} — ${collected.length} matching issue(s) so far…`);
607
746
 
608
747
  if (!res.pagination.hasNextPage) break;
609
748
  apiPage++;
@@ -617,13 +756,13 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
617
756
  collected.sort((a, b) => (b.points || 0) - (a.points || 0));
618
757
 
619
758
  if (collected.length === 0) {
620
- spinner.warn(yellow(` No unassigned ${selectedTrack} issues found for org "${targetOrg}" in the ${program.name} wave.`));
621
- console.log(dim(' Check the org name matches exactly (e.g. "stellar" not "Stellar").\n'));
759
+ spinner.warn(yellow(` No unassigned ${selectedTrack} issues found for "${displayLabel}" in the ${program.name} wave.`));
760
+ console.log(dim(' Try a different track, lower min points, or search again.\n'));
622
761
  return;
623
762
  }
624
763
 
625
764
  spinner.succeed(
626
- ` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(targetOrg)} — sorted by points`
765
+ ` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(displayLabel)} — sorted by points`
627
766
  );
628
767
 
629
768
  // Step 4: paginate through results (20 per page)
@@ -664,7 +803,7 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
664
803
  const { selected } = await inquirer.prompt([{
665
804
  type: 'list',
666
805
  name: 'selected',
667
- message: bold(`${targetOrg} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
806
+ message: bold(`${displayLabel} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
668
807
  choices: [...choices, ...nav],
669
808
  pageSize: 20,
670
809
  }]);
@@ -131,7 +131,7 @@ function parseMarkdownIssues(content: string): { issues: any[]; labels: Record<s
131
131
 
132
132
  export async function createIssuesFromFile(filePath: string, opts: { dryRun?: boolean; start?: number; end?: number }) {
133
133
  requireRepo();
134
- const resolved = path.resolve(filePath);
134
+ const resolved = path.resolve(filePath.trim());
135
135
  if (!fs.existsSync(resolved)) {
136
136
  console.error(chalk.red(`\n❌ File not found: ${resolved}\n`));
137
137
  return;
@@ -11,7 +11,7 @@ const SIG_TIER_1 = '<!-- gitpadi-reminder-24h -->';
11
11
  const SIG_TIER_2 = '<!-- gitpadi-reminder-48h -->';
12
12
  const SIG_TIER_3 = '<!-- gitpadi-unassigned -->';
13
13
 
14
- async function run() {
14
+ export async function remindContributors() {
15
15
  console.log(chalk.bold('\n🚀 GitPadi Escalating Reminder Engine\n'));
16
16
 
17
17
  try {
@@ -156,4 +156,7 @@ ${SIG_TIER_1}`;
156
156
  }
157
157
  }
158
158
 
159
- run();
159
+ // Standalone entry point (npx tsx src/remind-contributors.ts)
160
+ if (process.argv[1]?.endsWith('remind-contributors.ts') || process.argv[1]?.endsWith('remind-contributors.js')) {
161
+ remindContributors();
162
+ }