gitpadi 2.1.1 → 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.
@@ -0,0 +1,669 @@
1
+ // commands/drips.ts — Smart Drips Network issue applicant
2
+ //
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
8
+ // Auth: GitHub OAuth → JWT cookie (wave_access_token)
9
+ import inquirer from 'inquirer';
10
+ import chalk from 'chalk';
11
+ import ora from 'ora';
12
+ import { execSync } from 'child_process';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import os from 'node:os';
16
+ const DRIPS_API = 'https://wave-api.drips.network';
17
+ const DRIPS_WEB = 'https://www.drips.network';
18
+ const CONFIG_DIR = path.join(os.homedir(), '.gitpadi');
19
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
20
+ const dim = chalk.dim;
21
+ const cyan = chalk.cyanBright;
22
+ const yellow = chalk.yellowBright;
23
+ const green = chalk.greenBright;
24
+ const bold = chalk.bold;
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
+ };
46
+ // ── Config ────────────────────────────────────────────────────────────────────
47
+ function loadConfig() {
48
+ if (!fs.existsSync(CONFIG_FILE))
49
+ return {};
50
+ try {
51
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ function saveConfig(patch) {
58
+ const current = loadConfig();
59
+ if (!fs.existsSync(CONFIG_DIR))
60
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
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 });
71
+ }
72
+ // ── Helpers ───────────────────────────────────────────────────────────────────
73
+ function truncate(text, max) {
74
+ return text.length <= max ? text : text.slice(0, max - 1) + '…';
75
+ }
76
+ function diffDays(dateStr) {
77
+ return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86_400_000);
78
+ }
79
+ function openBrowser(url) {
80
+ try {
81
+ if (process.platform === 'darwin')
82
+ execSync(`open "${url}"`, { stdio: 'ignore' });
83
+ else if (process.platform === 'win32')
84
+ execSync(`start "" "${url}"`, { stdio: 'ignore' });
85
+ else
86
+ execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
87
+ }
88
+ catch { /* ignore */ }
89
+ }
90
+ function parseSlug(input) {
91
+ const m = input.match(/drips\.network\/wave\/([^/?#\s]+)/);
92
+ if (m)
93
+ return m[1];
94
+ return input.replace(/^\/+|\/+$/g, '').split('/').pop() || input;
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
+ }
129
+ // ── API ───────────────────────────────────────────────────────────────────────
130
+ async function dripsGet(endpoint, token) {
131
+ const headers = { Accept: 'application/json' };
132
+ if (token)
133
+ headers['Cookie'] = `wave_access_token=${token}`;
134
+ const res = await fetch(`${DRIPS_API}${endpoint}`, { headers });
135
+ if (!res.ok)
136
+ throw new Error(`Drips API ${res.status} on ${endpoint}`);
137
+ return res.json();
138
+ }
139
+ async function dripsPost(endpoint, body, token) {
140
+ const res = await fetch(`${DRIPS_API}${endpoint}`, {
141
+ method: 'POST',
142
+ headers: {
143
+ 'Content-Type': 'application/json',
144
+ Accept: 'application/json',
145
+ Cookie: `wave_access_token=${token}`,
146
+ },
147
+ body: JSON.stringify(body),
148
+ });
149
+ const text = await res.text();
150
+ if (!res.ok) {
151
+ let msg = text;
152
+ try {
153
+ msg = JSON.parse(text)?.message || text;
154
+ }
155
+ catch { }
156
+ throw new Error(`${res.status}: ${msg}`);
157
+ }
158
+ try {
159
+ return JSON.parse(text);
160
+ }
161
+ catch {
162
+ return {};
163
+ }
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
+ }
176
+ // ── Auth ──────────────────────────────────────────────────────────────────────
177
+ export async function ensureDripsAuth() {
178
+ const config = loadConfig();
179
+ if (config.dripsToken) {
180
+ try {
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' } });
182
+ if (res.status !== 401)
183
+ return config.dripsToken;
184
+ }
185
+ catch { /* network issue */ }
186
+ }
187
+ console.log();
188
+ console.log(dim(' ┌─ Connect your Drips Network account ────────────────────────┐'));
189
+ console.log(dim(' │'));
190
+ console.log(dim(' │ 1. Log in at: ') + cyan(DRIPS_WEB + '/wave/login'));
191
+ console.log(dim(' │ (GitPadi will open it in your browser)'));
192
+ console.log(dim(' │'));
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'));
195
+ console.log(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(')'));
198
+ console.log(dim(' │'));
199
+ console.log(dim(' │ This is a one-time step — GitPadi saves it for future use.'));
200
+ console.log(dim(' │'));
201
+ console.log(dim(' └─────────────────────────────────────────────────────────────┘'));
202
+ console.log();
203
+ const { launch } = await inquirer.prompt([{
204
+ type: 'confirm',
205
+ name: 'launch',
206
+ message: 'Open drips.network login in your browser?',
207
+ default: true,
208
+ }]);
209
+ if (launch) {
210
+ openBrowser(`${DRIPS_WEB}/wave/login?skipWelcome=false`);
211
+ console.log(dim('\n Browser opened. Log in with GitHub, then come back here.\n'));
212
+ }
213
+ else {
214
+ console.log(dim(`\n Open manually: ${cyan(DRIPS_WEB + '/wave/login')}\n`));
215
+ }
216
+ const { token } = await inquirer.prompt([{
217
+ type: 'password',
218
+ name: 'token',
219
+ message: cyan('Paste your wave_access_token cookie value:'),
220
+ mask: '•',
221
+ validate: (v) => {
222
+ if (!v || v.trim().length < 20)
223
+ return 'Token seems too short — copy the full value from DevTools';
224
+ if (!v.trim().startsWith('eyJ'))
225
+ return 'Expected a JWT starting with eyJ — check you copied wave_access_token';
226
+ return true;
227
+ },
228
+ }]);
229
+ const t = token.trim();
230
+ saveDripsToken(t);
231
+ console.log(green('\n ✅ Drips session saved.\n'));
232
+ return t;
233
+ }
234
+ // ── Profile setup ─────────────────────────────────────────────────────────────
235
+ async function setupProfile(existing) {
236
+ console.log();
237
+ console.log(bold(' Set up your Drips profile — GitPadi uses this to find the right issues for you.'));
238
+ console.log();
239
+ const { programInput } = await inquirer.prompt([{
240
+ type: 'input',
241
+ name: 'programInput',
242
+ message: bold('Drips Wave program (slug or URL):'),
243
+ default: existing?.programSlug || 'stellar',
244
+ }]);
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;
406
+ try {
407
+ token = await ensureDripsAuth();
408
+ }
409
+ catch {
410
+ console.log(red('\n Authentication cancelled.\n'));
411
+ return;
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;
453
+ let page = 1;
454
+ while (true) {
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
460
+ try {
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`);
477
+ }
478
+ catch (e) {
479
+ spinner.fail(` ${e.message}`);
480
+ return;
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
+ }
485
+ console.log();
486
+ const choices = allIssues.slice(0, 20).map((issue) => {
487
+ const age = diffDays(issue.updatedAt);
488
+ const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
489
+ const pts = issue.points ? green(`+${issue.points}pts`) : dim(' —pts');
490
+ const applicants = issue.pendingApplicationsCount > 0
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);
496
+ return {
497
+ name: ` ${pts} ${bold(title)} ${trackTag} ${applicants} ${ageStr}`,
498
+ value: issue,
499
+ short: issue.title,
500
+ };
501
+ });
502
+ const nav = [
503
+ new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
504
+ ];
505
+ if (hasNextPage)
506
+ nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
507
+ if (page > 1)
508
+ nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
509
+ nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
510
+ const { selected } = await inquirer.prompt([{
511
+ type: 'list',
512
+ name: 'selected',
513
+ message: bold(`${program.name} — ${selectedTrack} issues sorted by points:`),
514
+ choices: allIssues.length > 0 ? [...choices, ...nav] : nav,
515
+ pageSize: 18,
516
+ }]);
517
+ if (selected === '__back__')
518
+ return;
519
+ if (selected === '__next__') {
520
+ page++;
521
+ continue;
522
+ }
523
+ if (selected === '__prev__') {
524
+ page = Math.max(1, page - 1);
525
+ continue;
526
+ }
527
+ await applySingle(program, selected, profile);
528
+ }
529
+ }
530
+ // ── Apply single ──────────────────────────────────────────────────────────────
531
+ async function applySingle(program, issue, profile) {
532
+ console.log();
533
+ console.log(` ${bold(cyan(truncate(issue.title, 72)))}`);
534
+ if (issue.repo)
535
+ console.log(` ${dim('Repo: ')} ${issue.repo.fullName}`);
536
+ console.log(` ${dim('Points: ')} ${issue.points ? green('+' + issue.points + ' pts') : dim('—')}`);
537
+ console.log(` ${dim('Applied: ')} ${issue.pendingApplicationsCount} applicant(s)`);
538
+ if (issue.gitHubIssueUrl)
539
+ console.log(` ${dim('GitHub: ')} ${dim(issue.gitHubIssueUrl)}`);
540
+ console.log();
541
+ const { style } = await inquirer.prompt([{
542
+ type: 'list',
543
+ name: 'style',
544
+ message: bold('Application style:'),
545
+ choices: [
546
+ { name: ` ${green('⚡')} Quick — standard intro`, value: 'quick' },
547
+ { name: ` ${cyan('📝')} Custom — write your own`, value: 'custom' },
548
+ new inquirer.Separator(dim(' ────────────────')),
549
+ { name: ` ${dim('⬅ Cancel')}`, value: 'cancel' },
550
+ ],
551
+ }]);
552
+ if (style === 'cancel')
553
+ return;
554
+ let applicationText;
555
+ if (style === 'quick') {
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)*`;
557
+ }
558
+ else {
559
+ const { msg } = await inquirer.prompt([{
560
+ type: 'input',
561
+ name: 'msg',
562
+ message: bold('Your message:'),
563
+ validate: (v) => v.trim().length >= 10 || 'Write at least 10 characters',
564
+ }]);
565
+ applicationText = `${msg.trim()}\n\n*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`;
566
+ }
567
+ console.log();
568
+ console.log(dim(' ── Preview ────────────────────────────────────────────────────'));
569
+ applicationText.split('\n').forEach(l => console.log(` ${dim(l)}`));
570
+ console.log(dim(' ───────────────────────────────────────────────────────────────'));
571
+ console.log();
572
+ const { confirm } = await inquirer.prompt([{
573
+ type: 'confirm',
574
+ name: 'confirm',
575
+ message: bold('Submit application?'),
576
+ default: true,
577
+ }]);
578
+ if (!confirm)
579
+ return;
580
+ let token;
581
+ try {
582
+ token = await ensureDripsAuth();
583
+ }
584
+ catch {
585
+ console.log(red('\n Authentication cancelled.\n'));
586
+ return;
587
+ }
588
+ const spinner = ora(' Submitting…').start();
589
+ try {
590
+ await dripsPost(`/api/wave-programs/${program.id}/issues/${issue.id}/applications`, { applicationText }, token);
591
+ spinner.succeed(green(` ✅ Applied! ${bold(issue.title)}`));
592
+ console.log(dim(`\n Track: ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
593
+ }
594
+ catch (e) {
595
+ spinner.fail(red(` Failed: ${e.message}`));
596
+ if (e.message.startsWith('401')) {
597
+ saveDripsToken('');
598
+ console.log(yellow('\n Session expired — run again to re-authenticate.\n'));
599
+ }
600
+ }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitpadi",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "GitPadi — AI-powered GitHub & GitLab management CLI. Fork repos, manage issues & PRs, score contributors, grade assignments, and automate everything. Powered by Anthropic Claude via GitLab Duo Agent Platform.",
5
5
  "type": "module",
6
6
  "bin": {