gitpadi 2.0.7 → 2.1.2
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/.gitlab/duo/chat-rules.md +40 -0
- package/.gitlab/duo/mr-review-instructions.md +44 -0
- package/.gitlab-ci.yml +136 -0
- package/README.md +585 -57
- package/action.yml +21 -2
- package/dist/applicant-scorer.js +27 -105
- package/dist/cli.js +1045 -34
- package/dist/commands/apply-for-issue.js +396 -0
- package/dist/commands/bounty-hunter.js +441 -0
- package/dist/commands/contribute.js +245 -51
- package/dist/commands/drips.js +351 -0
- package/dist/commands/gitlab-issues.js +87 -0
- package/dist/commands/gitlab-mrs.js +163 -0
- package/dist/commands/gitlab-pipelines.js +95 -0
- package/dist/commands/prs.js +3 -3
- package/dist/core/github.js +24 -0
- package/dist/core/gitlab.js +233 -0
- package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
- package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
- package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
- package/dist/gitlab-agents/mr-review-agent.js +200 -0
- package/dist/gitlab-agents/reminder-agent.js +164 -0
- package/dist/grade-assignment.js +262 -0
- package/dist/remind-contributors.js +127 -0
- package/dist/review-and-merge.js +125 -0
- package/examples/gitpadi.yml +152 -0
- package/package.json +20 -4
- package/src/applicant-scorer.ts +33 -141
- package/src/cli.ts +1078 -34
- package/src/commands/apply-for-issue.ts +452 -0
- package/src/commands/bounty-hunter.ts +529 -0
- package/src/commands/contribute.ts +264 -50
- package/src/commands/drips.ts +408 -0
- package/src/commands/gitlab-issues.ts +87 -0
- package/src/commands/gitlab-mrs.ts +185 -0
- package/src/commands/gitlab-pipelines.ts +104 -0
- package/src/commands/prs.ts +3 -3
- package/src/core/github.ts +24 -0
- package/src/core/gitlab.ts +397 -0
- package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
- package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
- package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
- package/src/gitlab-agents/mr-review-agent.ts +231 -0
- package/src/gitlab-agents/reminder-agent.ts +203 -0
- package/src/grade-assignment.ts +283 -0
- package/src/remind-contributors.ts +159 -0
- package/src/review-and-merge.ts +143 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// commands/drips.ts — Apply for bounty issues on Drips Network from your terminal
|
|
2
|
+
//
|
|
3
|
+
// API reverse-engineered from https://github.com/drips-network/app
|
|
4
|
+
// Base: https://wave-api.drips.network
|
|
5
|
+
// Auth: GitHub OAuth → JWT cookie (wave_access_token)
|
|
6
|
+
|
|
7
|
+
import inquirer from 'inquirer';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
|
|
15
|
+
const DRIPS_API = 'https://wave-api.drips.network';
|
|
16
|
+
const DRIPS_WEB = 'https://www.drips.network';
|
|
17
|
+
const CONFIG_DIR = path.join(os.homedir(), '.gitpadi');
|
|
18
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
19
|
+
|
|
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
|
+
|
|
27
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface DripsIssue {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
state: string;
|
|
33
|
+
points: number | null;
|
|
34
|
+
complexity: string | null;
|
|
35
|
+
labels: Array<{ id: string; name: string }>;
|
|
36
|
+
repo: { name: string; fullName: string; url: string } | null;
|
|
37
|
+
assignedApplicant: any | null;
|
|
38
|
+
pendingApplicationsCount: number;
|
|
39
|
+
waveProgramId: string | null;
|
|
40
|
+
gitHubIssueUrl: string | null;
|
|
41
|
+
gitHubIssueNumber: number | null;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DripsPagination {
|
|
46
|
+
page: number;
|
|
47
|
+
limit: number;
|
|
48
|
+
total: number;
|
|
49
|
+
totalPages: number;
|
|
50
|
+
hasNextPage: boolean;
|
|
51
|
+
hasPreviousPage: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface DripsProgram {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
slug: string;
|
|
58
|
+
description: string;
|
|
59
|
+
issueCount: number;
|
|
60
|
+
approvedRepoCount: number;
|
|
61
|
+
presetBudgetUSD: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function loadConfig(): Record<string, any> {
|
|
67
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
68
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } catch { return {}; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveDripsToken(token: string): void {
|
|
72
|
+
const current = loadConfig();
|
|
73
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
74
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, dripsToken: token }, null, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function truncate(text: string, max: number): string {
|
|
80
|
+
return text.length <= max ? text : text.slice(0, max - 1) + '…';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diffDays(dateStr: string): number {
|
|
84
|
+
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86_400_000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openBrowser(url: string): void {
|
|
88
|
+
try {
|
|
89
|
+
if (process.platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
90
|
+
else if (process.platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
91
|
+
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
92
|
+
} catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseSlug(input: string): string {
|
|
96
|
+
const m = input.match(/drips\.network\/wave\/([^/?#\s]+)/);
|
|
97
|
+
if (m) return m[1];
|
|
98
|
+
return input.replace(/^\/+|\/+$/g, '').split('/').pop() || input;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── API ───────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async function dripsGet(endpoint: string, token?: string): Promise<any> {
|
|
104
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
105
|
+
if (token) headers['Cookie'] = `wave_access_token=${token}`;
|
|
106
|
+
const res = await fetch(`${DRIPS_API}${endpoint}`, { headers });
|
|
107
|
+
if (!res.ok) throw new Error(`Drips API ${res.status} on ${endpoint}`);
|
|
108
|
+
return res.json();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function dripsPost(endpoint: string, body: any, token: string): Promise<any> {
|
|
112
|
+
const res = await fetch(`${DRIPS_API}${endpoint}`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
Accept: 'application/json',
|
|
117
|
+
Cookie: `wave_access_token=${token}`,
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
});
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
let msg = text;
|
|
124
|
+
try { msg = JSON.parse(text)?.message || text; } catch { }
|
|
125
|
+
throw new Error(`${res.status}: ${msg}`);
|
|
126
|
+
}
|
|
127
|
+
try { return JSON.parse(text); } catch { return {}; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export async function ensureDripsAuth(): Promise<string> {
|
|
133
|
+
const config = loadConfig();
|
|
134
|
+
|
|
135
|
+
if (config.dripsToken) {
|
|
136
|
+
// Validate by hitting a quota endpoint that requires auth
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(
|
|
139
|
+
`${DRIPS_API}/api/wave-programs/fdc01c95-806f-4b6a-998b-a6ed37e0d81b/quotas/applications`,
|
|
140
|
+
{ headers: { Cookie: `wave_access_token=${config.dripsToken}`, Accept: 'application/json' } }
|
|
141
|
+
);
|
|
142
|
+
if (res.status !== 401) return config.dripsToken as string;
|
|
143
|
+
} catch { /* network issue, let user try anyway */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Show how-to instructions
|
|
147
|
+
console.log();
|
|
148
|
+
console.log(dim(' ┌─ Connect your Drips Network account ────────────────────────┐'));
|
|
149
|
+
console.log(dim(' │'));
|
|
150
|
+
console.log(dim(' │ Drips uses GitHub OAuth. Here\'s how to get your token:'));
|
|
151
|
+
console.log(dim(' │'));
|
|
152
|
+
console.log(dim(' │ 1. Log in at: ') + cyan(DRIPS_WEB + '/wave/login'));
|
|
153
|
+
console.log(dim(' │ (GitPadi will open it in your browser now)'));
|
|
154
|
+
console.log(dim(' │'));
|
|
155
|
+
console.log(dim(' │ 2. After logging in with GitHub, open DevTools:'));
|
|
156
|
+
console.log(dim(' │ ') + bold('F12') + dim(' (or Cmd+Option+I on Mac)'));
|
|
157
|
+
console.log(dim(' │ → ') + bold('Application') + dim(' tab'));
|
|
158
|
+
console.log(dim(' │ → ') + bold('Cookies') + dim(' → ') + cyan('www.drips.network'));
|
|
159
|
+
console.log(dim(' │'));
|
|
160
|
+
console.log(dim(' │ 3. Find the cookie named ') + yellow('wave_access_token'));
|
|
161
|
+
console.log(dim(' │ Copy the full value (it starts with ') + dim('eyJ...') + dim(')'));
|
|
162
|
+
console.log(dim(' │'));
|
|
163
|
+
console.log(dim(' │ GitPadi saves it once — you won\'t need to do this again.'));
|
|
164
|
+
console.log(dim(' │'));
|
|
165
|
+
console.log(dim(' └─────────────────────────────────────────────────────────────┘'));
|
|
166
|
+
console.log();
|
|
167
|
+
|
|
168
|
+
const { launch } = await inquirer.prompt([{
|
|
169
|
+
type: 'confirm',
|
|
170
|
+
name: 'launch',
|
|
171
|
+
message: 'Open drips.network login in your browser?',
|
|
172
|
+
default: true,
|
|
173
|
+
}]);
|
|
174
|
+
|
|
175
|
+
if (launch) {
|
|
176
|
+
openBrowser(`${DRIPS_WEB}/wave/login?skipWelcome=false`);
|
|
177
|
+
console.log(dim('\n Browser opened. Log in with GitHub, then come back here.\n'));
|
|
178
|
+
} else {
|
|
179
|
+
console.log(dim(`\n Open manually: ${cyan(DRIPS_WEB + '/wave/login')}\n`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { token } = await inquirer.prompt([{
|
|
183
|
+
type: 'password',
|
|
184
|
+
name: 'token',
|
|
185
|
+
message: cyan('Paste your wave_access_token cookie value:'),
|
|
186
|
+
mask: '•',
|
|
187
|
+
validate: (v: string) => {
|
|
188
|
+
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 — make sure you copied wave_access_token, not another cookie';
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
}]);
|
|
193
|
+
|
|
194
|
+
const t = token.trim();
|
|
195
|
+
saveDripsToken(t);
|
|
196
|
+
console.log(green('\n ✅ Drips session saved — you\'re all set!\n'));
|
|
197
|
+
return t;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Program lookup ────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async function getWaveProgram(slug: string): Promise<DripsProgram> {
|
|
203
|
+
const data = await dripsGet(`/api/wave-programs?slug=${encodeURIComponent(slug)}`);
|
|
204
|
+
if (data?.id) return data as DripsProgram;
|
|
205
|
+
if (Array.isArray(data?.data) && data.data.length > 0) return data.data[0] as DripsProgram;
|
|
206
|
+
throw new Error(`Wave program "${slug}" not found. Check the slug or URL.`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Issues ────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
async function getIssues(
|
|
212
|
+
programId: string,
|
|
213
|
+
page: number,
|
|
214
|
+
): Promise<{ data: DripsIssue[]; pagination: DripsPagination }> {
|
|
215
|
+
return dripsGet(
|
|
216
|
+
`/api/issues?waveProgramId=${programId}&state=open&page=${page}&limit=20&sortBy=updatedAt`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Main menu ─────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
export async function dripsMenu(): Promise<void> {
|
|
223
|
+
console.log();
|
|
224
|
+
console.log(bold(' Drips Network — Apply for bounty issues from your terminal'));
|
|
225
|
+
console.log(dim(' Browse issues from any Wave program and apply in seconds.'));
|
|
226
|
+
console.log();
|
|
227
|
+
|
|
228
|
+
const { input } = await inquirer.prompt([{
|
|
229
|
+
type: 'input',
|
|
230
|
+
name: 'input',
|
|
231
|
+
message: bold('Enter Drips Wave URL or program slug:'),
|
|
232
|
+
default: 'stellar',
|
|
233
|
+
validate: (v: string) => v.trim().length > 0 || 'Required',
|
|
234
|
+
}]);
|
|
235
|
+
|
|
236
|
+
const slug = parseSlug(input.trim());
|
|
237
|
+
|
|
238
|
+
const progSpinner = ora(dim(` Loading "${slug}" wave program…`)).start();
|
|
239
|
+
let program: DripsProgram;
|
|
240
|
+
try {
|
|
241
|
+
program = await getWaveProgram(slug);
|
|
242
|
+
progSpinner.succeed(
|
|
243
|
+
` ${bold(program.name)} · ${cyan(program.issueCount.toLocaleString() + ' open issues')} · ${green(program.presetBudgetUSD + '/mo')} · ${dim(program.approvedRepoCount + ' repos')}`
|
|
244
|
+
);
|
|
245
|
+
} catch (e: any) {
|
|
246
|
+
progSpinner.fail(` ${e.message}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let page = 1;
|
|
251
|
+
|
|
252
|
+
while (true) {
|
|
253
|
+
const issSpinner = ora(dim(` Fetching issues (page ${page})…`)).start();
|
|
254
|
+
let issues: DripsIssue[];
|
|
255
|
+
let pagination: DripsPagination;
|
|
256
|
+
try {
|
|
257
|
+
const res = await getIssues(program.id, page);
|
|
258
|
+
issues = res.data;
|
|
259
|
+
pagination = res.pagination;
|
|
260
|
+
issSpinner.succeed(
|
|
261
|
+
` Showing ${((page - 1) * 20) + 1}–${Math.min(page * 20, pagination.total)} of ${pagination.total.toLocaleString()} open issues`
|
|
262
|
+
);
|
|
263
|
+
} catch (e: any) {
|
|
264
|
+
issSpinner.fail(` ${e.message}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
console.log();
|
|
269
|
+
|
|
270
|
+
const choices = issues.map((issue) => {
|
|
271
|
+
const age = diffDays(issue.updatedAt);
|
|
272
|
+
const ageStr = age === 0 ? dim('today') : dim(`${age}d ago`);
|
|
273
|
+
const pts = issue.points ? green(`+${issue.points}pts`) : dim(' — pts');
|
|
274
|
+
const applicants = issue.pendingApplicationsCount > 0
|
|
275
|
+
? yellow(`${issue.pendingApplicationsCount} applied`)
|
|
276
|
+
: dim('0 applied');
|
|
277
|
+
const taken = issue.assignedApplicant ? red(' [assigned]') : '';
|
|
278
|
+
const title = truncate(issue.title, 48);
|
|
279
|
+
return {
|
|
280
|
+
name: ` ${pts} ${bold(title)} ${applicants}${taken} ${ageStr}`,
|
|
281
|
+
value: issue,
|
|
282
|
+
short: issue.title,
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const nav: any[] = [
|
|
287
|
+
new inquirer.Separator(dim(' ─────────────────────────────────────────────────────')),
|
|
288
|
+
];
|
|
289
|
+
if (pagination.hasNextPage) nav.push({ name: ` ${dim('→ Next page')}`, value: '__next__' });
|
|
290
|
+
if (page > 1) nav.push({ name: ` ${dim('← Previous page')}`, value: '__prev__' });
|
|
291
|
+
nav.push({ name: ` ${dim('⬅ Back')}`, value: '__back__' });
|
|
292
|
+
|
|
293
|
+
const { selected } = await inquirer.prompt([{
|
|
294
|
+
type: 'list',
|
|
295
|
+
name: 'selected',
|
|
296
|
+
message: bold(`${program.name} Wave — choose an issue to apply for:`),
|
|
297
|
+
choices: [...choices, ...nav],
|
|
298
|
+
pageSize: 18,
|
|
299
|
+
}]);
|
|
300
|
+
|
|
301
|
+
if (selected === '__back__') return;
|
|
302
|
+
if (selected === '__next__') { page++; continue; }
|
|
303
|
+
if (selected === '__prev__') { page = Math.max(1, page - 1); continue; }
|
|
304
|
+
|
|
305
|
+
await applyToDripsIssue(program, selected as DripsIssue);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Apply ─────────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
async function applyToDripsIssue(program: DripsProgram, issue: DripsIssue): Promise<void> {
|
|
312
|
+
console.log();
|
|
313
|
+
console.log(` ${bold(cyan(truncate(issue.title, 72)))}`);
|
|
314
|
+
if (issue.repo) console.log(` ${dim('Repo: ')} ${issue.repo.fullName}`);
|
|
315
|
+
console.log(` ${dim('Points: ')} ${issue.points ? green('+' + issue.points + ' pts') : dim('—')}`);
|
|
316
|
+
console.log(` ${dim('Applied: ')} ${issue.pendingApplicationsCount} applicant(s)`);
|
|
317
|
+
if (issue.assignedApplicant) {
|
|
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
|
+
}
|
|
323
|
+
console.log();
|
|
324
|
+
|
|
325
|
+
const { style } = await inquirer.prompt([{
|
|
326
|
+
type: 'list',
|
|
327
|
+
name: 'style',
|
|
328
|
+
message: bold('How would you like to apply?'),
|
|
329
|
+
choices: [
|
|
330
|
+
{ name: ` ${green('⚡')} Quick apply — standard intro message`, value: 'quick' },
|
|
331
|
+
{ name: ` ${cyan('📝')} Custom message — write your own`, value: 'custom' },
|
|
332
|
+
new inquirer.Separator(dim(' ─────────────────────────────────')),
|
|
333
|
+
{ name: ` ${dim('⬅ Cancel')}`, value: 'cancel' },
|
|
334
|
+
],
|
|
335
|
+
}]);
|
|
336
|
+
|
|
337
|
+
if (style === 'cancel') return;
|
|
338
|
+
|
|
339
|
+
let applicationText: string;
|
|
340
|
+
|
|
341
|
+
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');
|
|
349
|
+
} else {
|
|
350
|
+
const { msg } = await inquirer.prompt([{
|
|
351
|
+
type: 'input',
|
|
352
|
+
name: 'msg',
|
|
353
|
+
message: bold('Your application message (min 10 chars):'),
|
|
354
|
+
validate: (v: string) => v.trim().length >= 10 || 'Please write at least 10 characters',
|
|
355
|
+
}]);
|
|
356
|
+
applicationText = [
|
|
357
|
+
msg.trim(),
|
|
358
|
+
``,
|
|
359
|
+
`*Applied via [GitPadi](https://github.com/Netwalls/contributor-agent)*`,
|
|
360
|
+
].join('\n');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Preview
|
|
364
|
+
console.log();
|
|
365
|
+
console.log(dim(' ── Preview ───────────────────────────────────────────────────'));
|
|
366
|
+
applicationText.split('\n').forEach(l => console.log(` ${dim(l)}`));
|
|
367
|
+
console.log(dim(' ──────────────────────────────────────────────────────────────'));
|
|
368
|
+
console.log();
|
|
369
|
+
|
|
370
|
+
const { confirm } = await inquirer.prompt([{
|
|
371
|
+
type: 'list',
|
|
372
|
+
name: 'confirm',
|
|
373
|
+
message: bold('Submit this application?'),
|
|
374
|
+
choices: [
|
|
375
|
+
{ name: ` ${green('✅ Yes, apply')}`, value: 'yes' },
|
|
376
|
+
{ name: ` ${dim('❌ Cancel')}`, value: 'no' },
|
|
377
|
+
],
|
|
378
|
+
}]);
|
|
379
|
+
|
|
380
|
+
if (confirm === 'no') return;
|
|
381
|
+
|
|
382
|
+
// Ensure auth only when the user actually commits to applying
|
|
383
|
+
let token: string;
|
|
384
|
+
try {
|
|
385
|
+
token = await ensureDripsAuth();
|
|
386
|
+
} catch {
|
|
387
|
+
console.log(red('\n Authentication cancelled.\n'));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const spinner = ora(' Submitting application…').start();
|
|
392
|
+
try {
|
|
393
|
+
await dripsPost(
|
|
394
|
+
`/api/wave-programs/${program.id}/issues/${issue.id}/applications`,
|
|
395
|
+
{ applicationText },
|
|
396
|
+
token,
|
|
397
|
+
);
|
|
398
|
+
spinner.succeed(green(` Applied for: ${bold(issue.title)}`));
|
|
399
|
+
console.log(dim(`\n Track your applications:`));
|
|
400
|
+
console.log(dim(` ${cyan(DRIPS_WEB + '/wave/' + program.slug)}\n`));
|
|
401
|
+
} catch (e: any) {
|
|
402
|
+
spinner.fail(red(` Failed: ${e.message}`));
|
|
403
|
+
if (e.message.startsWith('401')) {
|
|
404
|
+
saveDripsToken('');
|
|
405
|
+
console.log(yellow('\n Session expired — run again to re-authenticate.\n'));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// commands/gitlab-issues.ts — GitLab issue management for GitPadi
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import Table from 'cli-table3';
|
|
6
|
+
import {
|
|
7
|
+
getNamespace, getProject, getFullProject, requireGitLabProject,
|
|
8
|
+
listGitLabIssues, createGitLabIssue, updateGitLabIssue, createGitLabIssueNote,
|
|
9
|
+
withGitLabRetry,
|
|
10
|
+
} from '../core/gitlab.js';
|
|
11
|
+
|
|
12
|
+
export async function listIssues(opts: { state?: string; limit?: number } = {}) {
|
|
13
|
+
requireGitLabProject();
|
|
14
|
+
const spinner = ora(`Fetching issues from ${chalk.cyan(getFullProject())}...`).start();
|
|
15
|
+
try {
|
|
16
|
+
const state = (opts.state === 'closed' ? 'closed' : opts.state === 'all' ? 'all' : 'opened') as 'opened' | 'closed' | 'all';
|
|
17
|
+
const issues = await withGitLabRetry(() =>
|
|
18
|
+
listGitLabIssues(getNamespace(), getProject(), { state, per_page: opts.limit || 50 })
|
|
19
|
+
);
|
|
20
|
+
spinner.stop();
|
|
21
|
+
|
|
22
|
+
if (!issues.length) { console.log(chalk.yellow('\n No issues found.\n')); return; }
|
|
23
|
+
|
|
24
|
+
const table = new Table({
|
|
25
|
+
head: ['#', 'Title', 'Author', 'State', 'Labels'].map(h => chalk.cyan(h)),
|
|
26
|
+
style: { head: [], border: [] },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
issues.forEach(issue => {
|
|
30
|
+
const stateLabel = issue.state === 'opened' ? chalk.green('open') : chalk.red('closed');
|
|
31
|
+
table.push([
|
|
32
|
+
`#${issue.iid}`,
|
|
33
|
+
issue.title.substring(0, 50),
|
|
34
|
+
`@${issue.author.username}`,
|
|
35
|
+
stateLabel,
|
|
36
|
+
issue.labels.slice(0, 3).join(', ') || '-',
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
console.log(`\n${chalk.bold(`📋 Issues — ${getFullProject()}`)} (${issues.length})\n`);
|
|
41
|
+
console.log(table.toString());
|
|
42
|
+
console.log('');
|
|
43
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function createIssue(opts: { title: string; description?: string; labels?: string }) {
|
|
47
|
+
requireGitLabProject();
|
|
48
|
+
const spinner = ora('Creating issue...').start();
|
|
49
|
+
try {
|
|
50
|
+
const issue = await withGitLabRetry(() =>
|
|
51
|
+
createGitLabIssue(getNamespace(), getProject(), {
|
|
52
|
+
title: opts.title,
|
|
53
|
+
description: opts.description,
|
|
54
|
+
labels: opts.labels,
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
spinner.succeed(`Created issue ${chalk.green(`#${issue.iid}`)}: ${issue.title}`);
|
|
58
|
+
console.log(chalk.dim(` ${issue.web_url}\n`));
|
|
59
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function closeIssue(iid: number) {
|
|
63
|
+
requireGitLabProject();
|
|
64
|
+
const spinner = ora(`Closing issue #${iid}...`).start();
|
|
65
|
+
try {
|
|
66
|
+
await withGitLabRetry(() => updateGitLabIssue(getNamespace(), getProject(), iid, { state_event: 'close' }));
|
|
67
|
+
spinner.succeed(`Closed issue ${chalk.red(`#${iid}`)}`);
|
|
68
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function reopenIssue(iid: number) {
|
|
72
|
+
requireGitLabProject();
|
|
73
|
+
const spinner = ora(`Reopening issue #${iid}...`).start();
|
|
74
|
+
try {
|
|
75
|
+
await withGitLabRetry(() => updateGitLabIssue(getNamespace(), getProject(), iid, { state_event: 'reopen' }));
|
|
76
|
+
spinner.succeed(`Reopened issue ${chalk.green(`#${iid}`)}`);
|
|
77
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function commentOnIssue(iid: number, body: string) {
|
|
81
|
+
requireGitLabProject();
|
|
82
|
+
const spinner = ora(`Posting comment on issue #${iid}...`).start();
|
|
83
|
+
try {
|
|
84
|
+
await withGitLabRetry(() => createGitLabIssueNote(getNamespace(), getProject(), iid, body));
|
|
85
|
+
spinner.succeed(`Comment posted on issue #${iid}`);
|
|
86
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
87
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// commands/gitlab-mrs.ts — GitLab Merge Request management for GitPadi
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import Table from 'cli-table3';
|
|
6
|
+
import {
|
|
7
|
+
getNamespace, getProject, getFullProject, requireGitLabProject,
|
|
8
|
+
listGitLabMRs, getGitLabMR, createGitLabMR, mergeGitLabMR, updateGitLabMR,
|
|
9
|
+
listGitLabMRChanges, listGitLabMRNotes, createGitLabMRNote, updateGitLabMRNote,
|
|
10
|
+
getGitLabMRPipelineStatus, withGitLabRetry,
|
|
11
|
+
} from '../core/gitlab.js';
|
|
12
|
+
|
|
13
|
+
export async function listMRs(opts: { state?: string; limit?: number } = {}) {
|
|
14
|
+
requireGitLabProject();
|
|
15
|
+
const spinner = ora(`Fetching MRs from ${chalk.cyan(getFullProject())}...`).start();
|
|
16
|
+
try {
|
|
17
|
+
const state = (['opened', 'closed', 'merged', 'all'].includes(opts.state || '')
|
|
18
|
+
? opts.state : 'opened') as 'opened' | 'closed' | 'merged' | 'all';
|
|
19
|
+
|
|
20
|
+
const mrs = await withGitLabRetry(() =>
|
|
21
|
+
listGitLabMRs(getNamespace(), getProject(), { state, per_page: opts.limit || 50 })
|
|
22
|
+
);
|
|
23
|
+
spinner.stop();
|
|
24
|
+
|
|
25
|
+
if (!mrs.length) { console.log(chalk.yellow('\n No merge requests found.\n')); return; }
|
|
26
|
+
|
|
27
|
+
const table = new Table({
|
|
28
|
+
head: ['#', 'Title', 'Author', 'Branch', 'State'].map(h => chalk.cyan(h)),
|
|
29
|
+
style: { head: [], border: [] },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
mrs.forEach(mr => {
|
|
33
|
+
const stateLabel = mr.state === 'opened' ? chalk.green('open')
|
|
34
|
+
: mr.state === 'merged' ? chalk.magenta('merged') : chalk.red(mr.state);
|
|
35
|
+
table.push([
|
|
36
|
+
`!${mr.iid}`,
|
|
37
|
+
mr.title.substring(0, 50),
|
|
38
|
+
`@${mr.author.username}`,
|
|
39
|
+
mr.source_branch.substring(0, 25),
|
|
40
|
+
stateLabel,
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log(`\n${chalk.bold(`🔀 Merge Requests — ${getFullProject()}`)} (${mrs.length})\n`);
|
|
45
|
+
console.log(table.toString());
|
|
46
|
+
console.log('');
|
|
47
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function mergeMR(iid: number, opts: { squash?: boolean; message?: string; force?: boolean } = {}) {
|
|
51
|
+
requireGitLabProject();
|
|
52
|
+
|
|
53
|
+
if (opts.force) {
|
|
54
|
+
const spinner = ora(`Force merging MR !${iid}...`).start();
|
|
55
|
+
try {
|
|
56
|
+
const result = await withGitLabRetry(() =>
|
|
57
|
+
mergeGitLabMR(getNamespace(), getProject(), iid, { squash: opts.squash ?? true, message: opts.message })
|
|
58
|
+
);
|
|
59
|
+
spinner.succeed(`Force merged MR ${chalk.green(`!${iid}`)} ${chalk.yellow('(CI skipped)')}`);
|
|
60
|
+
console.log(chalk.dim(` SHA: ${result.sha}\n`));
|
|
61
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const spinner = ora(`Checking CI status for MR !${iid}...`).start();
|
|
66
|
+
try {
|
|
67
|
+
// Poll up to 15 min (90 × 10s)
|
|
68
|
+
let attempts = 0;
|
|
69
|
+
const maxAttempts = 90;
|
|
70
|
+
let ciStatus: string | null = null;
|
|
71
|
+
|
|
72
|
+
while (attempts < maxAttempts) {
|
|
73
|
+
const pipeline = await withGitLabRetry(() => getGitLabMRPipelineStatus(getNamespace(), getProject(), iid));
|
|
74
|
+
|
|
75
|
+
if (!pipeline) {
|
|
76
|
+
spinner.info(chalk.dim('No CI pipeline found — proceeding without check.'));
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
ciStatus = pipeline.status;
|
|
81
|
+
|
|
82
|
+
if (['success', 'failed', 'canceled', 'skipped'].includes(ciStatus)) break;
|
|
83
|
+
|
|
84
|
+
spinner.text = chalk.dim(`Pipeline ${ciStatus}... (${attempts * 10}s elapsed)`);
|
|
85
|
+
await new Promise(r => setTimeout(r, 10000));
|
|
86
|
+
attempts++;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
spinner.stop();
|
|
90
|
+
|
|
91
|
+
if (ciStatus === 'failed' || ciStatus === 'canceled') {
|
|
92
|
+
console.log(chalk.red(` ❌ Pipeline ${ciStatus} — merge blocked. Fix the pipeline and retry.\n`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (attempts >= maxAttempts) {
|
|
96
|
+
console.log(chalk.yellow(` ⚠️ Pipeline still running after 15 min. Try again later.\n`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (ciStatus === 'success') {
|
|
100
|
+
console.log(chalk.green(` ✅ Pipeline passed!\n`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const mergeSpinner = ora(`Merging MR !${iid}...`).start();
|
|
104
|
+
const result = await withGitLabRetry(() =>
|
|
105
|
+
mergeGitLabMR(getNamespace(), getProject(), iid, { squash: opts.squash ?? true, message: opts.message })
|
|
106
|
+
);
|
|
107
|
+
mergeSpinner.succeed(`Merged MR ${chalk.green(`!${iid}`)}`);
|
|
108
|
+
console.log(chalk.dim(` SHA: ${result.sha}\n`));
|
|
109
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function closeMR(iid: number) {
|
|
113
|
+
requireGitLabProject();
|
|
114
|
+
const spinner = ora(`Closing MR !${iid}...`).start();
|
|
115
|
+
try {
|
|
116
|
+
await withGitLabRetry(() => updateGitLabMR(getNamespace(), getProject(), iid, { state_event: 'close' }));
|
|
117
|
+
spinner.succeed(`Closed MR ${chalk.red(`!${iid}`)}`);
|
|
118
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function reviewMR(iid: number) {
|
|
122
|
+
requireGitLabProject();
|
|
123
|
+
const spinner = ora(`Reviewing MR !${iid}...`).start();
|
|
124
|
+
try {
|
|
125
|
+
const mr = await withGitLabRetry(() => getGitLabMR(getNamespace(), getProject(), iid));
|
|
126
|
+
const changes = await withGitLabRetry(() => listGitLabMRChanges(getNamespace(), getProject(), iid));
|
|
127
|
+
spinner.stop();
|
|
128
|
+
|
|
129
|
+
console.log(`\n${chalk.bold(`🔍 MR Review — !${iid}: ${mr.title}`)}`);
|
|
130
|
+
console.log(chalk.dim(` Author: @${mr.author.username} Branch: ${mr.source_branch} → ${mr.target_branch}\n`));
|
|
131
|
+
|
|
132
|
+
const checks: Array<{ name: string; icon: string; detail: string }> = [];
|
|
133
|
+
|
|
134
|
+
// Linked issues
|
|
135
|
+
const linked = mr.description?.match(/(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi) || [];
|
|
136
|
+
checks.push({ name: 'Linked Issues', icon: linked.length ? '✅' : '⚠️', detail: linked.length ? linked.join(', ') : 'None found — use "Fixes #N"' });
|
|
137
|
+
|
|
138
|
+
// Size
|
|
139
|
+
const totalLines = changes.reduce((sum, f) => {
|
|
140
|
+
const adds = (f.diff.match(/^\+/gm) || []).length;
|
|
141
|
+
const dels = (f.diff.match(/^-/gm) || []).length;
|
|
142
|
+
return sum + adds + dels;
|
|
143
|
+
}, 0);
|
|
144
|
+
checks.push({ name: 'MR Size', icon: totalLines > 1000 ? '❌' : totalLines > 500 ? '⚠️' : '✅', detail: `~${totalLines} lines changed (${changes.length} files)` });
|
|
145
|
+
|
|
146
|
+
// Tests
|
|
147
|
+
const srcFiles = changes.filter(f => !f.new_path.includes('test') && !f.new_path.includes('spec') && /\.(ts|rs|js|py)$/.test(f.new_path));
|
|
148
|
+
const testFiles = changes.filter(f => f.new_path.includes('test') || f.new_path.includes('spec'));
|
|
149
|
+
checks.push({ name: 'Tests', icon: srcFiles.length > 0 && testFiles.length === 0 ? '⚠️' : '✅', detail: `${testFiles.length} test file(s), ${srcFiles.length} source file(s)` });
|
|
150
|
+
|
|
151
|
+
// Sensitive files
|
|
152
|
+
const sensitive = changes.filter(f => /(\.env|secret|credential|password|\.key|\.pem)/i.test(f.new_path));
|
|
153
|
+
checks.push({ name: 'Security', icon: sensitive.length ? '❌' : '✅', detail: sensitive.length ? `Flagged: ${sensitive.map(f => f.new_path).join(', ')}` : 'Clean' });
|
|
154
|
+
|
|
155
|
+
// Draft
|
|
156
|
+
checks.push({ name: 'Draft Status', icon: mr.draft ? '⚠️' : '✅', detail: mr.draft ? 'MR is a draft — not ready to merge' : 'Ready for review' });
|
|
157
|
+
|
|
158
|
+
checks.forEach(c => console.log(` ${c.icon} ${chalk.bold(c.name)}: ${c.detail}`));
|
|
159
|
+
console.log('');
|
|
160
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function diffMR(iid: number) {
|
|
164
|
+
requireGitLabProject();
|
|
165
|
+
const spinner = ora(`Fetching diff for MR !${iid}...`).start();
|
|
166
|
+
try {
|
|
167
|
+
const changes = await withGitLabRetry(() => listGitLabMRChanges(getNamespace(), getProject(), iid));
|
|
168
|
+
spinner.stop();
|
|
169
|
+
|
|
170
|
+
console.log(`\n${chalk.bold(`📄 MR !${iid} — Changed Files`)} (${changes.length})\n`);
|
|
171
|
+
|
|
172
|
+
const table = new Table({
|
|
173
|
+
head: ['File', 'Status'].map(h => chalk.cyan(h)),
|
|
174
|
+
style: { head: [], border: [] },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
changes.forEach(f => {
|
|
178
|
+
const status = f.new_file ? chalk.green('added') : f.deleted_file ? chalk.red('removed') : chalk.yellow('modified');
|
|
179
|
+
table.push([f.new_path.substring(0, 70), status]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
console.log(table.toString());
|
|
183
|
+
console.log('');
|
|
184
|
+
} catch (e: any) { spinner.fail(e.message); }
|
|
185
|
+
}
|