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