gitpadi 2.1.2 → 2.1.3

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,42 @@ 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
+ /** Detect which track an issue belongs to based on its labels and title */
158
+ function detectTrack(issue: DripsIssue): Exclude<Track, 'all'> | null {
159
+ const haystack = [...issueLabels(issue), issue.title.toLowerCase()].join(' ');
160
+ for (const [track, keywords] of Object.entries(TRACK_KEYWORDS) as [Exclude<Track, 'all'>, string[]][]) {
161
+ if (keywords.some(kw => haystack.includes(kw))) return track;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /** Score an issue against the user's tech stack. Returns 0 if no match. */
167
+ function scoreIssue(issue: DripsIssue, techStack: string[]): { score: number; matchReasons: string[] } {
168
+ const haystack = [...issueLabels(issue), issue.title.toLowerCase()].join(' ');
169
+ const matchReasons: string[] = [];
170
+
171
+ for (const tech of techStack) {
172
+ if (haystack.includes(tech.toLowerCase())) {
173
+ matchReasons.push(tech);
174
+ }
175
+ }
176
+
177
+ return { score: matchReasons.length, matchReasons };
178
+ }
179
+
180
+ /** Filter issues matching the user's track preference */
181
+ function matchesTrack(issue: DripsIssue, track: Track): boolean {
182
+ if (track === 'all') return true;
183
+ const detected = detectTrack(issue);
184
+ if (detected === track) return true;
185
+ // Also accept issues with no detected track when browsing 'all'
186
+ return false;
187
+ }
188
+
101
189
  // ── API ───────────────────────────────────────────────────────────────────────
102
190
 
103
191
  async function dripsGet(endpoint: string, token?: string): Promise<any> {
@@ -127,40 +215,51 @@ async function dripsPost(endpoint: string, body: any, token: string): Promise<an
127
215
  try { return JSON.parse(text); } catch { return {}; }
128
216
  }
129
217
 
218
+ async function fetchIssuePage(
219
+ programId: string,
220
+ page: number,
221
+ limit: number = 50,
222
+ ): Promise<{ data: DripsIssue[]; pagination: DripsPagination }> {
223
+ return dripsGet(
224
+ `/api/issues?waveProgramId=${programId}&state=open&page=${page}&limit=${limit}&sortBy=updatedAt`,
225
+ );
226
+ }
227
+
228
+ async function getWaveProgram(slug: string): Promise<DripsProgram> {
229
+ const data = await dripsGet(`/api/wave-programs?slug=${encodeURIComponent(slug)}`);
230
+ if (data?.id) return data as DripsProgram;
231
+ if (Array.isArray(data?.data) && data.data.length > 0) return data.data[0] as DripsProgram;
232
+ throw new Error(`Wave program "${slug}" not found. Check the slug or URL.`);
233
+ }
234
+
130
235
  // ── Auth ──────────────────────────────────────────────────────────────────────
131
236
 
132
237
  export async function ensureDripsAuth(): Promise<string> {
133
238
  const config = loadConfig();
134
239
 
135
240
  if (config.dripsToken) {
136
- // Validate by hitting a quota endpoint that requires auth
137
241
  try {
138
242
  const res = await fetch(
139
243
  `${DRIPS_API}/api/wave-programs/fdc01c95-806f-4b6a-998b-a6ed37e0d81b/quotas/applications`,
140
244
  { headers: { Cookie: `wave_access_token=${config.dripsToken}`, Accept: 'application/json' } }
141
245
  );
142
246
  if (res.status !== 401) return config.dripsToken as string;
143
- } catch { /* network issue, let user try anyway */ }
247
+ } catch { /* network issue */ }
144
248
  }
145
249
 
146
- // Show how-to instructions
147
250
  console.log();
148
251
  console.log(dim(' ┌─ Connect your Drips Network account ────────────────────────┐'));
149
252
  console.log(dim(' │'));
150
- console.log(dim(' │ Drips uses GitHub OAuth. Here\'s how to get your token:'));
151
- console.log(dim(' │'));
152
253
  console.log(dim(' │ 1. Log in at: ') + cyan(DRIPS_WEB + '/wave/login'));
153
- console.log(dim(' │ (GitPadi will open it in your browser now)'));
254
+ console.log(dim(' │ (GitPadi will open it in your browser)'));
154
255
  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'));
256
+ console.log(dim(' │ 2. After login, open DevTools:'));
257
+ console.log(dim(' │ ') + bold('F12') + dim(' → ') + bold('Application') + dim(' → ') + bold('Cookies') + dim(' → ') + cyan('www.drips.network'));
159
258
  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(')'));
259
+ console.log(dim(' │ 3. Find the cookie: ') + yellow('wave_access_token'));
260
+ console.log(dim(' │ Copy its full value (starts with ') + dim('eyJ...') + dim(')'));
162
261
  console.log(dim(' │'));
163
- console.log(dim(' │ GitPadi saves it onceyou won\'t need to do this again.'));
262
+ console.log(dim(' │ This is a one-time step GitPadi saves it for future use.'));
164
263
  console.log(dim(' │'));
165
264
  console.log(dim(' └─────────────────────────────────────────────────────────────┘'));
166
265
  console.log();
@@ -186,98 +285,325 @@ export async function ensureDripsAuth(): Promise<string> {
186
285
  mask: '•',
187
286
  validate: (v: string) => {
188
287
  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';
288
+ if (!v.trim().startsWith('eyJ')) return 'Expected a JWT starting with eyJ — check you copied wave_access_token';
190
289
  return true;
191
290
  },
192
291
  }]);
193
292
 
194
293
  const t = token.trim();
195
294
  saveDripsToken(t);
196
- console.log(green('\n ✅ Drips session saved — you\'re all set!\n'));
295
+ console.log(green('\n ✅ Drips session saved.\n'));
197
296
  return t;
198
297
  }
199
298
 
200
- // ── Program lookup ────────────────────────────────────────────────────────────
299
+ // ── Profile setup ─────────────────────────────────────────────────────────────
201
300
 
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
- }
301
+ async function setupProfile(existing?: DripsProfile): Promise<DripsProfile> {
302
+ console.log();
303
+ console.log(bold(' Set up your Drips profile — GitPadi uses this to find the right issues for you.'));
304
+ console.log();
208
305
 
209
- // ── Issues ────────────────────────────────────────────────────────────────────
306
+ const { programInput } = await inquirer.prompt([{
307
+ type: 'input',
308
+ name: 'programInput',
309
+ message: bold('Drips Wave program (slug or URL):'),
310
+ default: existing?.programSlug || 'stellar',
311
+ }]);
210
312
 
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
- );
313
+ const { track } = await inquirer.prompt([{
314
+ type: 'list',
315
+ name: 'track',
316
+ message: bold('What is your track?'),
317
+ default: existing?.track || 'backend',
318
+ choices: [
319
+ { name: ` ${cyan('⚙️')} Backend ${dim('— APIs, servers, databases, Node.js, Rust, Go...')}`, value: 'backend' },
320
+ { name: ` ${magenta('🎨')} Frontend ${dim('— React, Svelte, Vue, CSS, mobile...')}`, value: 'frontend' },
321
+ { name: ` ${yellow('📜')} Smart Contract ${dim('— Solidity, EVM, Anchor, DeFi, Web3...')}`, value: 'contract' },
322
+ { name: ` ${green('🌐')} All tracks ${dim('— show everything, filter by tech stack only')}`, value: 'all' },
323
+ ],
324
+ }]);
325
+
326
+ const { stackInput } = await inquirer.prompt([{
327
+ type: 'input',
328
+ name: 'stackInput',
329
+ message: bold('Your tech stack (comma-separated):'),
330
+ default: existing?.techStack.join(', ') || 'TypeScript, Node.js',
331
+ validate: (v: string) => v.trim().length > 0 || 'Enter at least one technology',
332
+ }]);
333
+
334
+ const { minPoints } = await inquirer.prompt([{
335
+ type: 'list',
336
+ name: 'minPoints',
337
+ message: bold('Minimum points to apply for:'),
338
+ default: existing?.minPoints || 0,
339
+ 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 },
345
+ ],
346
+ }]);
347
+
348
+ const techStack = stackInput.split(',').map((s: string) => s.trim()).filter(Boolean);
349
+ const programSlug = parseSlug(programInput.trim());
350
+
351
+ const profile: DripsProfile = { track, techStack, minPoints, programSlug };
352
+ saveProfile(profile);
353
+
354
+ console.log(green(`\n ✅ Profile saved — track: ${bold(track)}, stack: ${techStack.join(', ')}, min: ${minPoints}pts\n`));
355
+ return profile;
218
356
  }
219
357
 
220
- // ── Main menu ─────────────────────────────────────────────────────────────────
358
+ // ── Auto-apply ────────────────────────────────────────────────────────────────
221
359
 
222
- export async function dripsMenu(): Promise<void> {
360
+ async function autoApply(program: DripsProgram, profile: DripsProfile): Promise<void> {
223
361
  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.'));
362
+ console.log(bold(` Scanning ${program.name} issues for matches…`));
363
+ console.log(dim(` Track: ${profile.track} | Stack: ${profile.techStack.join(', ')} | Min: ${profile.minPoints}pts`));
226
364
  console.log();
227
365
 
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',
366
+ const spinner = ora(dim(' Fetching open issues…')).start();
367
+ const scored: ScoredIssue[] = [];
368
+ let page = 1;
369
+ const MAX_PAGES = 10; // scan up to 500 issues
370
+
371
+ while (page <= MAX_PAGES) {
372
+ let data: DripsIssue[];
373
+ let pagination: DripsPagination;
374
+
375
+ try {
376
+ const res = await fetchIssuePage(program.id, page, 50);
377
+ data = res.data;
378
+ pagination = res.pagination;
379
+ } catch (e: any) {
380
+ spinner.fail(` Fetch error: ${e.message}`);
381
+ return;
382
+ }
383
+
384
+ for (const issue of data) {
385
+ // Skip assigned issues
386
+ if (issue.assignedApplicant !== null) continue;
387
+
388
+ // Skip below min points (only if points are set)
389
+ if (profile.minPoints > 0 && issue.points !== null && issue.points < profile.minPoints) continue;
390
+
391
+ // Skip if track doesn't match
392
+ if (!matchesTrack(issue, profile.track)) continue;
393
+
394
+ // Score against tech stack
395
+ const { score, matchReasons } = scoreIssue(issue, profile.techStack);
396
+
397
+ // For 'all' track, accept any score >= 0. For specific tracks, require at least a label match
398
+ if (profile.track !== 'all' && score === 0) {
399
+ // Still include it if it clearly matches the track (track filter already handled above)
400
+ // but boost score so track-matching issues without stack matches still show up
401
+ scored.push({ issue, score: 0.5, matchReasons: [profile.track] });
402
+ } else {
403
+ scored.push({ issue, score, matchReasons: score > 0 ? matchReasons : [profile.track] });
404
+ }
405
+ }
406
+
407
+ spinner.text = dim(` Scanned ${page * 50} issues, found ${scored.length} matches so far…`);
408
+
409
+ if (!pagination.hasNextPage || scored.length >= 50) break;
410
+ page++;
411
+ }
412
+
413
+ // Sort: by score desc, then by points desc
414
+ scored.sort((a, b) => {
415
+ if (b.score !== a.score) return b.score - a.score;
416
+ return (b.issue.points || 0) - (a.issue.points || 0);
417
+ });
418
+
419
+ const top = scored.slice(0, 30);
420
+ spinner.succeed(` Found ${scored.length} matching issue(s) — showing top ${top.length}`);
421
+
422
+ if (top.length === 0) {
423
+ console.log(yellow('\n No matching unassigned issues found. Try broadening your tech stack or lowering min points.\n'));
424
+ return;
425
+ }
426
+
427
+ console.log();
428
+
429
+ // Checkbox selection
430
+ const checkboxChoices = top.map(({ issue, score, matchReasons }) => {
431
+ const pts = issue.points ? green(`+${issue.points}pts`) : dim('—pts');
432
+ const applicants = issue.pendingApplicationsCount > 0 ? yellow(`${issue.pendingApplicationsCount}▲`) : dim('0▲');
433
+ const match = matchReasons.length > 0 ? cyan(`[${matchReasons.slice(0, 3).join(', ')}]`) : '';
434
+ const title = truncate(issue.title, 44);
435
+ return {
436
+ name: ` ${pts} ${bold(title)} ${match} ${applicants}`,
437
+ value: issue,
438
+ checked: score >= 1, // pre-select strong matches
439
+ };
440
+ });
441
+
442
+ const { selectedIssues } = await inquirer.prompt([{
443
+ type: 'checkbox',
444
+ name: 'selectedIssues',
445
+ message: bold('Select issues to apply for (Space to toggle, Enter to confirm):'),
446
+ choices: checkboxChoices,
447
+ pageSize: 20,
448
+ validate: (selected: any[]) => selected.length > 0 || 'Select at least one issue',
234
449
  }]);
235
450
 
236
- const slug = parseSlug(input.trim());
451
+ if (!selectedIssues || selectedIssues.length === 0) {
452
+ console.log(dim('\n No issues selected.\n'));
453
+ return;
454
+ }
237
455
 
238
- const progSpinner = ora(dim(` Loading "${slug}" wave program…`)).start();
239
- let program: DripsProgram;
456
+ console.log();
457
+ console.log(bold(` ${selectedIssues.length} issue(s) selected. Composing application message…`));
458
+ console.log();
459
+
460
+ // Single application message for all
461
+ const { style } = await inquirer.prompt([{
462
+ type: 'list',
463
+ name: 'style',
464
+ message: bold('Application message for all selected issues:'),
465
+ choices: [
466
+ { name: ` ${green('⚡')} Quick — standard intro message`, value: 'quick' },
467
+ { name: ` ${cyan('📝')} Custom — write one message for all`, value: 'custom' },
468
+ ],
469
+ }]);
470
+
471
+ let baseMessage: string;
472
+ if (style === 'quick') {
473
+ 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)*`;
474
+ } else {
475
+ const { msg } = await inquirer.prompt([{
476
+ type: 'input',
477
+ name: 'msg',
478
+ message: bold('Your message (applies to all selected issues):'),
479
+ validate: (v: string) => v.trim().length >= 10 || 'Write at least 10 characters',
480
+ }]);
481
+ baseMessage = `${msg.trim()}\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
482
+ }
483
+
484
+ const { go } = await inquirer.prompt([{
485
+ type: 'confirm',
486
+ name: 'go',
487
+ message: bold(`Apply to ${selectedIssues.length} issue(s) now?`),
488
+ default: true,
489
+ }]);
490
+
491
+ if (!go) return;
492
+
493
+ // Auth
494
+ let token: string;
240
495
  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}`);
496
+ token = await ensureDripsAuth();
497
+ } catch {
498
+ console.log(red('\n Authentication cancelled.\n'));
247
499
  return;
248
500
  }
249
501
 
502
+ console.log();
503
+ let applied = 0;
504
+ let failed = 0;
505
+
506
+ for (const issue of selectedIssues as DripsIssue[]) {
507
+ const spinner = ora(` Applying to: ${truncate(issue.title, 50)}…`).start();
508
+ try {
509
+ await dripsPost(
510
+ `/api/wave-programs/${program.id}/issues/${issue.id}/applications`,
511
+ { applicationText: baseMessage },
512
+ token,
513
+ );
514
+ spinner.succeed(green(` ✅ ${truncate(issue.title, 55)}`) + (issue.points ? dim(` (+${issue.points}pts)`) : ''));
515
+ applied++;
516
+ } catch (e: any) {
517
+ spinner.fail(red(` ✗ ${truncate(issue.title, 55)} — ${e.message}`));
518
+ failed++;
519
+ if (e.message.startsWith('401')) {
520
+ saveDripsToken('');
521
+ console.log(yellow('\n Session expired — run again to re-authenticate.\n'));
522
+ break;
523
+ }
524
+ }
525
+ }
526
+
527
+ console.log();
528
+ console.log(bold(` Done: ${green(applied + ' applied')}${failed > 0 ? ', ' + red(failed + ' failed') : ''}`));
529
+ console.log(dim(`\n Track your applications: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
530
+ }
531
+
532
+ // ── Browse by track ───────────────────────────────────────────────────────────
533
+
534
+ async function browseByTrack(program: DripsProgram, profile: DripsProfile): Promise<void> {
535
+ // Ask which track to browse if they want to override
536
+ const { browseTrack } = await inquirer.prompt([{
537
+ type: 'list',
538
+ name: 'browseTrack',
539
+ message: bold('Browse which track?'),
540
+ default: profile.track,
541
+ choices: [
542
+ { name: ` ${cyan('⚙️')} Backend`, value: 'backend' },
543
+ { name: ` ${magenta('🎨')} Frontend`, value: 'frontend' },
544
+ { name: ` ${yellow('📜')} Smart Contract`, value: 'contract' },
545
+ { name: ` ${green('🌐')} All tracks`, value: 'all' },
546
+ ],
547
+ }]);
548
+
549
+ const selectedTrack: Track = browseTrack;
250
550
  let page = 1;
251
551
 
252
552
  while (true) {
253
- const issSpinner = ora(dim(` Fetching issues (page ${page})…`)).start();
254
- let issues: DripsIssue[];
255
- let pagination: DripsPagination;
553
+ const spinner = ora(dim(` Fetching unassigned issues (page ${page})…`)).start();
554
+ let allIssues: DripsIssue[] = [];
555
+ let hasNextPage = false;
556
+ let totalFetched = 0;
557
+
558
+ // Fetch a batch and filter client-side for unassigned + track
256
559
  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`
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
+ );
576
+
577
+ // Sort by points descending (highest reward first)
578
+ allIssues.sort((a, b) => (b.points || 0) - (a.points || 0));
579
+
580
+ hasNextPage = pageB.pagination.hasNextPage || (pageA.pagination.hasNextPage && !pageB.pagination.hasNextPage);
581
+
582
+ spinner.succeed(
583
+ ` ${allIssues.length} unassigned ${selectedTrack} issue(s) found (from ${totalFetched} scanned) — sorted by points`
262
584
  );
263
585
  } catch (e: any) {
264
- issSpinner.fail(` ${e.message}`);
586
+ spinner.fail(` ${e.message}`);
265
587
  return;
266
588
  }
267
589
 
268
- console.log();
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
+ }
269
593
 
270
- const choices = issues.map((issue) => {
594
+ console.log();
595
+ const choices = allIssues.slice(0, 20).map((issue) => {
271
596
  const age = diffDays(issue.updatedAt);
272
597
  const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
273
- const pts = issue.points ? green(`+${issue.points}pts`) : dim(' pts');
598
+ const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
274
599
  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);
600
+ ? yellow(`${issue.pendingApplicationsCount}▲`)
601
+ : dim('0');
602
+ const detected = detectTrack(issue);
603
+ const trackTag = detected ? dim(`[${detected}]`) : '';
604
+ const title = truncate(issue.title, 46);
279
605
  return {
280
- name: ` ${pts} ${bold(title)} ${applicants}${taken} ${ageStr}`,
606
+ name: ` ${pts} ${bold(title)} ${trackTag} ${applicants} ${ageStr}`,
281
607
  value: issue,
282
608
  short: issue.title,
283
609
  };
@@ -286,15 +612,15 @@ export async function dripsMenu(): Promise<void> {
286
612
  const nav: any[] = [
287
613
  new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
288
614
  ];
289
- if (pagination.hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
615
+ if (hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
290
616
  if (page > 1) nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
291
617
  nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
292
618
 
293
619
  const { selected } = await inquirer.prompt([{
294
620
  type: 'list',
295
621
  name: 'selected',
296
- message: bold(`${program.name} Wave choose an issue to apply for:`),
297
- choices: [...choices, ...nav],
622
+ message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
623
+ choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
298
624
  pageSize: 18,
299
625
  }]);
300
626
 
@@ -302,34 +628,29 @@ export async function dripsMenu(): Promise<void> {
302
628
  if (selected === '__next__') { page++; continue; }
303
629
  if (selected === '__prev__') { page = Math.max(1, page - 1); continue; }
304
630
 
305
- await applyToDripsIssue(program, selected as DripsIssue);
631
+ await applySingle(program, selected as DripsIssue, profile);
306
632
  }
307
633
  }
308
634
 
309
- // ── Apply ─────────────────────────────────────────────────────────────────────
635
+ // ── Apply single ──────────────────────────────────────────────────────────────
310
636
 
311
- async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Promise<void> {
637
+ async function applySingle(program: DripsProgram, issue: DripsIssue, profile: DripsProfile): Promise<void> {
312
638
  console.log();
313
639
  console.log(` ${bold(cyan(truncate(issue.title, 72)))}`);
314
640
  if (issue.repo) console.log(` ${dim('Repo: ')} ${issue.repo.fullName}`);
315
641
  console.log(` ${dim('Points: ')} ${issue.points ? green('+' + issue.points + ' pts') : dim('—')}`);
316
642
  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
- }
643
+ if (issue.gitHubIssueUrl) console.log(` ${dim('GitHub: ')} ${dim(issue.gitHubIssueUrl)}`);
323
644
  console.log();
324
645
 
325
646
  const { style } = await inquirer.prompt([{
326
647
  type: 'list',
327
648
  name: 'style',
328
- message: bold('How would you like to apply?'),
649
+ message: bold('Application style:'),
329
650
  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(' ─────────────────────────────────')),
651
+ { name: ` ${green('⚡')} Quick — standard intro`, value: 'quick' },
652
+ { name: ` ${cyan('📝')} Custom — write your own`, value: 'custom' },
653
+ new inquirer.Separator(dim(' ────────────────')),
333
654
  { name: ` ${dim('⬅ Cancel')}`, value: 'cancel' },
334
655
  ],
335
656
  }]);
@@ -337,49 +658,33 @@ async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Prom
337
658
  if (style === 'cancel') return;
338
659
 
339
660
  let applicationText: string;
340
-
341
661
  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');
662
+ 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
663
  } else {
350
664
  const { msg } = await inquirer.prompt([{
351
665
  type: 'input',
352
666
  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',
667
+ message: bold('Your message:'),
668
+ validate: (v: string) => v.trim().length >= 10 || 'Write at least 10 characters',
355
669
  }]);
356
- applicationText = [
357
- msg.trim(),
358
- ``,
359
- `*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`,
360
- ].join('\n');
670
+ applicationText = `${msg.trim()}\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
361
671
  }
362
672
 
363
- // Preview
364
673
  console.log();
365
- console.log(dim(' ── Preview ───────────────────────────────────────────────────'));
674
+ console.log(dim(' ── Preview ────────────────────────────────────────────────────'));
366
675
  applicationText.split('\n').forEach(l => console.log(` ${dim(l)}`));
367
- console.log(dim(' ──────────────────────────────────────────────────────────────'));
676
+ console.log(dim(' ───────────────────────────────────────────────────────────────'));
368
677
  console.log();
369
678
 
370
679
  const { confirm } = await inquirer.prompt([{
371
- type: 'list',
680
+ type: 'confirm',
372
681
  name: 'confirm',
373
- message: bold('Submit this application?'),
374
- choices: [
375
- { name: ` ${green('✅ Yes, apply')}`, value: 'yes' },
376
- { name: ` ${dim('❌ Cancel')}`, value: 'no' },
377
- ],
682
+ message: bold('Submit application?'),
683
+ default: true,
378
684
  }]);
379
685
 
380
- if (confirm === 'no') return;
686
+ if (!confirm) return;
381
687
 
382
- // Ensure auth only when the user actually commits to applying
383
688
  let token: string;
384
689
  try {
385
690
  token = await ensureDripsAuth();
@@ -388,16 +693,15 @@ async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Prom
388
693
  return;
389
694
  }
390
695
 
391
- const spinner = ora(' Submitting application…').start();
696
+ const spinner = ora(' Submitting…').start();
392
697
  try {
393
698
  await dripsPost(
394
699
  `/api/wave-programs/${program.id}/issues/${issue.id}/applications`,
395
700
  { applicationText },
396
701
  token,
397
702
  );
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`));
703
+ spinner.succeed(green(` Applied! ${bold(issue.title)}`));
704
+ console.log(dim(`\n Track: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
401
705
  } catch (e: any) {
402
706
  spinner.fail(red(` Failed: ${e.message}`));
403
707
  if (e.message.startsWith('401')) {
@@ -406,3 +710,75 @@ async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Prom
406
710
  }
407
711
  }
408
712
  }
713
+
714
+ // ── Main menu ─────────────────────────────────────────────────────────────────
715
+
716
+ export async function dripsMenu(): Promise<void> {
717
+ // Load or set up profile first
718
+ let profile = loadProfile();
719
+
720
+ if (!profile) {
721
+ console.log();
722
+ console.log(bold(' Welcome to Drips Network! Let\'s set up your profile first.'));
723
+ profile = await setupProfile();
724
+ }
725
+
726
+ // Load the wave program
727
+ const progSpinner = ora(dim(` Loading "${profile.programSlug}" wave program…`)).start();
728
+ let program: DripsProgram;
729
+ try {
730
+ program = await getWaveProgram(profile.programSlug);
731
+ progSpinner.succeed(
732
+ ` ${bold(program.name)} · ${cyan(program.issueCount.toLocaleString() + ' issues')} · ${green(program.presetBudgetUSD + '/mo')} · ${dim(program.approvedRepoCount + ' repos')}`
733
+ );
734
+ } catch (e: any) {
735
+ progSpinner.fail(` ${e.message}`);
736
+ return;
737
+ }
738
+
739
+ while (true) {
740
+ console.log();
741
+ const { action } = await inquirer.prompt([{
742
+ type: 'list',
743
+ name: 'action',
744
+ message: bold(`Drips Network — ${program.name} Wave`),
745
+ choices: [
746
+ {
747
+ name: ` ${green('🤖')} ${bold('Auto-Apply for Me')} ${dim('— scan issues, match my stack, batch-apply')}`,
748
+ value: 'auto',
749
+ },
750
+ {
751
+ name: ` ${cyan('🔍')} ${bold('Browse by Track')} ${dim('— unassigned only, sorted by points, filter by track')}`,
752
+ value: 'browse',
753
+ },
754
+ {
755
+ name: ` ${dim('⚙️')} ${dim('Update my Profile')} ${dim('— change track, tech stack, min points')}`,
756
+ value: 'profile',
757
+ },
758
+ new inquirer.Separator(dim(' ─────────────────────────────────────────────')),
759
+ { name: ` ${dim('⬅ Back')}`, value: 'back' },
760
+ ],
761
+ loop: false,
762
+ }]);
763
+
764
+ if (action === 'back') return;
765
+
766
+ if (action === 'auto') {
767
+ await autoApply(program, profile);
768
+ } else if (action === 'browse') {
769
+ await browseByTrack(program, profile);
770
+ } else if (action === 'profile') {
771
+ profile = await setupProfile(profile);
772
+ // Reload program if slug changed
773
+ if (profile.programSlug !== program.slug) {
774
+ const s = ora(dim(` Switching to "${profile.programSlug}"…`)).start();
775
+ try {
776
+ program = await getWaveProgram(profile.programSlug);
777
+ s.succeed(` ${bold(program.name)} loaded`);
778
+ } catch (e: any) {
779
+ s.fail(` ${e.message}`);
780
+ }
781
+ }
782
+ }
783
+ }
784
+ }