gitpadi 2.1.4 → 2.1.6

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
@@ -26,7 +26,7 @@ import { dripsMenu } from './commands/drips.js';
26
26
  import * as gitlabIssues from './commands/gitlab-issues.js';
27
27
  import * as gitlabMRs from './commands/gitlab-mrs.js';
28
28
  import * as gitlabPipelines from './commands/gitlab-pipelines.js';
29
- const VERSION = '2.1.4';
29
+ const VERSION = '2.1.6';
30
30
  let targetConfirmed = false;
31
31
  let gitlabProjectConfirmed = false;
32
32
  // ── Styling ────────────────────────────────────────────────────────────
@@ -456,13 +456,127 @@ async function autoApply(program, profile) {
456
456
  console.log(bold(` Done: ${green(applied + ' applied')}${skipped > 0 ? ', ' + yellow(skipped + ' skipped (org limit)') : ''}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
457
457
  console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
458
458
  }
459
- // ── Browse by track ───────────────────────────────────────────────────────────
459
+ // ── Repo/org search ───────────────────────────────────────────────────────────
460
+ /**
461
+ * Scans the wave program and returns unique repos whose org or repo name
462
+ * contains the search term (case-insensitive).
463
+ */
464
+ async function searchRepos(program, term) {
465
+ const termLower = term.toLowerCase();
466
+ const repoMap = new Map();
467
+ const MAX_SEARCH_PAGES = 20;
468
+ for (let p = 1; p <= MAX_SEARCH_PAGES; p++) {
469
+ const res = await fetchIssuePage(program.id, p, 50);
470
+ for (const issue of res.data) {
471
+ if (!issue.repo)
472
+ continue;
473
+ const fullName = issue.repo.fullName.toLowerCase();
474
+ const [org, repo] = fullName.split('/');
475
+ if (!org.includes(termLower) && !repo.includes(termLower))
476
+ continue;
477
+ const existing = repoMap.get(fullName);
478
+ if (existing) {
479
+ existing.issueCount++;
480
+ }
481
+ else {
482
+ repoMap.set(fullName, { org, repo, fullName, issueCount: 1 });
483
+ }
484
+ }
485
+ if (!res.pagination.hasNextPage)
486
+ break;
487
+ }
488
+ return Array.from(repoMap.values()).sort((a, b) => b.issueCount - a.issueCount);
489
+ }
490
+ // ── Browse by org + track ─────────────────────────────────────────────────────
460
491
  async function browseByTrack(program, profile) {
461
- // Ask which track to browse if they want to override
492
+ // Step 1: ask for org/repo name with search fallback
493
+ const { orgInput } = await inquirer.prompt([{
494
+ type: 'input',
495
+ name: 'orgInput',
496
+ message: bold('Enter org name or repo name to search (e.g. stellar, Inheritx, uniswap):'),
497
+ validate: (v) => v.trim().length > 0 || 'Required',
498
+ }]);
499
+ const term = orgInput.trim();
500
+ // Search for matching repos in the wave program
501
+ const searchSpinner = ora(dim(` Searching for "${term}" in ${program.name} wave…`)).start();
502
+ let matches;
503
+ try {
504
+ matches = await searchRepos(program, term);
505
+ }
506
+ catch (e) {
507
+ searchSpinner.fail(` Search failed: ${e.message}`);
508
+ return;
509
+ }
510
+ if (matches.length === 0) {
511
+ searchSpinner.warn(yellow(` No repos found matching "${term}" in the ${program.name} wave.`));
512
+ console.log(dim(' Try a shorter or different search term.\n'));
513
+ return browseByTrack(program, profile);
514
+ }
515
+ searchSpinner.succeed(` Found ${matches.length} repo(s) matching "${term}"`);
516
+ console.log();
517
+ // If exact single match, use it directly — otherwise let user pick
518
+ // Value is the full repo name (org/repo) so we can filter precisely
519
+ let targetFullName; // e.g. "inheritx/smart-contracts" or "stellar" (org-level)
520
+ let displayLabel;
521
+ const exactOrg = matches.find(m => m.org === term.toLowerCase());
522
+ const exactRepo = matches.find(m => m.fullName === term.toLowerCase());
523
+ if (matches.length === 1) {
524
+ targetFullName = matches[0].fullName;
525
+ displayLabel = matches[0].fullName;
526
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
527
+ }
528
+ else if (exactRepo) {
529
+ targetFullName = exactRepo.fullName;
530
+ displayLabel = exactRepo.fullName;
531
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
532
+ }
533
+ else if (exactOrg) {
534
+ // Exact org match — show all repos under that org
535
+ targetFullName = exactOrg.org; // org-level (prefix match)
536
+ displayLabel = exactOrg.org + '/*';
537
+ console.log(dim(` Using org: ${cyan(exactOrg.org)} (all repos)\n`));
538
+ }
539
+ else {
540
+ // Multiple partial matches — let user pick repo or entire org
541
+ const repoChoices = [];
542
+ // Group by org: if org has multiple repos, offer org-level option first
543
+ const orgs = [...new Set(matches.map(m => m.org))];
544
+ for (const org of orgs) {
545
+ const orgRepos = matches.filter(m => m.org === org);
546
+ if (orgRepos.length > 1) {
547
+ const total = orgRepos.reduce((s, r) => s + r.issueCount, 0);
548
+ repoChoices.push({
549
+ name: ` ${cyan(org + '/*')} ${dim(`all ${orgRepos.length} repos · ${total} issues`)}`,
550
+ value: org,
551
+ short: org + '/*',
552
+ });
553
+ }
554
+ for (const r of orgRepos) {
555
+ repoChoices.push({
556
+ name: ` ${dim('└')} ${cyan(r.fullName)} ${dim(r.issueCount + ' issue(s)')}`,
557
+ value: r.fullName,
558
+ short: r.fullName,
559
+ });
560
+ }
561
+ }
562
+ repoChoices.push(new inquirer.Separator(dim(' ───────────────────────────')), { name: ` ${dim('🔍 Search again')}`, value: '__search__', short: 'search' });
563
+ const { picked } = await inquirer.prompt([{
564
+ type: 'list',
565
+ name: 'picked',
566
+ message: bold('Pick the org or repo to browse:'),
567
+ choices: repoChoices,
568
+ pageSize: 18,
569
+ }]);
570
+ if (picked === '__search__')
571
+ return browseByTrack(program, profile);
572
+ targetFullName = picked;
573
+ displayLabel = picked.includes('/') && !picked.endsWith('/*') ? picked : picked + '/*';
574
+ }
575
+ // Step 2: ask for track
462
576
  const { browseTrack } = await inquirer.prompt([{
463
577
  type: 'list',
464
578
  name: 'browseTrack',
465
- message: bold('Browse which track?'),
579
+ message: bold('Filter by track:'),
466
580
  default: profile.track,
467
581
  choices: [
468
582
  { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
@@ -472,40 +586,60 @@ async function browseByTrack(program, profile) {
472
586
  ],
473
587
  }]);
474
588
  const selectedTrack = browseTrack;
475
- let page = 1;
476
- while (true) {
477
- const spinner = ora(dim(` Fetching unassigned issues (page ${page})…`)).start();
478
- let allIssues = [];
479
- let hasNextPage = false;
480
- let totalFetched = 0;
481
- // Fetch a batch and filter client-side for unassigned + track
482
- try {
483
- // Fetch 2 pages worth (100 issues) to ensure we have enough unassigned after filtering
484
- const batchStart = (page - 1) * 2 + 1;
485
- const pageA = await fetchIssuePage(program.id, batchStart, 50);
486
- const pageB = pageA.pagination.hasNextPage
487
- ? await fetchIssuePage(program.id, batchStart + 1, 50)
488
- : { data: [], pagination: { ...pageA.pagination, hasNextPage: false } };
489
- const raw = [...pageA.data, ...pageB.data];
490
- totalFetched = raw.length;
491
- // Filter: unassigned + track + min points
492
- allIssues = raw.filter(i => i.assignedApplicant === null &&
493
- matchesTrack(i, selectedTrack) &&
494
- (profile.minPoints === 0 || i.points === null || i.points >= profile.minPoints));
495
- // Sort by points descending (highest reward first)
496
- allIssues.sort((a, b) => (b.points || 0) - (a.points || 0));
497
- hasNextPage = pageB.pagination.hasNextPage || (pageA.pagination.hasNextPage && !pageB.pagination.hasNextPage);
498
- spinner.succeed(` ${allIssues.length} unassigned ${selectedTrack} issue(s) found (from ${totalFetched} scanned) — sorted by points`);
499
- }
500
- catch (e) {
501
- spinner.fail(` ${e.message}`);
502
- return;
503
- }
504
- if (allIssues.length === 0) {
505
- console.log(yellow(`\n No unassigned ${selectedTrack} issues found on this page. Try next page or change track.\n`));
589
+ // Step 3: scan and collect — match by exact repo fullName or org prefix
590
+ const isOrgLevel = !targetFullName.includes('/'); // "stellar" vs "stellar/js-stellar-sdk"
591
+ const spinner = ora(dim(` Scanning ${program.name} for unassigned ${selectedTrack} issues in ${bold(displayLabel)}…`)).start();
592
+ const collected = [];
593
+ let apiPage = 1;
594
+ const MAX_SCAN_PAGES = 20;
595
+ try {
596
+ while (apiPage <= MAX_SCAN_PAGES) {
597
+ const res = await fetchIssuePage(program.id, apiPage, 50);
598
+ for (const issue of res.data) {
599
+ if (!issue.repo)
600
+ continue;
601
+ const issueFullName = issue.repo.fullName.toLowerCase();
602
+ const issueOrg = issueFullName.split('/')[0];
603
+ // Match: exact repo OR org-level (all repos under that org)
604
+ const matches = isOrgLevel
605
+ ? issueOrg === targetFullName
606
+ : issueFullName === targetFullName;
607
+ if (!matches)
608
+ continue;
609
+ if (issue.assignedApplicant !== null)
610
+ continue;
611
+ if (!matchesTrack(issue, selectedTrack))
612
+ continue;
613
+ if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints)
614
+ continue;
615
+ collected.push(issue);
616
+ }
617
+ spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} — ${collected.length} matching issue(s) so far…`);
618
+ if (!res.pagination.hasNextPage)
619
+ break;
620
+ apiPage++;
506
621
  }
622
+ }
623
+ catch (e) {
624
+ spinner.fail(` ${e.message}`);
625
+ return;
626
+ }
627
+ // Sort by points descending
628
+ collected.sort((a, b) => (b.points || 0) - (a.points || 0));
629
+ if (collected.length === 0) {
630
+ spinner.warn(yellow(` No unassigned ${selectedTrack} issues found for "${displayLabel}" in the ${program.name} wave.`));
631
+ console.log(dim(' Try a different track, lower min points, or search again.\n'));
632
+ return;
633
+ }
634
+ spinner.succeed(` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(displayLabel)} — sorted by points`);
635
+ // Step 4: paginate through results (20 per page)
636
+ const PAGE_SIZE = 20;
637
+ let viewPage = 0;
638
+ while (true) {
639
+ const pageSlice = collected.slice(viewPage * PAGE_SIZE, (viewPage + 1) * PAGE_SIZE);
640
+ const totalPages = Math.ceil(collected.length / PAGE_SIZE);
507
641
  console.log();
508
- const choices = allIssues.slice(0, 20).map((issue) => {
642
+ const choices = pageSlice.map((issue) => {
509
643
  const age = diffDays(issue.updatedAt);
510
644
  const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
511
645
  const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
@@ -524,26 +658,29 @@ async function browseByTrack(program, profile) {
524
658
  const nav = [
525
659
  new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
526
660
  ];
527
- if (hasNextPage)
528
- nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
529
- if (page > 1)
530
- nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
661
+ if ((viewPage + 1) < totalPages)
662
+ nav.push({ name: ` ${dim(`→ Next page (${viewPage + 2}/${totalPages})`)}`, value: '__next__' });
663
+ if (viewPage > 0)
664
+ nav.push({ name: ` ${dim(`← Previous page (${viewPage}/${totalPages})`)}`, value: '__prev__' });
665
+ nav.push({ name: ` ${dim('🔍 Search a different org')}`, value: '__reorg__' });
531
666
  nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
532
667
  const { selected } = await inquirer.prompt([{
533
668
  type: 'list',
534
669
  name: 'selected',
535
- message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
536
- choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
537
- pageSize: 18,
670
+ message: bold(`${displayLabel} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
671
+ choices: [...choices, ...nav],
672
+ pageSize: 20,
538
673
  }]);
539
674
  if (selected === '__back__')
540
675
  return;
676
+ if (selected === '__reorg__')
677
+ return browseByTrack(program, profile);
541
678
  if (selected === '__next__') {
542
- page++;
679
+ viewPage++;
543
680
  continue;
544
681
  }
545
682
  if (selected === '__prev__') {
546
- page = Math.max(1, page - 1);
683
+ viewPage = Math.max(0, viewPage - 1);
547
684
  continue;
548
685
  }
549
686
  await applySingle(program, selected, profile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitpadi",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
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
@@ -35,7 +35,7 @@ import * as gitlabIssues from './commands/gitlab-issues.js';
35
35
  import * as gitlabMRs from './commands/gitlab-mrs.js';
36
36
  import * as gitlabPipelines from './commands/gitlab-pipelines.js';
37
37
 
38
- const VERSION = '2.1.4';
38
+ const VERSION = '2.1.6';
39
39
  let targetConfirmed = false;
40
40
  let gitlabProjectConfirmed = false;
41
41
 
@@ -554,70 +554,218 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
554
554
  console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
555
555
  }
556
556
 
557
- // ── Browse by track ───────────────────────────────────────────────────────────
557
+ // ── Repo/org search ───────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * Scans the wave program and returns unique repos whose org or repo name
561
+ * contains the search term (case-insensitive).
562
+ */
563
+ async function searchRepos(
564
+ program: DripsProgram,
565
+ term: string,
566
+ ): Promise<Array<{ org: string; repo: string; fullName: string; issueCount: number }>> {
567
+ const termLower = term.toLowerCase();
568
+ const repoMap = new Map<string, { org: string; repo: string; fullName: string; issueCount: number }>();
569
+ const MAX_SEARCH_PAGES = 20;
570
+
571
+ for (let p = 1; p <= MAX_SEARCH_PAGES; p++) {
572
+ const res = await fetchIssuePage(program.id, p, 50);
573
+
574
+ for (const issue of res.data) {
575
+ if (!issue.repo) continue;
576
+ const fullName = issue.repo.fullName.toLowerCase();
577
+ const [org, repo] = fullName.split('/');
578
+ if (!org.includes(termLower) && !repo.includes(termLower)) continue;
579
+
580
+ const existing = repoMap.get(fullName);
581
+ if (existing) {
582
+ existing.issueCount++;
583
+ } else {
584
+ repoMap.set(fullName, { org, repo, fullName, issueCount: 1 });
585
+ }
586
+ }
587
+
588
+ if (!res.pagination.hasNextPage) break;
589
+ }
590
+
591
+ return Array.from(repoMap.values()).sort((a, b) => b.issueCount - a.issueCount);
592
+ }
593
+
594
+ // ── Browse by org + track ─────────────────────────────────────────────────────
558
595
 
559
596
  async function browseByTrack(program: DripsProgram, profile: DripsProfile): Promise<void> {
560
- // Ask which track to browse if they want to override
597
+ // Step 1: ask for org/repo name with search fallback
598
+ const { orgInput } = await inquirer.prompt([{
599
+ type: 'input',
600
+ name: 'orgInput',
601
+ message: bold('Enter org name or repo name to search (e.g. stellar, Inheritx, uniswap):'),
602
+ validate: (v: string) => v.trim().length > 0 || 'Required',
603
+ }]);
604
+
605
+ const term = orgInput.trim();
606
+
607
+ // Search for matching repos in the wave program
608
+ const searchSpinner = ora(dim(` Searching for "${term}" in ${program.name} wave…`)).start();
609
+ let matches: Array<{ org: string; repo: string; fullName: string; issueCount: number }>;
610
+ try {
611
+ matches = await searchRepos(program, term);
612
+ } catch (e: any) {
613
+ searchSpinner.fail(` Search failed: ${e.message}`);
614
+ return;
615
+ }
616
+
617
+ if (matches.length === 0) {
618
+ searchSpinner.warn(yellow(` No repos found matching "${term}" in the ${program.name} wave.`));
619
+ console.log(dim(' Try a shorter or different search term.\n'));
620
+ return browseByTrack(program, profile);
621
+ }
622
+
623
+ searchSpinner.succeed(` Found ${matches.length} repo(s) matching "${term}"`);
624
+ console.log();
625
+
626
+ // If exact single match, use it directly — otherwise let user pick
627
+ // Value is the full repo name (org/repo) so we can filter precisely
628
+ let targetFullName: string; // e.g. "inheritx/smart-contracts" or "stellar" (org-level)
629
+ let displayLabel: string;
630
+
631
+ const exactOrg = matches.find(m => m.org === term.toLowerCase());
632
+ const exactRepo = matches.find(m => m.fullName === term.toLowerCase());
633
+
634
+ if (matches.length === 1) {
635
+ targetFullName = matches[0].fullName;
636
+ displayLabel = matches[0].fullName;
637
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
638
+ } else if (exactRepo) {
639
+ targetFullName = exactRepo.fullName;
640
+ displayLabel = exactRepo.fullName;
641
+ console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
642
+ } else if (exactOrg) {
643
+ // Exact org match — show all repos under that org
644
+ targetFullName = exactOrg.org; // org-level (prefix match)
645
+ displayLabel = exactOrg.org + '/*';
646
+ console.log(dim(` Using org: ${cyan(exactOrg.org)} (all repos)\n`));
647
+ } else {
648
+ // Multiple partial matches — let user pick repo or entire org
649
+ const repoChoices: any[] = [];
650
+
651
+ // Group by org: if org has multiple repos, offer org-level option first
652
+ const orgs = [...new Set(matches.map(m => m.org))];
653
+ for (const org of orgs) {
654
+ const orgRepos = matches.filter(m => m.org === org);
655
+ if (orgRepos.length > 1) {
656
+ const total = orgRepos.reduce((s, r) => s + r.issueCount, 0);
657
+ repoChoices.push({
658
+ name: ` ${cyan(org + '/*')} ${dim(`all ${orgRepos.length} repos · ${total} issues`)}`,
659
+ value: org,
660
+ short: org + '/*',
661
+ });
662
+ }
663
+ for (const r of orgRepos) {
664
+ repoChoices.push({
665
+ name: ` ${dim('└')} ${cyan(r.fullName)} ${dim(r.issueCount + ' issue(s)')}`,
666
+ value: r.fullName,
667
+ short: r.fullName,
668
+ });
669
+ }
670
+ }
671
+
672
+ repoChoices.push(
673
+ new inquirer.Separator(dim(' ───────────────────────────')) as any,
674
+ { name: ` ${dim('🔍 Search again')}`, value: '__search__', short: 'search' },
675
+ );
676
+
677
+ const { picked } = await inquirer.prompt([{
678
+ type: 'list',
679
+ name: 'picked',
680
+ message: bold('Pick the org or repo to browse:'),
681
+ choices: repoChoices,
682
+ pageSize: 18,
683
+ }]);
684
+
685
+ if (picked === '__search__') return browseByTrack(program, profile);
686
+ targetFullName = picked;
687
+ displayLabel = picked.includes('/') && !picked.endsWith('/*') ? picked : picked + '/*';
688
+ }
689
+
690
+ // Step 2: ask for track
561
691
  const { browseTrack } = await inquirer.prompt([{
562
692
  type: 'list',
563
693
  name: 'browseTrack',
564
- message: bold('Browse which track?'),
694
+ message: bold('Filter by track:'),
565
695
  default: profile.track,
566
696
  choices: [
567
- { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
568
- { name: ` ${magenta('🎨')} Frontend`, value: 'frontend' },
697
+ { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
698
+ { name: ` ${magenta('🎨')} Frontend`, value: 'frontend' },
569
699
  { name: ` ${yellow('📜')} Smart Contract`, value: 'contract' },
570
- { name: ` ${green('🌐')} All tracks`, value: 'all' },
700
+ { name: ` ${green('🌐')} All tracks`, value: 'all' },
571
701
  ],
572
702
  }]);
573
703
 
574
704
  const selectedTrack: Track = browseTrack;
575
- let page = 1;
576
705
 
577
- while (true) {
578
- const spinner = ora(dim(` Fetching unassigned issues (page ${page})…`)).start();
579
- let allIssues: DripsIssue[] = [];
580
- let hasNextPage = false;
581
- let totalFetched = 0;
706
+ // Step 3: scan and collect — match by exact repo fullName or org prefix
707
+ const isOrgLevel = !targetFullName.includes('/'); // "stellar" vs "stellar/js-stellar-sdk"
708
+ const spinner = ora(dim(` Scanning ${program.name} for unassigned ${selectedTrack} issues in ${bold(displayLabel)}…`)).start();
582
709
 
583
- // Fetch a batch and filter client-side for unassigned + track
584
- try {
585
- // Fetch 2 pages worth (100 issues) to ensure we have enough unassigned after filtering
586
- const batchStart = (page - 1) * 2 + 1;
587
- const pageA = await fetchIssuePage(program.id, batchStart, 50);
588
- const pageB = pageA.pagination.hasNextPage
589
- ? await fetchIssuePage(program.id, batchStart + 1, 50)
590
- : { data: [], pagination: { ...pageA.pagination, hasNextPage: false } };
591
-
592
- const raw = [...pageA.data, ...pageB.data];
593
- totalFetched = raw.length;
594
-
595
- // Filter: unassigned + track + min points
596
- allIssues = raw.filter(i =>
597
- i.assignedApplicant === null &&
598
- matchesTrack(i, selectedTrack) &&
599
- (profile.minPoints === 0 || i.points === null || i.points >= profile.minPoints)
600
- );
710
+ const collected: DripsIssue[] = [];
711
+ let apiPage = 1;
712
+ const MAX_SCAN_PAGES = 20;
601
713
 
602
- // Sort by points descending (highest reward first)
603
- allIssues.sort((a, b) => (b.points || 0) - (a.points || 0));
714
+ try {
715
+ while (apiPage <= MAX_SCAN_PAGES) {
716
+ const res = await fetchIssuePage(program.id, apiPage, 50);
717
+
718
+ for (const issue of res.data) {
719
+ if (!issue.repo) continue;
720
+ const issueFullName = issue.repo.fullName.toLowerCase();
721
+ const issueOrg = issueFullName.split('/')[0];
722
+
723
+ // Match: exact repo OR org-level (all repos under that org)
724
+ const matches = isOrgLevel
725
+ ? issueOrg === targetFullName
726
+ : issueFullName === targetFullName;
727
+ if (!matches) continue;
728
+
729
+ if (issue.assignedApplicant !== null) continue;
730
+ if (!matchesTrack(issue, selectedTrack)) continue;
731
+ if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints) continue;
732
+ collected.push(issue);
733
+ }
604
734
 
605
- hasNextPage = pageB.pagination.hasNextPage || (pageA.pagination.hasNextPage && !pageB.pagination.hasNextPage);
735
+ spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} — ${collected.length} matching issue(s) so far…`);
606
736
 
607
- spinner.succeed(
608
- ` ${allIssues.length} unassigned ${selectedTrack} issue(s) found (from ${totalFetched} scanned) — sorted by points`
609
- );
610
- } catch (e: any) {
611
- spinner.fail(` ${e.message}`);
612
- return;
737
+ if (!res.pagination.hasNextPage) break;
738
+ apiPage++;
613
739
  }
740
+ } catch (e: any) {
741
+ spinner.fail(` ${e.message}`);
742
+ return;
743
+ }
614
744
 
615
- if (allIssues.length === 0) {
616
- console.log(yellow(`\n No unassigned ${selectedTrack} issues found on this page. Try next page or change track.\n`));
617
- }
745
+ // Sort by points descending
746
+ collected.sort((a, b) => (b.points || 0) - (a.points || 0));
747
+
748
+ if (collected.length === 0) {
749
+ spinner.warn(yellow(` No unassigned ${selectedTrack} issues found for "${displayLabel}" in the ${program.name} wave.`));
750
+ console.log(dim(' Try a different track, lower min points, or search again.\n'));
751
+ return;
752
+ }
753
+
754
+ spinner.succeed(
755
+ ` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(displayLabel)} — sorted by points`
756
+ );
757
+
758
+ // Step 4: paginate through results (20 per page)
759
+ const PAGE_SIZE = 20;
760
+ let viewPage = 0;
761
+
762
+ while (true) {
763
+ const pageSlice = collected.slice(viewPage * PAGE_SIZE, (viewPage + 1) * PAGE_SIZE);
764
+ const totalPages = Math.ceil(collected.length / PAGE_SIZE);
618
765
 
619
766
  console.log();
620
- const choices = allIssues.slice(0, 20).map((issue) => {
767
+
768
+ const choices = pageSlice.map((issue) => {
621
769
  const age = diffDays(issue.updatedAt);
622
770
  const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
623
771
  const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
@@ -637,21 +785,23 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
637
785
  const nav: any[] = [
638
786
  new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
639
787
  ];
640
- if (hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
641
- if (page > 1) nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
788
+ if ((viewPage + 1) < totalPages) nav.push({ name: ` ${dim(`→ Next page (${viewPage + 2}/${totalPages})`)}`, value: '__next__' });
789
+ if (viewPage > 0) nav.push({ name: ` ${dim(`← Previous page (${viewPage}/${totalPages})`)}`, value: '__prev__' });
790
+ nav.push({ name: ` ${dim('🔍 Search a different org')}`, value: '__reorg__' });
642
791
  nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
643
792
 
644
793
  const { selected } = await inquirer.prompt([{
645
794
  type: 'list',
646
795
  name: 'selected',
647
- message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
648
- choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
649
- pageSize: 18,
796
+ message: bold(`${displayLabel} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
797
+ choices: [...choices, ...nav],
798
+ pageSize: 20,
650
799
  }]);
651
800
 
652
801
  if (selected === '__back__') return;
653
- if (selected === '__next__') { page++; continue; }
654
- if (selected === '__prev__') { page = Math.max(1, page - 1); continue; }
802
+ if (selected === '__reorg__') return browseByTrack(program, profile);
803
+ if (selected === '__next__') { viewPage++; continue; }
804
+ if (selected === '__prev__') { viewPage = Math.max(0, viewPage - 1); continue; }
655
805
 
656
806
  await applySingle(program, selected as DripsIssue, profile);
657
807
  }