gitpadi 2.1.6 β†’ 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,6 +23,7 @@ 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';
@@ -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,36 +469,26 @@ 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
  }
459
- // ── Repo/org search ───────────────────────────────────────────────────────────
460
472
  /**
461
- * Scans the wave program and returns unique repos whose org or repo name
462
- * contains the search term (case-insensitive).
473
+ * Uses the Drips repos API to search by org/repo name β€” fast, covers ALL repos.
463
474
  */
464
475
  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);
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`);
489
492
  }
490
493
  // ── Browse by org + track ─────────────────────────────────────────────────────
491
494
  async function browseByTrack(program, profile) {
@@ -514,48 +517,48 @@ async function browseByTrack(program, profile) {
514
517
  }
515
518
  searchSpinner.succeed(` Found ${matches.length} repo(s) matching "${term}"`);
516
519
  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 targetOrgId;
520
521
  let displayLabel;
521
- const exactOrg = matches.find(m => m.org === term.toLowerCase());
522
- const exactRepo = matches.find(m => m.fullName === term.toLowerCase());
522
+ const exactOrg = matches.find(m => m.orgLogin.toLowerCase() === term.toLowerCase());
523
+ const exactRepo = matches.find(m => m.repoFullName.toLowerCase() === term.toLowerCase());
523
524
  if (matches.length === 1) {
524
- targetFullName = matches[0].fullName;
525
- displayLabel = matches[0].fullName;
525
+ targetOrgId = matches[0].orgId;
526
+ displayLabel = matches[0].repoFullName;
526
527
  console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
527
528
  }
528
529
  else if (exactRepo) {
529
- targetFullName = exactRepo.fullName;
530
- displayLabel = exactRepo.fullName;
530
+ targetOrgId = exactRepo.orgId;
531
+ displayLabel = exactRepo.repoFullName;
531
532
  console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
532
533
  }
533
534
  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`));
535
+ targetOrgId = exactOrg.orgId;
536
+ displayLabel = exactOrg.orgLogin + '/*';
537
+ console.log(dim(` Using org: ${cyan(exactOrg.orgLogin)} (all repos)\n`));
538
538
  }
539
539
  else {
540
- // Multiple partial matches β€” let user pick repo or entire org
540
+ // Multiple partial matches β€” let user pick
541
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);
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) {
546
549
  if (orgRepos.length > 1) {
547
550
  const total = orgRepos.reduce((s, r) => s + r.issueCount, 0);
548
551
  repoChoices.push({
549
552
  name: ` ${cyan(org + '/*')} ${dim(`all ${orgRepos.length} repos Β· ${total} issues`)}`,
550
- value: org,
553
+ value: orgRepos[0].orgId,
551
554
  short: org + '/*',
552
555
  });
553
556
  }
554
557
  for (const r of orgRepos) {
555
558
  repoChoices.push({
556
- name: ` ${dim('β””')} ${cyan(r.fullName)} ${dim(r.issueCount + ' issue(s)')}`,
557
- value: r.fullName,
558
- short: r.fullName,
559
+ name: ` ${dim('β””')} ${cyan(r.repoFullName)} ${dim(r.issueCount + ' issue(s)')}`,
560
+ value: r.orgId,
561
+ short: r.repoFullName,
559
562
  });
560
563
  }
561
564
  }
@@ -569,8 +572,9 @@ async function browseByTrack(program, profile) {
569
572
  }]);
570
573
  if (picked === '__search__')
571
574
  return browseByTrack(program, profile);
572
- targetFullName = picked;
573
- displayLabel = picked.includes('/') && !picked.endsWith('/*') ? picked : picked + '/*';
575
+ targetOrgId = picked;
576
+ const pickedRepo = matches.find(r => r.orgId === picked);
577
+ displayLabel = pickedRepo ? pickedRepo.repoFullName : picked;
574
578
  }
575
579
  // Step 2: ask for track
576
580
  const { browseTrack } = await inquirer.prompt([{
@@ -586,26 +590,14 @@ async function browseByTrack(program, profile) {
586
590
  ],
587
591
  }]);
588
592
  const selectedTrack = browseTrack;
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();
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();
592
595
  const collected = [];
593
596
  let apiPage = 1;
594
- const MAX_SCAN_PAGES = 20;
595
597
  try {
596
- while (apiPage <= MAX_SCAN_PAGES) {
597
- const res = await fetchIssuePage(program.id, apiPage, 50);
598
+ while (true) {
599
+ const res = await getIssuesByOrg(program.id, targetOrgId, apiPage, 50);
598
600
  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
601
  if (issue.assignedApplicant !== null)
610
602
  continue;
611
603
  if (!matchesTrack(issue, selectedTrack))
@@ -614,7 +606,7 @@ async function browseByTrack(program, profile) {
614
606
  continue;
615
607
  collected.push(issue);
616
608
  }
617
- spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} β€” ${collected.length} matching issue(s) so far…`);
609
+ spinner.text = dim(` Fetched page ${apiPage}/${res.pagination.totalPages} β€” ${collected.length} matching issue(s) so far…`);
618
610
  if (!res.pagination.hasNextPage)
619
611
  break;
620
612
  apiPage++;
@@ -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.6",
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,6 +31,7 @@ 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';
@@ -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',
@@ -556,39 +570,45 @@ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<
556
570
 
557
571
  // ── Repo/org search ───────────────────────────────────────────────────────────
558
572
 
573
+ interface DripsRepo {
574
+ orgId: string;
575
+ orgLogin: string;
576
+ repoFullName: string;
577
+ repoName: string;
578
+ issueCount: number;
579
+ description: string;
580
+ }
581
+
559
582
  /**
560
- * Scans the wave program and returns unique repos whose org or repo name
561
- * contains the search term (case-insensitive).
583
+ * Uses the Drips repos API to search by org/repo name β€” fast, covers ALL repos.
562
584
  */
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
- }
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
+ }
590
599
 
591
- return Array.from(repoMap.values()).sort((a, b) => b.issueCount - a.issueCount);
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
+ );
592
612
  }
593
613
 
594
614
  // ── Browse by org + track ─────────────────────────────────────────────────────
@@ -606,7 +626,7 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
606
626
 
607
627
  // Search for matching repos in the wave program
608
628
  const searchSpinner = ora(dim(` Searching for "${term}" in ${program.name} wave…`)).start();
609
- let matches: Array<{ org: string; repo: string; fullName: string; issueCount: number }>;
629
+ let matches: DripsRepo[];
610
630
  try {
611
631
  matches = await searchRepos(program, term);
612
632
  } catch (e: any) {
@@ -623,48 +643,49 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
623
643
  searchSpinner.succeed(` Found ${matches.length} repo(s) matching "${term}"`);
624
644
  console.log();
625
645
 
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)
646
+ let targetOrgId: string;
629
647
  let displayLabel: string;
630
648
 
631
- const exactOrg = matches.find(m => m.org === term.toLowerCase());
632
- const exactRepo = matches.find(m => m.fullName === term.toLowerCase());
649
+ const exactOrg = matches.find(m => m.orgLogin.toLowerCase() === term.toLowerCase());
650
+ const exactRepo = matches.find(m => m.repoFullName.toLowerCase() === term.toLowerCase());
633
651
 
634
652
  if (matches.length === 1) {
635
- targetFullName = matches[0].fullName;
636
- displayLabel = matches[0].fullName;
653
+ targetOrgId = matches[0].orgId;
654
+ displayLabel = matches[0].repoFullName;
637
655
  console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
638
656
  } else if (exactRepo) {
639
- targetFullName = exactRepo.fullName;
640
- displayLabel = exactRepo.fullName;
657
+ targetOrgId = exactRepo.orgId;
658
+ displayLabel = exactRepo.repoFullName;
641
659
  console.log(dim(` Using repo: ${cyan(displayLabel)}\n`));
642
660
  } 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`));
661
+ targetOrgId = exactOrg.orgId;
662
+ displayLabel = exactOrg.orgLogin + '/*';
663
+ console.log(dim(` Using org: ${cyan(exactOrg.orgLogin)} (all repos)\n`));
647
664
  } else {
648
- // Multiple partial matches β€” let user pick repo or entire org
665
+ // Multiple partial matches β€” let user pick
649
666
  const repoChoices: any[] = [];
650
667
 
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);
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) {
655
676
  if (orgRepos.length > 1) {
656
677
  const total = orgRepos.reduce((s, r) => s + r.issueCount, 0);
657
678
  repoChoices.push({
658
679
  name: ` ${cyan(org + '/*')} ${dim(`all ${orgRepos.length} repos Β· ${total} issues`)}`,
659
- value: org,
680
+ value: orgRepos[0].orgId,
660
681
  short: org + '/*',
661
682
  });
662
683
  }
663
684
  for (const r of orgRepos) {
664
685
  repoChoices.push({
665
- name: ` ${dim('β””')} ${cyan(r.fullName)} ${dim(r.issueCount + ' issue(s)')}`,
666
- value: r.fullName,
667
- short: r.fullName,
686
+ name: ` ${dim('β””')} ${cyan(r.repoFullName)} ${dim(r.issueCount + ' issue(s)')}`,
687
+ value: r.orgId,
688
+ short: r.repoFullName,
668
689
  });
669
690
  }
670
691
  }
@@ -683,8 +704,9 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
683
704
  }]);
684
705
 
685
706
  if (picked === '__search__') return browseByTrack(program, profile);
686
- targetFullName = picked;
687
- displayLabel = picked.includes('/') && !picked.endsWith('/*') ? picked : picked + '/*';
707
+ targetOrgId = picked;
708
+ const pickedRepo = matches.find(r => r.orgId === picked);
709
+ displayLabel = pickedRepo ? pickedRepo.repoFullName : picked;
688
710
  }
689
711
 
690
712
  // Step 2: ask for track
@@ -703,36 +725,24 @@ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Prom
703
725
 
704
726
  const selectedTrack: Track = browseTrack;
705
727
 
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();
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();
709
730
 
710
731
  const collected: DripsIssue[] = [];
711
732
  let apiPage = 1;
712
- const MAX_SCAN_PAGES = 20;
713
733
 
714
734
  try {
715
- while (apiPage <= MAX_SCAN_PAGES) {
716
- const res = await fetchIssuePage(program.id, apiPage, 50);
735
+ while (true) {
736
+ const res = await getIssuesByOrg(program.id, targetOrgId, apiPage, 50);
717
737
 
718
738
  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
739
  if (issue.assignedApplicant !== null) continue;
730
740
  if (!matchesTrack(issue, selectedTrack)) continue;
731
741
  if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints) continue;
732
742
  collected.push(issue);
733
743
  }
734
744
 
735
- spinner.text = dim(` Scanning page ${apiPage}/${MAX_SCAN_PAGES} β€” ${collected.length} matching issue(s) so far…`);
745
+ spinner.text = dim(` Fetched page ${apiPage}/${res.pagination.totalPages} β€” ${collected.length} matching issue(s) so far…`);
736
746
 
737
747
  if (!res.pagination.hasNextPage) break;
738
748
  apiPage++;
@@ -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
+ }