gitpadi 2.1.3 → 2.1.5

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.3';
29
+ const VERSION = '2.1.5';
30
30
  let targetConfirmed = false;
31
31
  let gitlabProjectConfirmed = false;
32
32
  // ── Styling ────────────────────────────────────────────────────────────
@@ -96,6 +96,11 @@ function parseSlug(input) {
96
96
  function issueLabels(issue) {
97
97
  return issue.labels.map(l => l.name.toLowerCase());
98
98
  }
99
+ const ORG_APPLICATION_LIMIT = 4;
100
+ /** Extract the org/owner from a repo fullName like "myorg/myrepo" */
101
+ function extractOrg(issue) {
102
+ return issue.repo?.fullName?.split('/')[0]?.toLowerCase() || '__unknown__';
103
+ }
99
104
  /** Detect which track an issue belongs to based on its labels and title */
100
105
  function detectTrack(issue) {
101
106
  const haystack = [...issueLabels(issue), issue.title.toLowerCase()].join(' ');
@@ -265,13 +270,12 @@ async function setupProfile(existing) {
265
270
  type: 'list',
266
271
  name: 'minPoints',
267
272
  message: bold('Minimum points to apply for:'),
268
- default: existing?.minPoints || 0,
273
+ default: existing?.minPoints ?? 100,
269
274
  choices: [
270
- { name: ` Any points (including unspecified)`, value: 0 },
271
- { name: ` 50+ pts`, value: 50 },
272
- { name: ` 100+ pts`, value: 100 },
273
- { name: ` 200+ pts`, value: 200 },
274
- { name: ` 500+ pts`, value: 500 },
275
+ { name: ` Any (including unspecified)`, value: 0 },
276
+ { name: ` 100 pts`, value: 100 },
277
+ { name: ` 150 pts`, value: 150 },
278
+ { name: ` 200 pts`, value: 200 },
275
279
  ],
276
280
  }]);
277
281
  const techStack = stackInput.split(',').map((s) => s.trim()).filter(Boolean);
@@ -344,15 +348,23 @@ async function autoApply(program, profile) {
344
348
  }
345
349
  console.log();
346
350
  // Checkbox selection
351
+ // Pre-compute org counts to show how many slots remain per org
352
+ const previewOrgCounts = new Map();
347
353
  const checkboxChoices = top.map(({ issue, score, matchReasons }) => {
348
354
  const pts = issue.points ? green(`+${issue.points}pts`) : dim('—pts');
349
355
  const applicants = issue.pendingApplicationsCount > 0 ? yellow(`${issue.pendingApplicationsCount}▲`) : dim('0▲');
350
356
  const match = matchReasons.length > 0 ? cyan(`[${matchReasons.slice(0, 3).join(', ')}]`) : '';
351
- const title = truncate(issue.title, 44);
357
+ const org = extractOrg(issue);
358
+ const orgSlots = ORG_APPLICATION_LIMIT - (previewOrgCounts.get(org) || 0);
359
+ const orgTag = dim(`(${org} ${orgSlots}/${ORG_APPLICATION_LIMIT} slots)`);
360
+ const title = truncate(issue.title, 40);
361
+ const autoCheck = score >= 1 && orgSlots > 0;
362
+ if (autoCheck)
363
+ previewOrgCounts.set(org, (previewOrgCounts.get(org) || 0) + 1);
352
364
  return {
353
- name: ` ${pts} ${bold(title)} ${match} ${applicants}`,
365
+ name: ` ${pts} ${bold(title)} ${match} ${applicants} ${orgTag}`,
354
366
  value: issue,
355
- checked: score >= 1, // pre-select strong matches
367
+ checked: autoCheck,
356
368
  };
357
369
  });
358
370
  const { selectedIssues } = await inquirer.prompt([{
@@ -413,11 +425,21 @@ async function autoApply(program, profile) {
413
425
  console.log();
414
426
  let applied = 0;
415
427
  let failed = 0;
428
+ let skipped = 0;
429
+ const orgCounts = new Map();
416
430
  for (const issue of selectedIssues) {
431
+ const org = extractOrg(issue);
432
+ const orgCount = orgCounts.get(org) || 0;
433
+ if (orgCount >= ORG_APPLICATION_LIMIT) {
434
+ console.log(yellow(` ⚠ Skipped: ${truncate(issue.title, 50)} — already applied to ${ORG_APPLICATION_LIMIT} issues from ${org}`));
435
+ skipped++;
436
+ continue;
437
+ }
417
438
  const spinner = ora(` Applying to: ${truncate(issue.title, 50)}…`).start();
418
439
  try {
419
440
  await dripsPost(`/api/wave-programs/${program.id}/issues/${issue.id}/applications`, { applicationText: baseMessage }, token);
420
- spinner.succeed(green(` ✅ ${truncate(issue.title, 55)}`) + (issue.points ? dim(` (+${issue.points}pts)`) : ''));
441
+ orgCounts.set(org, orgCount + 1);
442
+ spinner.succeed(green(` ✅ ${truncate(issue.title, 55)}`) + (issue.points ? dim(` (+${issue.points}pts)`) : '') + dim(` [${org}: ${orgCount + 1}/${ORG_APPLICATION_LIMIT}]`));
421
443
  applied++;
422
444
  }
423
445
  catch (e) {
@@ -431,16 +453,24 @@ async function autoApply(program, profile) {
431
453
  }
432
454
  }
433
455
  console.log();
434
- console.log(bold(` Done: ${green(applied + ' applied')}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
456
+ console.log(bold(` Done: ${green(applied + ' applied')}${skipped > 0 ? ', ' + yellow(skipped + ' skipped (org limit)') : ''}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
435
457
  console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
436
458
  }
437
- // ── Browse by track ───────────────────────────────────────────────────────────
459
+ // ── Browse by org + track ─────────────────────────────────────────────────────
438
460
  async function browseByTrack(program, profile) {
439
- // Ask which track to browse if they want to override
461
+ // Step 1: ask for org name
462
+ const { orgInput } = await inquirer.prompt([{
463
+ type: 'input',
464
+ name: 'orgInput',
465
+ message: bold('Enter GitHub org name (e.g. stellar, uniswap, openzeppelin):'),
466
+ validate: (v) => v.trim().length > 0 || 'Required',
467
+ }]);
468
+ const targetOrg = orgInput.trim().toLowerCase();
469
+ // Step 2: ask for track
440
470
  const { browseTrack } = await inquirer.prompt([{
441
471
  type: 'list',
442
472
  name: 'browseTrack',
443
- message: bold('Browse which track?'),
473
+ message: bold('Filter by track:'),
444
474
  default: profile.track,
445
475
  choices: [
446
476
  { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
@@ -450,40 +480,52 @@ async function browseByTrack(program, profile) {
450
480
  ],
451
481
  }]);
452
482
  const selectedTrack = browseTrack;
453
- let page = 1;
454
- while (true) {
455
- const spinner = ora(dim(` Fetching unassigned issues (page ${page})…`)).start();
456
- let allIssues = [];
457
- let hasNextPage = false;
458
- let totalFetched = 0;
459
- // Fetch a batch and filter client-side for unassigned + track
460
- try {
461
- // Fetch 2 pages worth (100 issues) to ensure we have enough unassigned after filtering
462
- const batchStart = (page - 1) * 2 + 1;
463
- const pageA = await fetchIssuePage(program.id, batchStart, 50);
464
- const pageB = pageA.pagination.hasNextPage
465
- ? await fetchIssuePage(program.id, batchStart + 1, 50)
466
- : { data: [], pagination: { ...pageA.pagination, hasNextPage: false } };
467
- const raw = [...pageA.data, ...pageB.data];
468
- totalFetched = raw.length;
469
- // Filter: unassigned + track + min points
470
- allIssues = raw.filter(i => i.assignedApplicant === null &&
471
- matchesTrack(i, selectedTrack) &&
472
- (profile.minPoints === 0 || i.points === null || i.points >= profile.minPoints));
473
- // Sort by points descending (highest reward first)
474
- allIssues.sort((a, b) => (b.points || 0) - (a.points || 0));
475
- hasNextPage = pageB.pagination.hasNextPage || (pageA.pagination.hasNextPage && !pageB.pagination.hasNextPage);
476
- spinner.succeed(` ${allIssues.length} unassigned ${selectedTrack} issue(s) found (from ${totalFetched} scanned) — sorted by points`);
477
- }
478
- catch (e) {
479
- spinner.fail(` ${e.message}`);
480
- return;
481
- }
482
- if (allIssues.length === 0) {
483
- console.log(yellow(`\n No unassigned ${selectedTrack} issues found on this page. Try next page or change track.\n`));
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();
485
+ const collected = [];
486
+ let apiPage = 1;
487
+ const MAX_SCAN_PAGES = 20; // scan up to 1000 issues to find the org's full list
488
+ try {
489
+ while (apiPage <= MAX_SCAN_PAGES) {
490
+ const res = await fetchIssuePage(program.id, apiPage, 50);
491
+ for (const issue of res.data) {
492
+ const issueOrg = extractOrg(issue);
493
+ if (issueOrg !== targetOrg)
494
+ continue;
495
+ if (issue.assignedApplicant !== null)
496
+ continue;
497
+ if (!matchesTrack(issue, selectedTrack))
498
+ continue;
499
+ if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints)
500
+ continue;
501
+ collected.push(issue);
502
+ }
503
+ spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} found ${collected.length} issues in ${targetOrg} so far…`);
504
+ if (!res.pagination.hasNextPage)
505
+ break;
506
+ apiPage++;
484
507
  }
508
+ }
509
+ catch (e) {
510
+ spinner.fail(` ${e.message}`);
511
+ return;
512
+ }
513
+ // Sort by points descending
514
+ collected.sort((a, b) => (b.points || 0) - (a.points || 0));
515
+ 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'));
518
+ return;
519
+ }
520
+ spinner.succeed(` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(targetOrg)} — sorted by points`);
521
+ // Step 4: paginate through results (20 per page)
522
+ const PAGE_SIZE = 20;
523
+ let viewPage = 0;
524
+ while (true) {
525
+ const pageSlice = collected.slice(viewPage * PAGE_SIZE, (viewPage + 1) * PAGE_SIZE);
526
+ const totalPages = Math.ceil(collected.length / PAGE_SIZE);
485
527
  console.log();
486
- const choices = allIssues.slice(0, 20).map((issue) => {
528
+ const choices = pageSlice.map((issue) => {
487
529
  const age = diffDays(issue.updatedAt);
488
530
  const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
489
531
  const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
@@ -502,26 +544,29 @@ async function browseByTrack(program, profile) {
502
544
  const nav = [
503
545
  new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
504
546
  ];
505
- if (hasNextPage)
506
- nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
507
- if (page > 1)
508
- nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
547
+ if ((viewPage + 1) < totalPages)
548
+ nav.push({ name: ` ${dim(`→ Next page (${viewPage + 2}/${totalPages})`)}`, value: '__next__' });
549
+ if (viewPage > 0)
550
+ nav.push({ name: ` ${dim(`← Previous page (${viewPage}/${totalPages})`)}`, value: '__prev__' });
551
+ nav.push({ name: ` ${dim('🔍 Search a different org')}`, value: '__reorg__' });
509
552
  nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
510
553
  const { selected } = await inquirer.prompt([{
511
554
  type: 'list',
512
555
  name: 'selected',
513
- message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
514
- choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
515
- pageSize: 18,
556
+ message: bold(`${targetOrg} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
557
+ choices: [...choices, ...nav],
558
+ pageSize: 20,
516
559
  }]);
517
560
  if (selected === '__back__')
518
561
  return;
562
+ if (selected === '__reorg__')
563
+ return browseByTrack(program, profile);
519
564
  if (selected === '__next__') {
520
- page++;
565
+ viewPage++;
521
566
  continue;
522
567
  }
523
568
  if (selected === '__prev__') {
524
- page = Math.max(1, page - 1);
569
+ viewPage = Math.max(0, viewPage - 1);
525
570
  continue;
526
571
  }
527
572
  await applySingle(program, selected, profile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitpadi",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
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.3';
38
+ const VERSION = '2.1.5';
39
39
  let targetConfirmed = false;
40
40
  let gitlabProjectConfirmed = false;
41
41
 
@@ -154,6 +154,13 @@ function issueLabels(issue: DripsIssue): string[] {
154
154
  return issue.labels.map(l => l.name.toLowerCase());
155
155
  }
156
156
 
157
+ const ORG_APPLICATION_LIMIT = 4;
158
+
159
+ /** Extract the org/owner from a repo fullName like "myorg/myrepo" */
160
+ function extractOrg(issue: DripsIssue): string {
161
+ return issue.repo?.fullName?.split('/')[0]?.toLowerCase() || '__unknown__';
162
+ }
163
+
157
164
  /** Detect which track an issue belongs to based on its labels and title */
158
165
  function detectTrack(issue: DripsIssue): Exclude<Track, 'all'> | null {
159
166
  const haystack = [...issueLabels(issue), issue.title.toLowerCase()].join(' ');
@@ -335,13 +342,12 @@ async function setupProfile(existing?: DripsProfile): Promise<DripsProfile> {
335
342
  type: 'list',
336
343
  name: 'minPoints',
337
344
  message: bold('Minimum points to apply for:'),
338
- default: existing?.minPoints || 0,
345
+ default: existing?.minPoints ?? 100,
339
346
  choices: [
340
- { name: ` Any points (including unspecified)`, value: 0 },
341
- { name: ` 50+ pts`, value: 50 },
342
- { name: ` 100+ pts`, value: 100 },
343
- { name: ` 200+ pts`, value: 200 },
344
- { name: ` 500+ pts`, value: 500 },
347
+ { name: ` Any (including unspecified)`, value: 0 },
348
+ { name: ` 100 pts`, value: 100 },
349
+ { name: ` 150 pts`, value: 150 },
350
+ { name: ` 200 pts`, value: 200 },
345
351
  ],
346
352
  }]);
347
353
 
@@ -427,15 +433,22 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
427
433
  console.log();
428
434
 
429
435
  // Checkbox selection
436
+ // Pre-compute org counts to show how many slots remain per org
437
+ const previewOrgCounts = new Map<string, number>();
430
438
  const checkboxChoices = top.map(({ issue, score, matchReasons }) => {
431
439
  const pts = issue.points ? green(`+${issue.points}pts`) : dim('—pts');
432
440
  const applicants = issue.pendingApplicationsCount > 0 ? yellow(`${issue.pendingApplicationsCount}▲`) : dim('0▲');
433
441
  const match = matchReasons.length > 0 ? cyan(`[${matchReasons.slice(0, 3).join(', ')}]`) : '';
434
- const title = truncate(issue.title, 44);
442
+ const org = extractOrg(issue);
443
+ const orgSlots = ORG_APPLICATION_LIMIT - (previewOrgCounts.get(org) || 0);
444
+ const orgTag = dim(`(${org} ${orgSlots}/${ORG_APPLICATION_LIMIT} slots)`);
445
+ const title = truncate(issue.title, 40);
446
+ const autoCheck = score >= 1 && orgSlots > 0;
447
+ if (autoCheck) previewOrgCounts.set(org, (previewOrgCounts.get(org) || 0) + 1);
435
448
  return {
436
- name: ` ${pts} ${bold(title)} ${match} ${applicants}`,
449
+ name: ` ${pts} ${bold(title)} ${match} ${applicants} ${orgTag}`,
437
450
  value: issue,
438
- checked: score >= 1, // pre-select strong matches
451
+ checked: autoCheck,
439
452
  };
440
453
  });
441
454
 
@@ -502,8 +515,19 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
502
515
  console.log();
503
516
  let applied = 0;
504
517
  let failed = 0;
518
+ let skipped = 0;
519
+ const orgCounts = new Map<string, number>();
505
520
 
506
521
  for (const issue of selectedIssues as DripsIssue[]) {
522
+ const org = extractOrg(issue);
523
+ const orgCount = orgCounts.get(org) || 0;
524
+
525
+ if (orgCount >= ORG_APPLICATION_LIMIT) {
526
+ console.log(yellow(` ⚠ Skipped: ${truncate(issue.title, 50)} — already applied to ${ORG_APPLICATION_LIMIT} issues from ${org}`));
527
+ skipped++;
528
+ continue;
529
+ }
530
+
507
531
  const spinner = ora(` Applying to: ${truncate(issue.title, 50)}…`).start();
508
532
  try {
509
533
  await dripsPost(
@@ -511,7 +535,8 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
511
535
  { applicationText: baseMessage },
512
536
  token,
513
537
  );
514
- spinner.succeed(green(` ✅ ${truncate(issue.title, 55)}`) + (issue.points ? dim(` (+${issue.points}pts)`) : ''));
538
+ orgCounts.set(org, orgCount + 1);
539
+ spinner.succeed(green(` ✅ ${truncate(issue.title, 55)}`) + (issue.points ? dim(` (+${issue.points}pts)`) : '') + dim(` [${org}: ${orgCount + 1}/${ORG_APPLICATION_LIMIT}]`));
515
540
  applied++;
516
541
  } catch (e: any) {
517
542
  spinner.fail(red(` ✗ ${truncate(issue.title, 55)} — ${e.message}`));
@@ -525,74 +550,93 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
525
550
  }
526
551
 
527
552
  console.log();
528
- console.log(bold(` Done: ${green(applied + ' applied')}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
553
+ console.log(bold(` Done: ${green(applied + ' applied')}${skipped > 0 ? ', ' + yellow(skipped + ' skipped (org limit)') : ''}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
529
554
  console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
530
555
  }
531
556
 
532
- // ── Browse by track ───────────────────────────────────────────────────────────
557
+ // ── Browse by org + track ─────────────────────────────────────────────────────
533
558
 
534
559
  async function browseByTrack(program: DripsProgram, profile: DripsProfile): Promise<void> {
535
- // Ask which track to browse if they want to override
560
+ // Step 1: ask for org name
561
+ const { orgInput } = await inquirer.prompt([{
562
+ type: 'input',
563
+ name: 'orgInput',
564
+ message: bold('Enter GitHub org name (e.g. stellar, uniswap, openzeppelin):'),
565
+ validate: (v: string) => v.trim().length > 0 || 'Required',
566
+ }]);
567
+
568
+ const targetOrg = orgInput.trim().toLowerCase();
569
+
570
+ // Step 2: ask for track
536
571
  const { browseTrack } = await inquirer.prompt([{
537
572
  type: 'list',
538
573
  name: 'browseTrack',
539
- message: bold('Browse which track?'),
574
+ message: bold('Filter by track:'),
540
575
  default: profile.track,
541
576
  choices: [
542
- { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
543
- { name: ` ${magenta('🎨')} Frontend`, value: 'frontend' },
577
+ { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
578
+ { name: ` ${magenta('🎨')} Frontend`, value: 'frontend' },
544
579
  { name: ` ${yellow('📜')} Smart Contract`, value: 'contract' },
545
- { name: ` ${green('🌐')} All tracks`, value: 'all' },
580
+ { name: ` ${green('🌐')} All tracks`, value: 'all' },
546
581
  ],
547
582
  }]);
548
583
 
549
584
  const selectedTrack: Track = browseTrack;
550
- let page = 1;
551
585
 
552
- while (true) {
553
- const spinner = ora(dim(` Fetching unassigned issues (page ${page})…`)).start();
554
- let allIssues: DripsIssue[] = [];
555
- let hasNextPage = false;
556
- let totalFetched = 0;
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();
557
588
 
558
- // Fetch a batch and filter client-side for unassigned + track
559
- try {
560
- // Fetch 2 pages worth (100 issues) to ensure we have enough unassigned after filtering
561
- const batchStart = (page - 1) * 2 + 1;
562
- const pageA = await fetchIssuePage(program.id, batchStart, 50);
563
- const pageB = pageA.pagination.hasNextPage
564
- ? await fetchIssuePage(program.id, batchStart + 1, 50)
565
- : { data: [], pagination: { ...pageA.pagination, hasNextPage: false } };
566
-
567
- const raw = [...pageA.data, ...pageB.data];
568
- totalFetched = raw.length;
569
-
570
- // Filter: unassigned + track + min points
571
- allIssues = raw.filter(i =>
572
- i.assignedApplicant === null &&
573
- matchesTrack(i, selectedTrack) &&
574
- (profile.minPoints === 0 || i.points === null || i.points >= profile.minPoints)
575
- );
589
+ const collected: DripsIssue[] = [];
590
+ let apiPage = 1;
591
+ const MAX_SCAN_PAGES = 20; // scan up to 1000 issues to find the org's full list
576
592
 
577
- // Sort by points descending (highest reward first)
578
- allIssues.sort((a, b) => (b.points || 0) - (a.points || 0));
593
+ try {
594
+ while (apiPage <= MAX_SCAN_PAGES) {
595
+ const res = await fetchIssuePage(program.id, apiPage, 50);
596
+
597
+ for (const issue of res.data) {
598
+ const issueOrg = extractOrg(issue);
599
+ if (issueOrg !== targetOrg) continue;
600
+ if (issue.assignedApplicant !== null) continue;
601
+ if (!matchesTrack(issue, selectedTrack)) continue;
602
+ if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints) continue;
603
+ collected.push(issue);
604
+ }
579
605
 
580
- hasNextPage = pageB.pagination.hasNextPage || (pageA.pagination.hasNextPage && !pageB.pagination.hasNextPage);
606
+ spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} — found ${collected.length} issues in ${targetOrg} so far…`);
581
607
 
582
- spinner.succeed(
583
- ` ${allIssues.length} unassigned ${selectedTrack} issue(s) found (from ${totalFetched} scanned) — sorted by points`
584
- );
585
- } catch (e: any) {
586
- spinner.fail(` ${e.message}`);
587
- return;
608
+ if (!res.pagination.hasNextPage) break;
609
+ apiPage++;
588
610
  }
611
+ } catch (e: any) {
612
+ spinner.fail(` ${e.message}`);
613
+ return;
614
+ }
589
615
 
590
- if (allIssues.length === 0) {
591
- console.log(yellow(`\n No unassigned ${selectedTrack} issues found on this page. Try next page or change track.\n`));
592
- }
616
+ // Sort by points descending
617
+ collected.sort((a, b) => (b.points || 0) - (a.points || 0));
618
+
619
+ 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'));
622
+ return;
623
+ }
624
+
625
+ spinner.succeed(
626
+ ` ${bold(collected.length + '')} unassigned ${selectedTrack} issue(s) from ${cyan(targetOrg)} — sorted by points`
627
+ );
628
+
629
+ // Step 4: paginate through results (20 per page)
630
+ const PAGE_SIZE = 20;
631
+ let viewPage = 0;
632
+
633
+ while (true) {
634
+ const pageSlice = collected.slice(viewPage * PAGE_SIZE, (viewPage + 1) * PAGE_SIZE);
635
+ const totalPages = Math.ceil(collected.length / PAGE_SIZE);
593
636
 
594
637
  console.log();
595
- const choices = allIssues.slice(0, 20).map((issue) => {
638
+
639
+ const choices = pageSlice.map((issue) => {
596
640
  const age = diffDays(issue.updatedAt);
597
641
  const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
598
642
  const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
@@ -612,21 +656,23 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
612
656
  const nav: any[] = [
613
657
  new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
614
658
  ];
615
- if (hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
616
- if (page > 1) nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
659
+ if ((viewPage + 1) < totalPages) nav.push({ name: ` ${dim(`→ Next page (${viewPage + 2}/${totalPages})`)}`, value: '__next__' });
660
+ if (viewPage > 0) nav.push({ name: ` ${dim(`← Previous page (${viewPage}/${totalPages})`)}`, value: '__prev__' });
661
+ nav.push({ name: ` ${dim('🔍 Search a different org')}`, value: '__reorg__' });
617
662
  nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
618
663
 
619
664
  const { selected } = await inquirer.prompt([{
620
665
  type: 'list',
621
666
  name: 'selected',
622
- message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
623
- choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
624
- pageSize: 18,
667
+ message: bold(`${targetOrg} — ${selectedTrack} issues (${collected.length} total, page ${viewPage + 1}/${totalPages}):`),
668
+ choices: [...choices, ...nav],
669
+ pageSize: 20,
625
670
  }]);
626
671
 
627
672
  if (selected === '__back__') return;
628
- if (selected === '__next__') { page++; continue; }
629
- if (selected === '__prev__') { page = Math.max(1, page - 1); continue; }
673
+ if (selected === '__reorg__') return browseByTrack(program, profile);
674
+ if (selected === '__next__') { viewPage++; continue; }
675
+ if (selected === '__prev__') { viewPage = Math.max(0, viewPage - 1); continue; }
630
676
 
631
677
  await applySingle(program, selected as DripsIssue, profile);
632
678
  }