gitpadi 2.0.7 → 2.1.1

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.
Files changed (45) hide show
  1. package/.gitlab/duo/chat-rules.md +40 -0
  2. package/.gitlab/duo/mr-review-instructions.md +44 -0
  3. package/.gitlab-ci.yml +136 -0
  4. package/README.md +585 -57
  5. package/action.yml +21 -2
  6. package/dist/applicant-scorer.js +27 -105
  7. package/dist/cli.js +1040 -34
  8. package/dist/commands/apply-for-issue.js +396 -0
  9. package/dist/commands/bounty-hunter.js +441 -0
  10. package/dist/commands/contribute.js +245 -51
  11. package/dist/commands/gitlab-issues.js +87 -0
  12. package/dist/commands/gitlab-mrs.js +163 -0
  13. package/dist/commands/gitlab-pipelines.js +95 -0
  14. package/dist/commands/prs.js +3 -3
  15. package/dist/core/github.js +24 -0
  16. package/dist/core/gitlab.js +233 -0
  17. package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
  18. package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
  19. package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
  20. package/dist/gitlab-agents/mr-review-agent.js +200 -0
  21. package/dist/gitlab-agents/reminder-agent.js +164 -0
  22. package/dist/grade-assignment.js +262 -0
  23. package/dist/remind-contributors.js +127 -0
  24. package/dist/review-and-merge.js +125 -0
  25. package/examples/gitpadi.yml +152 -0
  26. package/package.json +20 -4
  27. package/src/applicant-scorer.ts +33 -141
  28. package/src/cli.ts +1073 -33
  29. package/src/commands/apply-for-issue.ts +452 -0
  30. package/src/commands/bounty-hunter.ts +529 -0
  31. package/src/commands/contribute.ts +264 -50
  32. package/src/commands/gitlab-issues.ts +87 -0
  33. package/src/commands/gitlab-mrs.ts +185 -0
  34. package/src/commands/gitlab-pipelines.ts +104 -0
  35. package/src/commands/prs.ts +3 -3
  36. package/src/core/github.ts +24 -0
  37. package/src/core/gitlab.ts +397 -0
  38. package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
  39. package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
  40. package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
  41. package/src/gitlab-agents/mr-review-agent.ts +231 -0
  42. package/src/gitlab-agents/reminder-agent.ts +203 -0
  43. package/src/grade-assignment.ts +283 -0
  44. package/src/remind-contributors.ts +159 -0
  45. package/src/review-and-merge.ts +143 -0
@@ -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
+ }
@@ -0,0 +1,104 @@
1
+ // commands/gitlab-pipelines.ts — GitLab CI/CD pipeline 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
+ listGitLabPipelines, getGitLabPipeline, listGitLabPipelineJobs, getGitLabJobLog,
9
+ withGitLabRetry,
10
+ } from '../core/gitlab.js';
11
+
12
+ function pipelineStatusColor(status: string): string {
13
+ switch (status) {
14
+ case 'success': return chalk.green(status);
15
+ case 'failed': return chalk.red(status);
16
+ case 'running': return chalk.yellow(status);
17
+ case 'canceled': return chalk.dim(status);
18
+ case 'pending': return chalk.blue(status);
19
+ default: return chalk.dim(status);
20
+ }
21
+ }
22
+
23
+ export async function listPipelines(opts: { ref?: string; limit?: number } = {}) {
24
+ requireGitLabProject();
25
+ const spinner = ora(`Fetching pipelines from ${chalk.cyan(getFullProject())}...`).start();
26
+ try {
27
+ const pipelines = await withGitLabRetry(() =>
28
+ listGitLabPipelines(getNamespace(), getProject(), { per_page: opts.limit || 20, ref: opts.ref })
29
+ );
30
+ spinner.stop();
31
+
32
+ if (!pipelines.length) { console.log(chalk.yellow('\n No pipelines found.\n')); return; }
33
+
34
+ const table = new Table({
35
+ head: ['ID', 'Status', 'Ref', 'SHA', 'Created'].map(h => chalk.cyan(h)),
36
+ style: { head: [], border: [] },
37
+ });
38
+
39
+ pipelines.forEach(p => {
40
+ table.push([
41
+ `#${p.id}`,
42
+ pipelineStatusColor(p.status),
43
+ p.ref.substring(0, 25),
44
+ p.sha.substring(0, 8),
45
+ new Date(p.created_at).toLocaleDateString(),
46
+ ]);
47
+ });
48
+
49
+ console.log(`\n${chalk.bold(`🔧 Pipelines — ${getFullProject()}`)} (${pipelines.length})\n`);
50
+ console.log(table.toString());
51
+ console.log('');
52
+ } catch (e: any) { spinner.fail(e.message); }
53
+ }
54
+
55
+ export async function viewPipelineJobs(pipelineId: number) {
56
+ requireGitLabProject();
57
+ const spinner = ora(`Fetching jobs for pipeline #${pipelineId}...`).start();
58
+ try {
59
+ const [pipeline, jobs] = await Promise.all([
60
+ withGitLabRetry(() => getGitLabPipeline(getNamespace(), getProject(), pipelineId)),
61
+ withGitLabRetry(() => listGitLabPipelineJobs(getNamespace(), getProject(), pipelineId)),
62
+ ]);
63
+ spinner.stop();
64
+
65
+ console.log(`\n${chalk.bold(`🔧 Pipeline #${pipelineId}`)} — ${pipelineStatusColor(pipeline.status)} — ${pipeline.ref}\n`);
66
+
67
+ const table = new Table({
68
+ head: ['Job', 'Stage', 'Status', 'Duration'].map(h => chalk.cyan(h)),
69
+ style: { head: [], border: [] },
70
+ });
71
+
72
+ jobs.forEach(job => {
73
+ const duration = job.duration ? `${Math.round(job.duration)}s` : '-';
74
+ table.push([job.name, job.stage, pipelineStatusColor(job.status), duration]);
75
+ });
76
+
77
+ console.log(table.toString());
78
+ console.log('');
79
+
80
+ const failed = jobs.filter(j => j.status === 'failed');
81
+ if (failed.length) {
82
+ console.log(chalk.red(` ❌ ${failed.length} failed job(s): ${failed.map(j => j.name).join(', ')}\n`));
83
+ }
84
+ } catch (e: any) { spinner.fail(e.message); }
85
+ }
86
+
87
+ export async function viewJobLog(jobId: number) {
88
+ requireGitLabProject();
89
+ const spinner = ora(`Fetching log for job #${jobId}...`).start();
90
+ try {
91
+ const log = await withGitLabRetry(() => getGitLabJobLog(getNamespace(), getProject(), jobId));
92
+ spinner.stop();
93
+
94
+ console.log(`\n${chalk.bold(`📋 Job #${jobId} Log`)}\n`);
95
+ // Show last 100 lines to avoid overwhelming the terminal
96
+ const lines = log.split('\n');
97
+ const tail = lines.slice(-100).join('\n');
98
+ if (lines.length > 100) {
99
+ console.log(chalk.dim(` ... (showing last 100 of ${lines.length} lines)\n`));
100
+ }
101
+ console.log(chalk.dim(tail));
102
+ console.log('');
103
+ } catch (e: any) { spinner.fail(e.message); }
104
+ }
@@ -60,9 +60,9 @@ export async function mergePR(number: number, opts: { method?: string; message?:
60
60
  const { data: pr } = await octokit.pulls.get({ owner: getOwner(), repo: getRepo(), pull_number: number });
61
61
  const sha = pr.head.sha;
62
62
 
63
- // Step 2: Poll until all checks complete (max 5 min)
63
+ // Step 2: Poll until all checks complete (max 15 min)
64
64
  let attempts = 0;
65
- const maxAttempts = 30;
65
+ const maxAttempts = 90;
66
66
  let allComplete = false;
67
67
  let ciChecks: Array<{ name: string; status: string; conclusion: string | null }> = [];
68
68
 
@@ -122,7 +122,7 @@ export async function mergePR(number: number, opts: { method?: string; message?:
122
122
  console.log('');
123
123
 
124
124
  if (!allComplete) {
125
- console.log(chalk.yellow(` ⚠️ Checks still running after 5 min. Try again later.\n`));
125
+ console.log(chalk.yellow(` ⚠️ Checks still running after 15 min. Try again later.\n`));
126
126
  return;
127
127
  }
128
128
  if (!allPassed) {
@@ -119,6 +119,30 @@ export async function getRepoPermissions(owner: string, repo: string) {
119
119
  return data.permissions || { admin: false, push: false, pull: true };
120
120
  }
121
121
 
122
+ /**
123
+ * Retries an async operation on GitHub rate-limit (429) or secondary rate-limit (403)
124
+ * with exponential backoff. maxRetries defaults to 4 (covers ~30s total wait).
125
+ */
126
+ export async function withRetry<T>(fn: () => Promise<T>, maxRetries = 4): Promise<T> {
127
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
128
+ try {
129
+ return await fn();
130
+ } catch (e: any) {
131
+ const isRateLimit = e.status === 429 || (e.status === 403 && /rate limit/i.test(e.message || ''));
132
+ if (isRateLimit && attempt < maxRetries) {
133
+ // Honour Retry-After header when present, else exponential backoff
134
+ const retryAfter = parseInt(e.response?.headers?.['retry-after'] || '0', 10);
135
+ const delay = retryAfter > 0 ? retryAfter * 1000 : Math.pow(2, attempt) * 1000 + Math.random() * 500;
136
+ await new Promise(r => setTimeout(r, delay));
137
+ continue;
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+ // Unreachable — satisfies TS return type
143
+ throw new Error('withRetry: max retries exceeded');
144
+ }
145
+
122
146
  export async function getLatestCheckRuns(owner: string, repo: string, ref: string) {
123
147
  const octokit = getOctokit();
124
148
  const { data: checks } = await octokit.checks.listForRef({ owner, repo, ref });