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