gitpadi 2.1.2 → 2.1.4

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.
@@ -1,7 +1,10 @@
1
- // commands/drips.ts — Apply for bounty issues on Drips Network from your terminal
1
+ // commands/drips.ts — Smart Drips Network issue applicant
2
2
  //
3
- // API reverse-engineered from https://github.com/drips-network/app
4
- // Base: https://wave-api.drips.network
3
+ // Features:
4
+ // 1. Auto-Apply — scan issues, score against your tech stack, batch-apply
5
+ // 2. Browse by Track — unassigned only, sorted by points, filter backend/frontend/contract
6
+ //
7
+ // API: https://wave-api.drips.network
5
8
  // Auth: GitHub OAuth → JWT cookie (wave_access_token)
6
9
 
7
10
  import inquirer from 'inquirer';
@@ -23,9 +26,19 @@ const yellow = chalk.yellowBright;
23
26
  const green = chalk.greenBright;
24
27
  const bold = chalk.bold;
25
28
  const red = chalk.redBright;
29
+ const magenta = chalk.magentaBright;
26
30
 
27
31
  // ── Types ─────────────────────────────────────────────────────────────────────
28
32
 
33
+ type Track = 'backend' | 'frontend' | 'contract' | 'all';
34
+
35
+ interface DripsProfile {
36
+ track: Track;
37
+ techStack: string[]; // e.g. ["TypeScript", "Node.js", "PostgreSQL"]
38
+ minPoints: number; // only apply to issues worth >= this
39
+ programSlug: string; // e.g. "stellar"
40
+ }
41
+
29
42
  interface DripsIssue {
30
43
  id: string;
31
44
  title: string;
@@ -61,6 +74,33 @@ interface DripsProgram {
61
74
  presetBudgetUSD: string;
62
75
  }
63
76
 
77
+ interface ScoredIssue {
78
+ issue: DripsIssue;
79
+ score: number;
80
+ matchReasons: string[];
81
+ }
82
+
83
+ // ── Track keyword maps ────────────────────────────────────────────────────────
84
+
85
+ const TRACK_KEYWORDS: Record<Exclude<Track, 'all'>, string[]> = {
86
+ contract: [
87
+ 'solidity', 'smart contract', 'contract', 'blockchain', 'web3',
88
+ 'evm', 'anchor', 'defi', 'nft', 'token', 'wallet', 'on-chain',
89
+ 'hardhat', 'foundry', 'wagmi', 'ethers',
90
+ ],
91
+ backend: [
92
+ 'backend', 'api', 'server', 'database', 'db', 'node', 'python',
93
+ 'golang', 'go', 'java', 'rust', 'graphql', 'rest', 'grpc',
94
+ 'postgresql', 'postgres', 'mysql', 'redis', 'kafka', 'docker',
95
+ 'microservice', 'auth', 'authentication', 'authorization',
96
+ ],
97
+ frontend: [
98
+ 'frontend', 'ui', 'ux', 'react', 'svelte', 'vue', 'angular',
99
+ 'css', 'html', 'tailwind', 'mobile', 'ios', 'android', 'flutter',
100
+ 'next', 'nuxt', 'design', 'component', 'responsive', 'a11y',
101
+ ],
102
+ };
103
+
64
104
  // ── Config ────────────────────────────────────────────────────────────────────
65
105
 
66
106
  function loadConfig(): Record<string, any> {
@@ -68,10 +108,22 @@ function loadConfig(): Record<string, any> {
68
108
  try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } catch { return {}; }
69
109
  }
70
110
 
71
- function saveDripsToken(token: string): void {
111
+ function saveConfig(patch: Record<string, any>): void {
72
112
  const current = loadConfig();
73
113
  if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
74
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, dripsToken: token }, null, 2));
114
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...patch }, null, 2));
115
+ }
116
+
117
+ function loadProfile(): DripsProfile | null {
118
+ return loadConfig().dripsProfile ?? null;
119
+ }
120
+
121
+ function saveProfile(profile: DripsProfile): void {
122
+ saveConfig({ dripsProfile: profile });
123
+ }
124
+
125
+ function saveDripsToken(token: string): void {
126
+ saveConfig({ dripsToken: token });
75
127
  }
76
128
 
77
129
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -98,6 +150,49 @@ function parseSlug(input: string): string {
98
150
  return input.replace(/^\/+|\/+$/g, '').split('/').pop() || input;
99
151
  }
100
152
 
153
+ function issueLabels(issue: DripsIssue): string[] {
154
+ return issue.labels.map(l => l.name.toLowerCase());
155
+ }
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
+
164
+ /** Detect which track an issue belongs to based on its labels and title */
165
+ function detectTrack(issue: DripsIssue): Exclude<Track, 'all'> | null {
166
+ const haystack = [...issueLabels(issue), issue.title.toLowerCase()].join(' ');
167
+ for (const [track, keywords] of Object.entries(TRACK_KEYWORDS) as [Exclude<Track, 'all'>, string[]][]) {
168
+ if (keywords.some(kw => haystack.includes(kw))) return track;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** Score an issue against the user's tech stack. Returns 0 if no match. */
174
+ function scoreIssue(issue: DripsIssue, techStack: string[]): { score: number; matchReasons: string[] } {
175
+ const haystack = [...issueLabels(issue), issue.title.toLowerCase()].join(' ');
176
+ const matchReasons: string[] = [];
177
+
178
+ for (const tech of techStack) {
179
+ if (haystack.includes(tech.toLowerCase())) {
180
+ matchReasons.push(tech);
181
+ }
182
+ }
183
+
184
+ return { score: matchReasons.length, matchReasons };
185
+ }
186
+
187
+ /** Filter issues matching the user's track preference */
188
+ function matchesTrack(issue: DripsIssue, track: Track): boolean {
189
+ if (track === 'all') return true;
190
+ const detected = detectTrack(issue);
191
+ if (detected === track) return true;
192
+ // Also accept issues with no detected track when browsing 'all'
193
+ return false;
194
+ }
195
+
101
196
  // ── API ───────────────────────────────────────────────────────────────────────
102
197
 
103
198
  async function dripsGet(endpoint: string, token?: string): Promise<any> {
@@ -127,40 +222,51 @@ async function dripsPost(endpoint: string, body: any, token: string): Promise<an
127
222
  try { return JSON.parse(text); } catch { return {}; }
128
223
  }
129
224
 
225
+ async function fetchIssuePage(
226
+ programId: string,
227
+ page: number,
228
+ limit: number = 50,
229
+ ): Promise<{ data: DripsIssue[]; pagination: DripsPagination }> {
230
+ return dripsGet(
231
+ `/api/issues?waveProgramId=${programId}&state=open&page=${page}&limit=${limit}&sortBy=updatedAt`,
232
+ );
233
+ }
234
+
235
+ async function getWaveProgram(slug: string): Promise<DripsProgram> {
236
+ const data = await dripsGet(`/api/wave-programs?slug=${encodeURIComponent(slug)}`);
237
+ if (data?.id) return data as DripsProgram;
238
+ if (Array.isArray(data?.data) && data.data.length > 0) return data.data[0] as DripsProgram;
239
+ throw new Error(`Wave program "${slug}" not found. Check the slug or URL.`);
240
+ }
241
+
130
242
  // ── Auth ──────────────────────────────────────────────────────────────────────
131
243
 
132
244
  export async function ensureDripsAuth(): Promise<string> {
133
245
  const config = loadConfig();
134
246
 
135
247
  if (config.dripsToken) {
136
- // Validate by hitting a quota endpoint that requires auth
137
248
  try {
138
249
  const res = await fetch(
139
250
  `${DRIPS_API}/api/wave-programs/fdc01c95-806f-4b6a-998b-a6ed37e0d81b/quotas/applications`,
140
251
  { headers: { Cookie: `wave_access_token=${config.dripsToken}`, Accept: 'application/json' } }
141
252
  );
142
253
  if (res.status !== 401) return config.dripsToken as string;
143
- } catch { /* network issue, let user try anyway */ }
254
+ } catch { /* network issue */ }
144
255
  }
145
256
 
146
- // Show how-to instructions
147
257
  console.log();
148
258
  console.log(dim(' ┌─ Connect your Drips Network account ────────────────────────┐'));
149
259
  console.log(dim(' │'));
150
- console.log(dim(' │ Drips uses GitHub OAuth. Here\'s how to get your token:'));
151
- console.log(dim(' │'));
152
260
  console.log(dim(' │ 1. Log in at: ') + cyan(DRIPS_WEB + '/wave/login'));
153
- console.log(dim(' │ (GitPadi will open it in your browser now)'));
261
+ console.log(dim(' │ (GitPadi will open it in your browser)'));
154
262
  console.log(dim(' │'));
155
- console.log(dim(' │ 2. After logging in with GitHub, open DevTools:'));
156
- console.log(dim(' │ ') + bold('F12') + dim(' (or Cmd+Option+I on Mac)'));
157
- console.log(dim(' │ → ') + bold('Application') + dim(' tab'));
158
- console.log(dim(' │ → ') + bold('Cookies') + dim(' → ') + cyan('www.drips.network'));
263
+ console.log(dim(' │ 2. After login, open DevTools:'));
264
+ console.log(dim(' │ ') + bold('F12') + dim(' → ') + bold('Application') + dim(' → ') + bold('Cookies') + dim(' → ') + cyan('www.drips.network'));
159
265
  console.log(dim(' │'));
160
- console.log(dim(' │ 3. Find the cookie named ') + yellow('wave_access_token'));
161
- console.log(dim(' │ Copy the full value (it starts with ') + dim('eyJ...') + dim(')'));
266
+ console.log(dim(' │ 3. Find the cookie: ') + yellow('wave_access_token'));
267
+ console.log(dim(' │ Copy its full value (starts with ') + dim('eyJ...') + dim(')'));
162
268
  console.log(dim(' │'));
163
- console.log(dim(' │ GitPadi saves it onceyou won\'t need to do this again.'));
269
+ console.log(dim(' │ This is a one-time step GitPadi saves it for future use.'));
164
270
  console.log(dim(' │'));
165
271
  console.log(dim(' └─────────────────────────────────────────────────────────────┘'));
166
272
  console.log();
@@ -186,98 +292,343 @@ export async function ensureDripsAuth(): Promise<string> {
186
292
  mask: '•',
187
293
  validate: (v: string) => {
188
294
  if (!v || v.trim().length < 20) return 'Token seems too short — copy the full value from DevTools';
189
- if (!v.trim().startsWith('eyJ')) return 'Expected a JWT starting with eyJ — make sure you copied wave_access_token, not another cookie';
295
+ if (!v.trim().startsWith('eyJ')) return 'Expected a JWT starting with eyJ — check you copied wave_access_token';
190
296
  return true;
191
297
  },
192
298
  }]);
193
299
 
194
300
  const t = token.trim();
195
301
  saveDripsToken(t);
196
- console.log(green('\n ✅ Drips session saved — you\'re all set!\n'));
302
+ console.log(green('\n ✅ Drips session saved.\n'));
197
303
  return t;
198
304
  }
199
305
 
200
- // ── Program lookup ────────────────────────────────────────────────────────────
306
+ // ── Profile setup ─────────────────────────────────────────────────────────────
201
307
 
202
- async function getWaveProgram(slug: string): Promise<DripsProgram> {
203
- const data = await dripsGet(`/api/wave-programs?slug=${encodeURIComponent(slug)}`);
204
- if (data?.id) return data as DripsProgram;
205
- if (Array.isArray(data?.data) && data.data.length > 0) return data.data[0] as DripsProgram;
206
- throw new Error(`Wave program "${slug}" not found. Check the slug or URL.`);
207
- }
308
+ async function setupProfile(existing?: DripsProfile): Promise<DripsProfile> {
309
+ console.log();
310
+ console.log(bold(' Set up your Drips profile — GitPadi uses this to find the right issues for you.'));
311
+ console.log();
208
312
 
209
- // ── Issues ────────────────────────────────────────────────────────────────────
313
+ const { programInput } = await inquirer.prompt([{
314
+ type: 'input',
315
+ name: 'programInput',
316
+ message: bold('Drips Wave program (slug or URL):'),
317
+ default: existing?.programSlug || 'stellar',
318
+ }]);
210
319
 
211
- async function getIssues(
212
- programId: string,
213
- page: number,
214
- ): Promise<{ data: DripsIssue[]; pagination: DripsPagination }> {
215
- return dripsGet(
216
- `/api/issues?waveProgramId=${programId}&state=open&page=${page}&limit=20&sortBy=updatedAt`,
217
- );
320
+ const { track } = await inquirer.prompt([{
321
+ type: 'list',
322
+ name: 'track',
323
+ message: bold('What is your track?'),
324
+ default: existing?.track || 'backend',
325
+ choices: [
326
+ { name: ` ${cyan('⚙️')} Backend ${dim('— APIs, servers, databases, Node.js, Rust, Go...')}`, value: 'backend' },
327
+ { name: ` ${magenta('🎨')} Frontend ${dim('— React, Svelte, Vue, CSS, mobile...')}`, value: 'frontend' },
328
+ { name: ` ${yellow('📜')} Smart Contract ${dim('— Solidity, EVM, Anchor, DeFi, Web3...')}`, value: 'contract' },
329
+ { name: ` ${green('🌐')} All tracks ${dim('— show everything, filter by tech stack only')}`, value: 'all' },
330
+ ],
331
+ }]);
332
+
333
+ const { stackInput } = await inquirer.prompt([{
334
+ type: 'input',
335
+ name: 'stackInput',
336
+ message: bold('Your tech stack (comma-separated):'),
337
+ default: existing?.techStack.join(', ') || 'TypeScript, Node.js',
338
+ validate: (v: string) => v.trim().length > 0 || 'Enter at least one technology',
339
+ }]);
340
+
341
+ const { minPoints } = await inquirer.prompt([{
342
+ type: 'list',
343
+ name: 'minPoints',
344
+ message: bold('Minimum points to apply for:'),
345
+ default: existing?.minPoints ?? 100,
346
+ choices: [
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 },
351
+ ],
352
+ }]);
353
+
354
+ const techStack = stackInput.split(',').map((s: string) => s.trim()).filter(Boolean);
355
+ const programSlug = parseSlug(programInput.trim());
356
+
357
+ const profile: DripsProfile = { track, techStack, minPoints, programSlug };
358
+ saveProfile(profile);
359
+
360
+ console.log(green(`\n ✅ Profile saved — track: ${bold(track)}, stack: ${techStack.join(', ')}, min: ${minPoints}pts\n`));
361
+ return profile;
218
362
  }
219
363
 
220
- // ── Main menu ─────────────────────────────────────────────────────────────────
364
+ // ── Auto-apply ────────────────────────────────────────────────────────────────
221
365
 
222
- export async function dripsMenu(): Promise<void> {
366
+ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<void> {
223
367
  console.log();
224
- console.log(bold(' Drips Network Apply for bounty issues from your terminal'));
225
- console.log(dim(' Browse issues from any Wave program and apply in seconds.'));
368
+ console.log(bold(` Scanning ${program.name} issues for matches…`));
369
+ console.log(dim(` Track: ${profile.track} | Stack: ${profile.techStack.join(', ')} | Min: ${profile.minPoints}pts`));
226
370
  console.log();
227
371
 
228
- const { input } = await inquirer.prompt([{
229
- type: 'input',
230
- name: 'input',
231
- message: bold('Enter Drips Wave URL or program slug:'),
232
- default: 'stellar',
233
- validate: (v: string) => v.trim().length > 0 || 'Required',
372
+ const spinner = ora(dim(' Fetching open issues…')).start();
373
+ const scored: ScoredIssue[] = [];
374
+ let page = 1;
375
+ const MAX_PAGES = 10; // scan up to 500 issues
376
+
377
+ while (page <= MAX_PAGES) {
378
+ let data: DripsIssue[];
379
+ let pagination: DripsPagination;
380
+
381
+ try {
382
+ const res = await fetchIssuePage(program.id, page, 50);
383
+ data = res.data;
384
+ pagination = res.pagination;
385
+ } catch (e: any) {
386
+ spinner.fail(` Fetch error: ${e.message}`);
387
+ return;
388
+ }
389
+
390
+ for (const issue of data) {
391
+ // Skip assigned issues
392
+ if (issue.assignedApplicant !== null) continue;
393
+
394
+ // Skip below min points (only if points are set)
395
+ if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints) continue;
396
+
397
+ // Skip if track doesn't match
398
+ if (!matchesTrack(issue, profile.track)) continue;
399
+
400
+ // Score against tech stack
401
+ const { score, matchReasons } = scoreIssue(issue, profile.techStack);
402
+
403
+ // For 'all' track, accept any score >= 0. For specific tracks, require at least a label match
404
+ if (profile.track !== 'all' && score === 0) {
405
+ // Still include it if it clearly matches the track (track filter already handled above)
406
+ // but boost score so track-matching issues without stack matches still show up
407
+ scored.push({ issue, score: 0.5, matchReasons: [profile.track] });
408
+ } else {
409
+ scored.push({ issue, score, matchReasons: score > 0 ? matchReasons : [profile.track] });
410
+ }
411
+ }
412
+
413
+ spinner.text = dim(` Scanned ${page * 50} issues, found ${scored.length} matches so far…`);
414
+
415
+ if (!pagination.hasNextPage || scored.length >= 50) break;
416
+ page++;
417
+ }
418
+
419
+ // Sort: by score desc, then by points desc
420
+ scored.sort((a, b) => {
421
+ if (b.score !== a.score) return b.score - a.score;
422
+ return (b.issue.points || 0) - (a.issue.points || 0);
423
+ });
424
+
425
+ const top = scored.slice(0, 30);
426
+ spinner.succeed(` Found ${scored.length} matching issue(s) — showing top ${top.length}`);
427
+
428
+ if (top.length === 0) {
429
+ console.log(yellow('\n No matching unassigned issues found. Try broadening your tech stack or lowering min points.\n'));
430
+ return;
431
+ }
432
+
433
+ console.log();
434
+
435
+ // Checkbox selection
436
+ // Pre-compute org counts to show how many slots remain per org
437
+ const previewOrgCounts = new Map<string, number>();
438
+ const checkboxChoices = top.map(({ issue, score, matchReasons }) => {
439
+ const pts = issue.points ? green(`+${issue.points}pts`) : dim('—pts');
440
+ const applicants = issue.pendingApplicationsCount > 0 ? yellow(`${issue.pendingApplicationsCount}▲`) : dim('0▲');
441
+ const match = matchReasons.length > 0 ? cyan(`[${matchReasons.slice(0, 3).join(', ')}]`) : '';
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);
448
+ return {
449
+ name: ` ${pts} ${bold(title)} ${match} ${applicants} ${orgTag}`,
450
+ value: issue,
451
+ checked: autoCheck,
452
+ };
453
+ });
454
+
455
+ const { selectedIssues } = await inquirer.prompt([{
456
+ type: 'checkbox',
457
+ name: 'selectedIssues',
458
+ message: bold('Select issues to apply for (Space to toggle, Enter to confirm):'),
459
+ choices: checkboxChoices,
460
+ pageSize: 20,
461
+ validate: (selected: any[]) => selected.length > 0 || 'Select at least one issue',
234
462
  }]);
235
463
 
236
- const slug = parseSlug(input.trim());
464
+ if (!selectedIssues || selectedIssues.length === 0) {
465
+ console.log(dim('\n No issues selected.\n'));
466
+ return;
467
+ }
237
468
 
238
- const progSpinner = ora(dim(` Loading "${slug}" wave program…`)).start();
239
- let program: DripsProgram;
469
+ console.log();
470
+ console.log(bold(` ${selectedIssues.length} issue(s) selected. Composing application message…`));
471
+ console.log();
472
+
473
+ // Single application message for all
474
+ const { style } = await inquirer.prompt([{
475
+ type: 'list',
476
+ name: 'style',
477
+ message: bold('Application message for all selected issues:'),
478
+ choices: [
479
+ { name: ` ${green('⚡')} Quick — standard intro message`, value: 'quick' },
480
+ { name: ` ${cyan('📝')} Custom — write one message for all`, value: 'custom' },
481
+ ],
482
+ }]);
483
+
484
+ let baseMessage: string;
485
+ if (style === 'quick') {
486
+ baseMessage = `Hi! I'd like to work on this issue.\n\nI'm available to start right away and will keep you updated on my progress. My stack includes ${profile.techStack.join(', ')}. Please consider assigning this to me.\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
487
+ } else {
488
+ const { msg } = await inquirer.prompt([{
489
+ type: 'input',
490
+ name: 'msg',
491
+ message: bold('Your message (applies to all selected issues):'),
492
+ validate: (v: string) => v.trim().length >= 10 || 'Write at least 10 characters',
493
+ }]);
494
+ baseMessage = `${msg.trim()}\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
495
+ }
496
+
497
+ const { go } = await inquirer.prompt([{
498
+ type: 'confirm',
499
+ name: 'go',
500
+ message: bold(`Apply to ${selectedIssues.length} issue(s) now?`),
501
+ default: true,
502
+ }]);
503
+
504
+ if (!go) return;
505
+
506
+ // Auth
507
+ let token: string;
240
508
  try {
241
- program = await getWaveProgram(slug);
242
- progSpinner.succeed(
243
- ` ${bold(program.name)} · ${cyan(program.issueCount.toLocaleString() + ' open issues')} · ${green(program.presetBudgetUSD + '/mo')} · ${dim(program.approvedRepoCount + ' repos')}`
244
- );
245
- } catch (e: any) {
246
- progSpinner.fail(` ${e.message}`);
509
+ token = await ensureDripsAuth();
510
+ } catch {
511
+ console.log(red('\n Authentication cancelled.\n'));
247
512
  return;
248
513
  }
249
514
 
515
+ console.log();
516
+ let applied = 0;
517
+ let failed = 0;
518
+ let skipped = 0;
519
+ const orgCounts = new Map<string, number>();
520
+
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
+
531
+ const spinner = ora(` Applying to: ${truncate(issue.title, 50)}…`).start();
532
+ try {
533
+ await dripsPost(
534
+ `/api/wave-programs/${program.id}/issues/${issue.id}/applications`,
535
+ { applicationText: baseMessage },
536
+ token,
537
+ );
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}]`));
540
+ applied++;
541
+ } catch (e: any) {
542
+ spinner.fail(red(` ✗ ${truncate(issue.title, 55)} — ${e.message}`));
543
+ failed++;
544
+ if (e.message.startsWith('401')) {
545
+ saveDripsToken('');
546
+ console.log(yellow('\n Session expired — run again to re-authenticate.\n'));
547
+ break;
548
+ }
549
+ }
550
+ }
551
+
552
+ console.log();
553
+ console.log(bold(` Done: ${green(applied + ' applied')}${skipped > 0 ? ', ' + yellow(skipped + ' skipped (org limit)') : ''}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
554
+ console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
555
+ }
556
+
557
+ // ── Browse by track ───────────────────────────────────────────────────────────
558
+
559
+ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Promise<void> {
560
+ // Ask which track to browse if they want to override
561
+ const { browseTrack } = await inquirer.prompt([{
562
+ type: 'list',
563
+ name: 'browseTrack',
564
+ message: bold('Browse which track?'),
565
+ default: profile.track,
566
+ choices: [
567
+ { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
568
+ { name: ` ${magenta('🎨')} Frontend`, value: 'frontend' },
569
+ { name: ` ${yellow('📜')} Smart Contract`, value: 'contract' },
570
+ { name: ` ${green('🌐')} All tracks`, value: 'all' },
571
+ ],
572
+ }]);
573
+
574
+ const selectedTrack: Track = browseTrack;
250
575
  let page = 1;
251
576
 
252
577
  while (true) {
253
- const issSpinner = ora(dim(` Fetching issues (page ${page})…`)).start();
254
- let issues: DripsIssue[];
255
- let pagination: DripsPagination;
578
+ const spinner = ora(dim(` Fetching unassigned issues (page ${page})…`)).start();
579
+ let allIssues: DripsIssue[] = [];
580
+ let hasNextPage = false;
581
+ let totalFetched = 0;
582
+
583
+ // Fetch a batch and filter client-side for unassigned + track
256
584
  try {
257
- const res = await getIssues(program.id, page);
258
- issues = res.data;
259
- pagination = res.pagination;
260
- issSpinner.succeed(
261
- ` Showing ${((page - 1) * 20) + 1}–${Math.min(page * 20, pagination.total)} of ${pagination.total.toLocaleString()} open issues`
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
+ );
601
+
602
+ // Sort by points descending (highest reward first)
603
+ allIssues.sort((a, b) => (b.points || 0) - (a.points || 0));
604
+
605
+ hasNextPage = pageB.pagination.hasNextPage || (pageA.pagination.hasNextPage && !pageB.pagination.hasNextPage);
606
+
607
+ spinner.succeed(
608
+ ` ${allIssues.length} unassigned ${selectedTrack} issue(s) found (from ${totalFetched} scanned) — sorted by points`
262
609
  );
263
610
  } catch (e: any) {
264
- issSpinner.fail(` ${e.message}`);
611
+ spinner.fail(` ${e.message}`);
265
612
  return;
266
613
  }
267
614
 
268
- console.log();
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
+ }
269
618
 
270
- const choices = issues.map((issue) => {
619
+ console.log();
620
+ const choices = allIssues.slice(0, 20).map((issue) => {
271
621
  const age = diffDays(issue.updatedAt);
272
622
  const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
273
- const pts = issue.points ? green(`+${issue.points}pts`) : dim(' pts');
623
+ const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
274
624
  const applicants = issue.pendingApplicationsCount > 0
275
- ? yellow(`${issue.pendingApplicationsCount} applied`)
276
- : dim('0 applied');
277
- const taken = issue.assignedApplicant ? red(' [assigned]') : '';
278
- const title = truncate(issue.title, 48);
625
+ ? yellow(`${issue.pendingApplicationsCount}▲`)
626
+ : dim('0');
627
+ const detected = detectTrack(issue);
628
+ const trackTag = detected ? dim(`[${detected}]`) : '';
629
+ const title = truncate(issue.title, 46);
279
630
  return {
280
- name: ` ${pts} ${bold(title)} ${applicants}${taken} ${ageStr}`,
631
+ name: ` ${pts} ${bold(title)} ${trackTag} ${applicants} ${ageStr}`,
281
632
  value: issue,
282
633
  short: issue.title,
283
634
  };
@@ -286,15 +637,15 @@ export async function dripsMenu(): Promise<void> {
286
637
  const nav: any[] = [
287
638
  new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
288
639
  ];
289
- if (pagination.hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
640
+ if (hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
290
641
  if (page > 1) nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
291
642
  nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
292
643
 
293
644
  const { selected } = await inquirer.prompt([{
294
645
  type: 'list',
295
646
  name: 'selected',
296
- message: bold(`${program.name} Wave choose an issue to apply for:`),
297
- choices: [...choices, ...nav],
647
+ message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
648
+ choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
298
649
  pageSize: 18,
299
650
  }]);
300
651
 
@@ -302,34 +653,29 @@ export async function dripsMenu(): Promise<void> {
302
653
  if (selected === '__next__') { page++; continue; }
303
654
  if (selected === '__prev__') { page = Math.max(1, page - 1); continue; }
304
655
 
305
- await applyToDripsIssue(program, selected as DripsIssue);
656
+ await applySingle(program, selected as DripsIssue, profile);
306
657
  }
307
658
  }
308
659
 
309
- // ── Apply ─────────────────────────────────────────────────────────────────────
660
+ // ── Apply single ──────────────────────────────────────────────────────────────
310
661
 
311
- async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Promise<void> {
662
+ async function applySingle(program: DripsProgram, issue: DripsIssue, profile: DripsProfile): Promise<void> {
312
663
  console.log();
313
664
  console.log(` ${bold(cyan(truncate(issue.title, 72)))}`);
314
665
  if (issue.repo) console.log(` ${dim('Repo: ')} ${issue.repo.fullName}`);
315
666
  console.log(` ${dim('Points: ')} ${issue.points ? green('+' + issue.points + ' pts') : dim('—')}`);
316
667
  console.log(` ${dim('Applied: ')} ${issue.pendingApplicationsCount} applicant(s)`);
317
- if (issue.assignedApplicant) {
318
- console.log(` ${yellow('⚠ Already assigned — you can still apply as a backup')}`);
319
- }
320
- if (issue.gitHubIssueUrl) {
321
- console.log(` ${dim('GitHub: ')} ${dim(issue.gitHubIssueUrl)}`);
322
- }
668
+ if (issue.gitHubIssueUrl) console.log(` ${dim('GitHub: ')} ${dim(issue.gitHubIssueUrl)}`);
323
669
  console.log();
324
670
 
325
671
  const { style } = await inquirer.prompt([{
326
672
  type: 'list',
327
673
  name: 'style',
328
- message: bold('How would you like to apply?'),
674
+ message: bold('Application style:'),
329
675
  choices: [
330
- { name: ` ${green('⚡')} Quick apply — standard intro message`, value: 'quick' },
331
- { name: ` ${cyan('📝')} Custom message — write your own`, value: 'custom' },
332
- new inquirer.Separator(dim(' ─────────────────────────────────')),
676
+ { name: ` ${green('⚡')} Quick — standard intro`, value: 'quick' },
677
+ { name: ` ${cyan('📝')} Custom — write your own`, value: 'custom' },
678
+ new inquirer.Separator(dim(' ────────────────')),
333
679
  { name: ` ${dim('⬅ Cancel')}`, value: 'cancel' },
334
680
  ],
335
681
  }]);
@@ -337,49 +683,33 @@ async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Prom
337
683
  if (style === 'cancel') return;
338
684
 
339
685
  let applicationText: string;
340
-
341
686
  if (style === 'quick') {
342
- applicationText = [
343
- `Hi! I'd like to work on this issue.`,
344
- ``,
345
- `I'm available to start right away and will keep you updated on progress. Please consider assigning this to me.`,
346
- ``,
347
- `*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`,
348
- ].join('\n');
687
+ applicationText = `Hi! I'd like to work on this issue.\n\nI'm available to start right away. My stack includes ${profile.techStack.join(', ')}. Please consider assigning this to me.\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
349
688
  } else {
350
689
  const { msg } = await inquirer.prompt([{
351
690
  type: 'input',
352
691
  name: 'msg',
353
- message: bold('Your application message (min 10 chars):'),
354
- validate: (v: string) => v.trim().length >= 10 || 'Please write at least 10 characters',
692
+ message: bold('Your message:'),
693
+ validate: (v: string) => v.trim().length >= 10 || 'Write at least 10 characters',
355
694
  }]);
356
- applicationText = [
357
- msg.trim(),
358
- ``,
359
- `*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`,
360
- ].join('\n');
695
+ applicationText = `${msg.trim()}\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
361
696
  }
362
697
 
363
- // Preview
364
698
  console.log();
365
- console.log(dim(' ── Preview ───────────────────────────────────────────────────'));
699
+ console.log(dim(' ── Preview ────────────────────────────────────────────────────'));
366
700
  applicationText.split('\n').forEach(l => console.log(` ${dim(l)}`));
367
- console.log(dim(' ──────────────────────────────────────────────────────────────'));
701
+ console.log(dim(' ───────────────────────────────────────────────────────────────'));
368
702
  console.log();
369
703
 
370
704
  const { confirm } = await inquirer.prompt([{
371
- type: 'list',
705
+ type: 'confirm',
372
706
  name: 'confirm',
373
- message: bold('Submit this application?'),
374
- choices: [
375
- { name: ` ${green('✅ Yes, apply')}`, value: 'yes' },
376
- { name: ` ${dim('❌ Cancel')}`, value: 'no' },
377
- ],
707
+ message: bold('Submit application?'),
708
+ default: true,
378
709
  }]);
379
710
 
380
- if (confirm === 'no') return;
711
+ if (!confirm) return;
381
712
 
382
- // Ensure auth only when the user actually commits to applying
383
713
  let token: string;
384
714
  try {
385
715
  token = await ensureDripsAuth();
@@ -388,16 +718,15 @@ async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Prom
388
718
  return;
389
719
  }
390
720
 
391
- const spinner = ora(' Submitting application…').start();
721
+ const spinner = ora(' Submitting…').start();
392
722
  try {
393
723
  await dripsPost(
394
724
  `/api/wave-programs/${program.id}/issues/${issue.id}/applications`,
395
725
  { applicationText },
396
726
  token,
397
727
  );
398
- spinner.succeed(green(` Applied for: ${bold(issue.title)}`));
399
- console.log(dim(`\n Track your applications:`));
400
- console.log(dim(` ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
728
+ spinner.succeed(green(` Applied! ${bold(issue.title)}`));
729
+ console.log(dim(`\n Track: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
401
730
  } catch (e: any) {
402
731
  spinner.fail(red(` Failed: ${e.message}`));
403
732
  if (e.message.startsWith('401')) {
@@ -406,3 +735,75 @@ async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Prom
406
735
  }
407
736
  }
408
737
  }
738
+
739
+ // ── Main menu ─────────────────────────────────────────────────────────────────
740
+
741
+ export async function dripsMenu(): Promise<void> {
742
+ // Load or set up profile first
743
+ let profile = loadProfile();
744
+
745
+ if (!profile) {
746
+ console.log();
747
+ console.log(bold(' Welcome to Drips Network! Let\'s set up your profile first.'));
748
+ profile = await setupProfile();
749
+ }
750
+
751
+ // Load the wave program
752
+ const progSpinner = ora(dim(` Loading "${profile.programSlug}" wave program…`)).start();
753
+ let program: DripsProgram;
754
+ try {
755
+ program = await getWaveProgram(profile.programSlug);
756
+ progSpinner.succeed(
757
+ ` ${bold(program.name)} · ${cyan(program.issueCount.toLocaleString() + ' issues')} · ${green(program.presetBudgetUSD + '/mo')} · ${dim(program.approvedRepoCount + ' repos')}`
758
+ );
759
+ } catch (e: any) {
760
+ progSpinner.fail(` ${e.message}`);
761
+ return;
762
+ }
763
+
764
+ while (true) {
765
+ console.log();
766
+ const { action } = await inquirer.prompt([{
767
+ type: 'list',
768
+ name: 'action',
769
+ message: bold(`Drips Network — ${program.name} Wave`),
770
+ choices: [
771
+ {
772
+ name: ` ${green('🤖')} ${bold('Auto-Apply for Me')} ${dim('— scan issues, match my stack, batch-apply')}`,
773
+ value: 'auto',
774
+ },
775
+ {
776
+ name: ` ${cyan('🔍')} ${bold('Browse by Track')} ${dim('— unassigned only, sorted by points, filter by track')}`,
777
+ value: 'browse',
778
+ },
779
+ {
780
+ name: ` ${dim('⚙️')} ${dim('Update my Profile')} ${dim('— change track, tech stack, min points')}`,
781
+ value: 'profile',
782
+ },
783
+ new inquirer.Separator(dim(' ─────────────────────────────────────────────')),
784
+ { name: ` ${dim('⬅ Back')}`, value: 'back' },
785
+ ],
786
+ loop: false,
787
+ }]);
788
+
789
+ if (action === 'back') return;
790
+
791
+ if (action === 'auto') {
792
+ await autoApply(program, profile);
793
+ } else if (action === 'browse') {
794
+ await browseByTrack(program, profile);
795
+ } else if (action === 'profile') {
796
+ profile = await setupProfile(profile);
797
+ // Reload program if slug changed
798
+ if (profile.programSlug !== program.slug) {
799
+ const s = ora(dim(` Switching to "${profile.programSlug}"…`)).start();
800
+ try {
801
+ program = await getWaveProgram(profile.programSlug);
802
+ s.succeed(` ${bold(program.name)} loaded`);
803
+ } catch (e: any) {
804
+ s.fail(` ${e.message}`);
805
+ }
806
+ }
807
+ }
808
+ }
809
+ }