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,231 @@
1
+ #!/usr/bin/env node
2
+ // gitlab-agents/mr-review-agent.ts
3
+ //
4
+ // GitLab Duo External Agent — AI-powered Merge Request Review
5
+ //
6
+ // Triggered when assigned as reviewer on a GitLab MR, or when someone
7
+ // comments "@gitpadi review" on an MR.
8
+ //
9
+ // Uses Claude to provide a detailed, constructive code review covering:
10
+ // - Code quality and maintainability
11
+ // - Security concerns
12
+ // - Test coverage
13
+ // - Alignment with issue description
14
+ // - Specific line-level suggestions
15
+
16
+ import Anthropic from '@anthropic-ai/sdk';
17
+
18
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
19
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
20
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
21
+
22
+ const anthropic = GATEWAY_TOKEN
23
+ ? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
24
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
25
+
26
+ const MR_REVIEW_MARKER = '<!-- gitpadi-mr-review -->';
27
+
28
+ async function glFetch<T>(method: string, path: string, body?: unknown): Promise<T> {
29
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
30
+ method,
31
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
32
+ body: body ? JSON.stringify(body) : undefined,
33
+ });
34
+ if (!res.ok) {
35
+ const text = await res.text();
36
+ throw new Error(`GitLab ${method} ${path} → ${res.status}: ${text}`);
37
+ }
38
+ return res.json() as Promise<T>;
39
+ }
40
+
41
+ async function postOrUpdateMRNote(projectId: number, mrIid: number, body: string): Promise<void> {
42
+ // Check for existing review note to update
43
+ const notes = await glFetch<Array<{ id: number; body: string; author: { username: string } }>>(
44
+ 'GET', `/projects/${projectId}/merge_requests/${mrIid}/notes?per_page=100`
45
+ );
46
+ const existing = notes.find(n => n.body?.includes(MR_REVIEW_MARKER));
47
+
48
+ if (existing) {
49
+ await glFetch('PUT', `/projects/${projectId}/merge_requests/${mrIid}/notes/${existing.id}`, { body });
50
+ console.log('✅ Updated existing review note');
51
+ } else {
52
+ await glFetch('POST', `/projects/${projectId}/merge_requests/${mrIid}/notes`, { body });
53
+ console.log('✅ Posted new review note');
54
+ }
55
+ }
56
+
57
+ async function main(): Promise<void> {
58
+ console.log('\n🤖 GitPadi MR Review Agent\n');
59
+
60
+ const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
61
+ let context: any;
62
+ try { context = JSON.parse(contextRaw); } catch { context = {}; }
63
+
64
+ const {
65
+ project_id,
66
+ project_path = '',
67
+ mr_iid,
68
+ title = '',
69
+ description = '',
70
+ source_branch = '',
71
+ target_branch = 'main',
72
+ author = '',
73
+ changes = [],
74
+ notes = [],
75
+ } = context;
76
+
77
+ if (!mr_iid) { console.log('⏭️ No MR context — skipping.'); return; }
78
+
79
+ console.log(` Project: ${project_path}`);
80
+ console.log(` MR: !${mr_iid} — ${title}`);
81
+ console.log(` Author: @${author}`);
82
+ console.log(` Files: ${changes.length} changed\n`);
83
+
84
+ // Build a diff summary for Claude (cap at ~4000 chars to stay within context)
85
+ const diffSummary = changes
86
+ .slice(0, 20)
87
+ .map((c: any) => {
88
+ const diffLines = (c.diff || '').split('\n').slice(0, 40).join('\n');
89
+ return `File: ${c.new_path}\n${diffLines}`;
90
+ })
91
+ .join('\n\n---\n\n')
92
+ .substring(0, 4000);
93
+
94
+ const sensitivePatterns = ['.env', 'secret', 'password', 'credential', '.key', '.pem'];
95
+ const sensitiveFiles = changes
96
+ .filter((c: any) => sensitivePatterns.some(p => c.new_path?.toLowerCase().includes(p)))
97
+ .map((c: any) => c.new_path);
98
+
99
+ const testFiles = changes.filter((c: any) =>
100
+ c.new_path?.includes('test') || c.new_path?.includes('spec')
101
+ );
102
+ const srcFiles = changes.filter((c: any) =>
103
+ !c.new_path?.includes('test') && !c.new_path?.includes('spec') &&
104
+ /\.(ts|js|py|rs|go|java|rb|php)$/.test(c.new_path || '')
105
+ );
106
+
107
+ const linkedIssues = description?.match(/(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi) || [];
108
+ const totalLines = changes.reduce((sum: number, c: any) => {
109
+ const adds = (c.diff?.match(/^\+/gm) || []).length;
110
+ const dels = (c.diff?.match(/^-/gm) || []).length;
111
+ return sum + adds + dels;
112
+ }, 0);
113
+
114
+ // Get Claude's analysis
115
+ const prompt = `You are GitPadi, an expert AI code reviewer integrated with GitLab.
116
+
117
+ Review this Merge Request:
118
+ - Title: "${title}"
119
+ - Author: @${author}
120
+ - Branch: ${source_branch} → ${target_branch}
121
+ - Description: ${description?.substring(0, 500) || 'None provided'}
122
+ - Files changed: ${changes.length} (${totalLines} lines)
123
+ - Test files: ${testFiles.length}
124
+ - Source files: ${srcFiles.length}
125
+ - Linked issues: ${linkedIssues.join(', ') || 'None'}
126
+ ${sensitiveFiles.length ? `- ⚠️ SENSITIVE FILES DETECTED: ${sensitiveFiles.join(', ')}` : ''}
127
+
128
+ Code changes (truncated):
129
+ \`\`\`diff
130
+ ${diffSummary || 'No diff available'}
131
+ \`\`\`
132
+
133
+ Provide a thorough, constructive code review. Be specific, actionable, and encouraging.
134
+ Format your response as JSON:
135
+ {
136
+ "verdict": "APPROVE|REQUEST_CHANGES|COMMENT",
137
+ "summary": "2-3 sentence overall assessment",
138
+ "strengths": ["specific strength 1", "specific strength 2"],
139
+ "concerns": [
140
+ { "severity": "critical|warning|suggestion", "file": "filename or null", "issue": "description", "suggestion": "how to fix" }
141
+ ],
142
+ "security": { "clean": true/false, "issues": ["issue1"] },
143
+ "testCoverage": { "adequate": true/false, "comment": "observation" },
144
+ "overallScore": 0-100
145
+ }`;
146
+
147
+ let review: any;
148
+ try {
149
+ const response = await anthropic.messages.create({
150
+ model: 'claude-sonnet-4-20250514',
151
+ max_tokens: 2048,
152
+ messages: [{ role: 'user', content: prompt }],
153
+ });
154
+
155
+ const text = response.content[0].type === 'text' ? response.content[0].text : '';
156
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
157
+ if (!jsonMatch) throw new Error('No JSON in Claude response');
158
+ review = JSON.parse(jsonMatch[0]);
159
+ } catch (e: any) {
160
+ console.error(`❌ Claude review failed: ${e.message}`);
161
+ process.exit(1);
162
+ }
163
+
164
+ // Build the GitLab comment
165
+ const verdictIcon = review.verdict === 'APPROVE' ? '✅' : review.verdict === 'REQUEST_CHANGES' ? '❌' : '💬';
166
+ const scoreEmoji = review.overallScore >= 80 ? '🟢' : review.overallScore >= 60 ? '🟡' : '🔴';
167
+
168
+ let comment = `## 🤖 GitPadi AI Code Review\n\n`;
169
+ comment += `**MR:** !${mr_iid} — ${title}\n`;
170
+ comment += `**Verdict:** ${verdictIcon} ${review.verdict?.replace('_', ' ')} | **Score:** ${scoreEmoji} ${review.overallScore}/100\n\n`;
171
+
172
+ comment += `### Summary\n\n${review.summary}\n\n`;
173
+
174
+ if (review.strengths?.length) {
175
+ comment += `### Strengths\n\n`;
176
+ review.strengths.forEach((s: string) => { comment += `- ✅ ${s}\n`; });
177
+ comment += '\n';
178
+ }
179
+
180
+ if (review.concerns?.length) {
181
+ comment += `### Concerns\n\n`;
182
+ const critical = review.concerns.filter((c: any) => c.severity === 'critical');
183
+ const warnings = review.concerns.filter((c: any) => c.severity === 'warning');
184
+ const suggestions = review.concerns.filter((c: any) => c.severity === 'suggestion');
185
+
186
+ const renderConcerns = (items: any[], icon: string) =>
187
+ items.forEach((c: any) => {
188
+ comment += `${icon} **${c.file ? `\`${c.file}\`` : 'General'}:** ${c.issue}\n`;
189
+ if (c.suggestion) comment += ` > 💡 ${c.suggestion}\n`;
190
+ comment += '\n';
191
+ });
192
+
193
+ if (critical.length) { comment += `**Critical:**\n`; renderConcerns(critical, '🔴'); }
194
+ if (warnings.length) { comment += `**Warnings:**\n`; renderConcerns(warnings, '⚠️'); }
195
+ if (suggestions.length) { comment += `**Suggestions:**\n`; renderConcerns(suggestions, '💡'); }
196
+ }
197
+
198
+ if (sensitiveFiles.length) {
199
+ comment += `### ⚠️ Security Alert\n\n`;
200
+ comment += `The following files may contain sensitive data:\n`;
201
+ sensitiveFiles.forEach((f: string) => { comment += `- \`${f}\`\n`; });
202
+ comment += `\nPlease ensure no secrets are committed.\n\n`;
203
+ }
204
+
205
+ if (review.testCoverage) {
206
+ comment += `### Test Coverage\n\n`;
207
+ comment += `${review.testCoverage.adequate ? '✅' : '⚠️'} ${review.testCoverage.comment}\n\n`;
208
+ }
209
+
210
+ comment += `### Quick Stats\n\n`;
211
+ comment += `| Metric | Value |\n|--------|-------|\n`;
212
+ comment += `| Files changed | ${changes.length} |\n`;
213
+ comment += `| Lines changed | ~${totalLines} |\n`;
214
+ comment += `| Test files | ${testFiles.length} |\n`;
215
+ comment += `| Linked issues | ${linkedIssues.length} |\n`;
216
+
217
+ comment += `\n---\n_🤖 Reviewed by [GitPadi](https://github.com/Netwalls/contributor-agent) using Claude ${review.verdict === 'APPROVE' ? '✅' : ''}_\n\n${MR_REVIEW_MARKER}`;
218
+
219
+ await postOrUpdateMRNote(project_id, mr_iid, comment);
220
+
221
+ // Exit with error code if CI-blocking issues found
222
+ if (review.verdict === 'REQUEST_CHANGES' && review.concerns?.some((c: any) => c.severity === 'critical')) {
223
+ console.log('❌ Critical issues found — blocking merge.');
224
+ process.exit(1);
225
+ }
226
+
227
+ console.log(`\n Verdict: ${review.verdict}`);
228
+ console.log(` Score: ${review.overallScore}/100\n`);
229
+ }
230
+
231
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ // gitlab-agents/reminder-agent.ts
3
+ //
4
+ // GitLab Duo External Agent — Escalating Contributor Reminders
5
+ //
6
+ // Runs on a schedule (e.g. daily cron). Finds stale open MRs, determines
7
+ // escalation tier, and posts a progressively urgent reminder. Auto-unassigns
8
+ // at 72h if no activity.
9
+ //
10
+ // Escalation:
11
+ // 24h — gentle reminder
12
+ // 48h — warning with context
13
+ // 72h — final notice + auto-unassigns from issue
14
+
15
+ import Anthropic from '@anthropic-ai/sdk';
16
+
17
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
18
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
19
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
20
+ const PROJECT_PATH = process.env.GITLAB_PROJECT_PATH || '';
21
+
22
+ const anthropic = GATEWAY_TOKEN
23
+ ? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
24
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
25
+
26
+ const REMINDER_MARKERS = {
27
+ h24: '<!-- gitpadi-reminder-24h -->',
28
+ h48: '<!-- gitpadi-reminder-48h -->',
29
+ h72: '<!-- gitpadi-reminder-72h -->',
30
+ };
31
+
32
+ async function glFetch<T>(method: string, path: string, body?: unknown): Promise<T> {
33
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
34
+ method,
35
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
36
+ body: body ? JSON.stringify(body) : undefined,
37
+ });
38
+ if (!res.ok) throw new Error(`GitLab ${method} ${path} → ${res.status}`);
39
+ return res.json() as Promise<T>;
40
+ }
41
+
42
+ function hoursAgo(dateStr: string): number {
43
+ return (Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60);
44
+ }
45
+
46
+ async function generateReminderMessage(tier: '24h' | '48h' | '72h', context: {
47
+ mrTitle: string;
48
+ author: string;
49
+ hoursStale: number;
50
+ branchName: string;
51
+ linkedIssue?: string;
52
+ }): Promise<string> {
53
+ const tierDescriptions = {
54
+ '24h': 'gentle first reminder — friendly and encouraging',
55
+ '48h': 'second reminder — more urgent but still supportive, mention help is available',
56
+ '72h': 'final notice — professional but firm, this will be unassigned',
57
+ };
58
+
59
+ const prompt = `You are GitPadi, a friendly contributor management bot on GitLab.
60
+
61
+ Write a ${tierDescriptions[tier]} for a stale Merge Request:
62
+ - MR Title: "${context.mrTitle}"
63
+ - Author: @${context.author}
64
+ - Hours since last activity: ${Math.round(context.hoursStale)}
65
+ - Branch: ${context.branchName}
66
+ ${context.linkedIssue ? `- Linked issue: ${context.linkedIssue}` : ''}
67
+
68
+ Requirements:
69
+ - Keep it under 100 words
70
+ - Be human, warm, and specific to the situation
71
+ - ${tier === '72h' ? 'Mention this is the final notice and the issue will be unassigned' : 'Do NOT mention unassignment'}
72
+ - Start with a relevant emoji
73
+ - Do NOT use generic corporate language
74
+
75
+ Return just the message text, no JSON, no explanation.`;
76
+
77
+ try {
78
+ const response = await anthropic.messages.create({
79
+ model: 'claude-haiku-4-5-20251001',
80
+ max_tokens: 256,
81
+ messages: [{ role: 'user', content: prompt }],
82
+ });
83
+ return response.content[0].type === 'text' ? response.content[0].text.trim() : getFallbackMessage(tier, context.author);
84
+ } catch {
85
+ return getFallbackMessage(tier, context.author);
86
+ }
87
+ }
88
+
89
+ function getFallbackMessage(tier: '24h' | '48h' | '72h', author: string): string {
90
+ const messages = {
91
+ '24h': `👋 @${author} — just a friendly nudge! Your MR has been open for about 24 hours. Any updates or blockers we can help with?`,
92
+ '48h': `⚠️ @${author} — your MR has been idle for 48 hours. If you're blocked or need help, please comment so the team can assist. Otherwise, please push an update!`,
93
+ '72h': `🚨 @${author} — final notice. This MR has been inactive for 72 hours. We'll need to unassign the linked issue to allow other contributors to pick it up. Please comment if you're still working on this.`,
94
+ };
95
+ return messages[tier];
96
+ }
97
+
98
+ interface MR {
99
+ iid: number;
100
+ title: string;
101
+ author: { username: string };
102
+ source_branch: string;
103
+ updated_at: string;
104
+ draft: boolean;
105
+ description: string | null;
106
+ project_id: number;
107
+ assignees?: Array<{ id: number; username: string }>;
108
+ }
109
+
110
+ async function processMR(mr: MR, projectId: number): Promise<void> {
111
+ const hoursStale = hoursAgo(mr.updated_at);
112
+
113
+ // Skip drafts — they're not ready
114
+ if (mr.draft) {
115
+ console.log(` ⏭️ !${mr.iid} — Draft, skipping`);
116
+ return;
117
+ }
118
+
119
+ // Determine escalation tier
120
+ let tier: '24h' | '48h' | '72h' | null = null;
121
+ if (hoursStale >= 72) tier = '72h';
122
+ else if (hoursStale >= 48) tier = '48h';
123
+ else if (hoursStale >= 24) tier = '24h';
124
+ else return; // Not stale enough
125
+
126
+ // Check for existing reminders
127
+ const notes = await glFetch<Array<{ body: string }>>(
128
+ 'GET', `/projects/${projectId}/merge_requests/${mr.iid}/notes?per_page=100`
129
+ );
130
+
131
+ const hasMarker = (marker: string) => notes.some(n => n.body?.includes(marker));
132
+
133
+ // Don't double-post the same tier
134
+ if (tier === '24h' && hasMarker(REMINDER_MARKERS.h24)) return;
135
+ if (tier === '48h' && hasMarker(REMINDER_MARKERS.h48)) return;
136
+ if (tier === '72h' && hasMarker(REMINDER_MARKERS.h72)) return;
137
+
138
+ // Detect linked issue from description
139
+ const issueMatch = mr.description?.match(/(?:fixes|closes|resolves)\s+#(\d+)/i);
140
+ const linkedIssue = issueMatch ? `#${issueMatch[1]}` : undefined;
141
+
142
+ console.log(` 📬 MR !${mr.iid} — Tier ${tier} (${Math.round(hoursStale)}h stale)`);
143
+
144
+ const message = await generateReminderMessage(tier, {
145
+ mrTitle: mr.title,
146
+ author: mr.author.username,
147
+ hoursStale,
148
+ branchName: mr.source_branch,
149
+ linkedIssue,
150
+ });
151
+
152
+ // Post reminder
153
+ const body = `${message}\n\n---\n_⏰ GitPadi Reminder — ${tier} | [Dismiss by pushing a commit](https://github.com/Netwalls/contributor-agent)_\n\n${REMINDER_MARKERS[`h${tier.replace('h', '')}` as keyof typeof REMINDER_MARKERS]}`;
154
+ await glFetch('POST', `/projects/${projectId}/merge_requests/${mr.iid}/notes`, { body });
155
+
156
+ // Auto-unassign at 72h
157
+ if (tier === '72h' && linkedIssue) {
158
+ const issueNum = parseInt(issueMatch![1]);
159
+ try {
160
+ await glFetch('PUT', `/projects/${projectId}/issues/${issueNum}`, { assignee_ids: [] });
161
+ console.log(` ✓ Unassigned issue ${linkedIssue}`);
162
+
163
+ // Also post on the issue
164
+ await glFetch('POST', `/projects/${projectId}/issues/${issueNum}/notes`, {
165
+ body: `⏰ **GitPadi:** @${mr.author.username}'s MR !${mr.iid} has been inactive for 72 hours. The issue has been unassigned and is now open for other contributors.\n\n<!-- gitpadi-unassign -->`,
166
+ });
167
+ } catch (e: any) {
168
+ console.log(` ⚠️ Could not unassign issue: ${e.message}`);
169
+ }
170
+ }
171
+ }
172
+
173
+ async function main(): Promise<void> {
174
+ console.log('\n🤖 GitPadi Reminder Agent\n');
175
+
176
+ if (!PROJECT_PATH) {
177
+ console.error('❌ GITLAB_PROJECT_PATH required (e.g. "mygroup/myproject")');
178
+ process.exit(1);
179
+ }
180
+
181
+ const encodedPath = encodeURIComponent(PROJECT_PATH);
182
+ const project = await glFetch<{ id: number; path_with_namespace: string }>(`GET`, `/projects/${encodedPath}`);
183
+
184
+ console.log(` Project: ${project.path_with_namespace}\n`);
185
+
186
+ // Get all open MRs
187
+ const mrs = await glFetch<MR[]>('GET', `/projects/${project.id}/merge_requests?state=opened&per_page=100`);
188
+ console.log(` Found ${mrs.length} open MR(s)\n`);
189
+
190
+ let reminded = 0;
191
+ for (const mr of mrs) {
192
+ try {
193
+ await processMR(mr, project.id);
194
+ reminded++;
195
+ } catch (e: any) {
196
+ console.log(` ⚠️ Error processing MR !${mr.iid}: ${e.message}`);
197
+ }
198
+ }
199
+
200
+ console.log(`\n✅ Reminder cycle complete — ${reminded} MR(s) processed`);
201
+ }
202
+
203
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });