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