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