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.
Files changed (47) 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 +1045 -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/drips.js +351 -0
  12. package/dist/commands/gitlab-issues.js +87 -0
  13. package/dist/commands/gitlab-mrs.js +163 -0
  14. package/dist/commands/gitlab-pipelines.js +95 -0
  15. package/dist/commands/prs.js +3 -3
  16. package/dist/core/github.js +24 -0
  17. package/dist/core/gitlab.js +233 -0
  18. package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
  19. package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
  20. package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
  21. package/dist/gitlab-agents/mr-review-agent.js +200 -0
  22. package/dist/gitlab-agents/reminder-agent.js +164 -0
  23. package/dist/grade-assignment.js +262 -0
  24. package/dist/remind-contributors.js +127 -0
  25. package/dist/review-and-merge.js +125 -0
  26. package/examples/gitpadi.yml +152 -0
  27. package/package.json +20 -4
  28. package/src/applicant-scorer.ts +33 -141
  29. package/src/cli.ts +1078 -34
  30. package/src/commands/apply-for-issue.ts +452 -0
  31. package/src/commands/bounty-hunter.ts +529 -0
  32. package/src/commands/contribute.ts +264 -50
  33. package/src/commands/drips.ts +408 -0
  34. package/src/commands/gitlab-issues.ts +87 -0
  35. package/src/commands/gitlab-mrs.ts +185 -0
  36. package/src/commands/gitlab-pipelines.ts +104 -0
  37. package/src/commands/prs.ts +3 -3
  38. package/src/core/github.ts +24 -0
  39. package/src/core/gitlab.ts +397 -0
  40. package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
  41. package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
  42. package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
  43. package/src/gitlab-agents/mr-review-agent.ts +231 -0
  44. package/src/gitlab-agents/reminder-agent.ts +203 -0
  45. package/src/grade-assignment.ts +283 -0
  46. package/src/remind-contributors.ts +159 -0
  47. package/src/review-and-merge.ts +143 -0
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // gitlab-agents/contributor-scoring-agent.ts
3
+ //
4
+ // GitLab Duo External Agent — Contributor Scoring
5
+ //
6
+ // Triggered when someone comments "@gitpadi score" on a GitLab issue.
7
+ // Reads the issue thread, identifies applicants, scores them using Claude,
8
+ // and posts a ranked breakdown as a GitLab issue note.
9
+ //
10
+ // Environment variables injected by GitLab Duo Agent Platform:
11
+ // AI_FLOW_INPUT — the triggering comment text
12
+ // AI_FLOW_CONTEXT — JSON with issue details, comments, project info
13
+ // AI_FLOW_AI_GATEWAY_TOKEN — GitLab-managed Anthropic auth token
14
+ // AI_FLOW_AI_GATEWAY_HEADERS — formatted auth headers
15
+ //
16
+ // Additional env vars (set in CI/CD variables):
17
+ // GITLAB_TOKEN — for posting back to GitLab
18
+ // GITLAB_HOST — default: https://gitlab.com
19
+ import Anthropic from '@anthropic-ai/sdk';
20
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
21
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
22
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
23
+ // Support both GitLab AI gateway (preferred for Anthropic prize) and direct API key
24
+ const anthropic = GATEWAY_TOKEN
25
+ ? new Anthropic({
26
+ apiKey: GATEWAY_TOKEN,
27
+ baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1`,
28
+ })
29
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
30
+ async function glFetch(method, path, body) {
31
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
32
+ method,
33
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ });
36
+ if (!res.ok)
37
+ throw new Error(`GitLab ${method} ${path} → ${res.status}`);
38
+ return res.json();
39
+ }
40
+ async function postNote(projectId, issueIid, body) {
41
+ await glFetch('POST', `/projects/${projectId}/issues/${issueIid}/notes`, { body });
42
+ }
43
+ async function main() {
44
+ console.log('\n🤖 GitPadi Contributor Scoring Agent\n');
45
+ const input = process.env.AI_FLOW_INPUT || '';
46
+ const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
47
+ let context;
48
+ try {
49
+ context = JSON.parse(contextRaw);
50
+ }
51
+ catch {
52
+ console.error('❌ Could not parse AI_FLOW_CONTEXT');
53
+ process.exit(1);
54
+ }
55
+ const { project_id, project_path, issue_iid, notes = [], title = '', description = '', labels = [] } = context;
56
+ if (!issue_iid) {
57
+ console.log('⏭️ No issue context — skipping.');
58
+ return;
59
+ }
60
+ console.log(` Project: ${project_path}`);
61
+ console.log(` Issue: #${issue_iid} — ${title}`);
62
+ console.log(` Notes: ${notes.length} comments\n`);
63
+ // Filter to applicant comments (exclude bots and the trigger command)
64
+ const applicantNotes = notes.filter(n => n.author !== 'gitpadi-bot' &&
65
+ !n.body.includes('@gitpadi') &&
66
+ n.body.trim().length > 5);
67
+ if (applicantNotes.length === 0) {
68
+ await postNote(project_id, issue_iid, [
69
+ '## 🤖 GitPadi — No Applicants Found',
70
+ '',
71
+ 'No applicant comments detected on this issue yet.',
72
+ 'Applicants should comment expressing interest to be scored.',
73
+ '',
74
+ '---',
75
+ '_GitPadi Contributor Scoring Agent 🤖_',
76
+ ].join('\n'));
77
+ return;
78
+ }
79
+ console.log(` Found ${applicantNotes.length} potential applicant(s). Calling Claude...\n`);
80
+ // Build prompt for Claude
81
+ const prompt = `You are GitPadi, an AI agent that scores and ranks contributor applicants for open source issues.
82
+
83
+ Issue: "${title}"
84
+ ${description ? `Description: ${description.substring(0, 500)}` : ''}
85
+ Labels: ${labels.join(', ') || 'none'}
86
+
87
+ Applicant comments:
88
+ ${applicantNotes.map((n, i) => `[${i + 1}] @${n.author}: "${n.body.substring(0, 300)}"`).join('\n')}
89
+
90
+ Score each applicant on a scale of 0-100 across these criteria:
91
+ - Application Quality (0-30): How well they explain their approach, experience, and plan
92
+ - Enthusiasm & Clarity (0-20): Clear intent and genuine interest
93
+ - Technical Relevance (0-30): Evidence of relevant skills based on their comment
94
+ - Community Fit (0-20): Professionalism and collaborative tone
95
+
96
+ Return a JSON object with this exact structure:
97
+ {
98
+ "applicants": [
99
+ {
100
+ "username": "@username",
101
+ "scores": { "applicationQuality": N, "enthusiasm": N, "technicalRelevance": N, "communityFit": N },
102
+ "total": N,
103
+ "tier": "S|A|B|C|D",
104
+ "reasoning": "1-2 sentence explanation",
105
+ "recommendation": "string"
106
+ }
107
+ ],
108
+ "topPick": "@username",
109
+ "summary": "1-2 sentence overall summary"
110
+ }
111
+
112
+ Be fair, constructive, and encouraging. Even lower-scoring applicants should receive positive, actionable feedback.`;
113
+ let scoringResult;
114
+ try {
115
+ const response = await anthropic.messages.create({
116
+ model: 'claude-haiku-4-5-20251001',
117
+ max_tokens: 1024,
118
+ messages: [{ role: 'user', content: prompt }],
119
+ });
120
+ const text = response.content[0].type === 'text' ? response.content[0].text : '';
121
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
122
+ if (!jsonMatch)
123
+ throw new Error('No JSON in response');
124
+ scoringResult = JSON.parse(jsonMatch[0]);
125
+ }
126
+ catch (e) {
127
+ console.error(`❌ Claude scoring failed: ${e.message}`);
128
+ // Fallback: post a simple note that scoring failed
129
+ await postNote(project_id, issue_iid, `## 🤖 GitPadi Scoring — Temporary Error\n\nCould not complete AI scoring at this time. Please try again by commenting \`@gitpadi score\`.\n\n---\n_GitPadi 🤖_`);
130
+ process.exit(1);
131
+ }
132
+ // Build the comment
133
+ const tierEmoji = { S: '🏆', A: '🟢', B: '🟡', C: '🟠', D: '🔴' };
134
+ const sorted = [...(scoringResult.applicants || [])].sort((a, b) => b.total - a.total);
135
+ let comment = `## 🤖 GitPadi — AI Contributor Scoring\n\n`;
136
+ comment += `**Issue:** ${title} (#${issue_iid})\n`;
137
+ comment += `**Scored:** ${sorted.length} applicant(s) | **Top Pick:** ${scoringResult.topPick || sorted[0]?.username}\n\n`;
138
+ comment += `### Rankings\n\n`;
139
+ comment += `| Rank | Applicant | Tier | Score | Application | Technical | Enthusiasm |\n`;
140
+ comment += `|------|-----------|------|-------|-------------|-----------|------------|\n`;
141
+ sorted.forEach((a, i) => {
142
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
143
+ comment += `| ${medal} | ${a.username} | ${tierEmoji[a.tier] || ''} ${a.tier} | **${a.total}**/100 | ${a.scores?.applicationQuality}/30 | ${a.scores?.technicalRelevance}/30 | ${a.scores?.enthusiasm}/20 |\n`;
144
+ });
145
+ comment += `\n### Feedback\n\n`;
146
+ sorted.forEach((a) => {
147
+ comment += `**${a.username}** (${a.total}/100): ${a.reasoning}\n\n`;
148
+ if (a.recommendation)
149
+ comment += `> 💡 ${a.recommendation}\n\n`;
150
+ });
151
+ if (scoringResult.summary) {
152
+ comment += `### Summary\n\n${scoringResult.summary}\n\n`;
153
+ }
154
+ comment += `---\n_🤖 Scored by [GitPadi](https://github.com/Netwalls/contributor-agent) using Claude via GitLab Duo Agent Platform_\n\n<!-- gitpadi-scoring -->`;
155
+ await postNote(project_id, issue_iid, comment);
156
+ console.log(`✅ Scoring posted for ${sorted.length} applicant(s)`);
157
+ console.log(` Top pick: ${scoringResult.topPick}`);
158
+ }
159
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // gitlab-agents/grade-assignment-agent.ts
3
+ //
4
+ // GitLab Duo External Agent — AI-powered Assignment Grading
5
+ //
6
+ // Triggered when an MR is opened or updated in a GitLab project that uses
7
+ // GitPadi for educational workflows.
8
+ //
9
+ // Grades the submission on 5 criteria using Claude, posts a grade card,
10
+ // and auto-merges if the score meets the pass threshold.
11
+ import Anthropic from '@anthropic-ai/sdk';
12
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
13
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
14
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
15
+ const PASS_THRESHOLD = parseInt(process.env.PASS_THRESHOLD || '40');
16
+ const anthropic = GATEWAY_TOKEN
17
+ ? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
18
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
19
+ const GRADE_MARKER = '<!-- gitpadi-grade -->';
20
+ async function glFetch(method, path, body) {
21
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
22
+ method,
23
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
24
+ body: body ? JSON.stringify(body) : undefined,
25
+ });
26
+ if (!res.ok)
27
+ throw new Error(`GitLab ${method} ${path} → ${res.status}`);
28
+ return res.json();
29
+ }
30
+ function getLetterGrade(score) {
31
+ if (score >= 80)
32
+ return { letter: 'A', emoji: '🟢' };
33
+ if (score >= 60)
34
+ return { letter: 'B', emoji: '🔵' };
35
+ if (score >= 40)
36
+ return { letter: 'C', emoji: '🟡' };
37
+ if (score >= 20)
38
+ return { letter: 'D', emoji: '🟠' };
39
+ return { letter: 'F', emoji: '🔴' };
40
+ }
41
+ async function getAIFeedback(context) {
42
+ const prompt = `You are GitPadi, an encouraging and constructive AI teaching assistant.
43
+
44
+ A student submitted an assignment:
45
+ - Student: @${context.student}
46
+ - Assignment: "${context.assignmentTitle}"
47
+ - Score: ${context.totalScore}/100 (Grade ${context.letter})
48
+
49
+ Score breakdown:
50
+ ${Object.entries(context.scores).map(([k, v]) => `- ${k}: ${v.score}/${v.max} — ${v.detail}`).join('\n')}
51
+
52
+ Files changed: ${context.changes.slice(0, 10).map((c) => c.new_path).join(', ')}
53
+
54
+ Write 3-4 sentences of personalized, constructive feedback:
55
+ 1. Acknowledge what they did well
56
+ 2. Point to the most impactful improvement they can make
57
+ 3. End with an encouraging note
58
+
59
+ Keep it warm, specific, and under 120 words. Address the student directly.`;
60
+ try {
61
+ const response = await anthropic.messages.create({
62
+ model: 'claude-haiku-4-5-20251001',
63
+ max_tokens: 256,
64
+ messages: [{ role: 'user', content: prompt }],
65
+ });
66
+ return response.content[0].type === 'text' ? response.content[0].text.trim() : '';
67
+ }
68
+ catch {
69
+ return '';
70
+ }
71
+ }
72
+ async function main() {
73
+ console.log('\n📝 GitPadi Assignment Grader Agent\n');
74
+ const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
75
+ let context;
76
+ try {
77
+ context = JSON.parse(contextRaw);
78
+ }
79
+ catch {
80
+ context = {};
81
+ }
82
+ const { project_id, project_path = '', mr_iid, title = '', description = '', source_branch = '', author = '', changes = [], pipeline_status = null, } = context;
83
+ if (!mr_iid) {
84
+ console.log('⏭️ No MR context — skipping.');
85
+ return;
86
+ }
87
+ const student = author;
88
+ const branchName = source_branch;
89
+ const prBody = description || '';
90
+ console.log(` Project: ${project_path}`);
91
+ console.log(` MR: !${mr_iid} — ${title}`);
92
+ console.log(` Student: @${student}\n`);
93
+ // Detect linked assignment issue
94
+ const bodyMatch = prBody.match(/(?:fixes|closes|resolves|assignment)\s*#(\d+)/i);
95
+ const branchMatch = branchName.match(/(?:assignment|hw|task|fix\/issue)-(\d+)/i);
96
+ const assignmentNum = bodyMatch ? parseInt(bodyMatch[1]) : branchMatch ? parseInt(branchMatch[1]) : null;
97
+ if (!assignmentNum) {
98
+ await glFetch('POST', `/projects/${project_id}/merge_requests/${mr_iid}/notes`, {
99
+ body: `⚠️ **GitPadi Grader:** Could not detect the assignment.\n\nPlease include \`Fixes #N\` in your MR description or name your branch \`assignment-N\`.\n\n${GRADE_MARKER}`,
100
+ });
101
+ return;
102
+ }
103
+ console.log(` Assignment: #${assignmentNum}\n`);
104
+ // Fetch assignment issue
105
+ let issueTitle = '';
106
+ let issueBody = '';
107
+ let issueLabels = [];
108
+ try {
109
+ const issue = await glFetch('GET', `/projects/${project_id}/issues/${assignmentNum}`);
110
+ issueTitle = issue.title;
111
+ issueBody = issue.description || '';
112
+ issueLabels = issue.labels || [];
113
+ }
114
+ catch {
115
+ console.log(`❌ Assignment issue #${assignmentNum} not found.`);
116
+ return;
117
+ }
118
+ const filenames = changes.map((c) => (c.new_path || '').toLowerCase());
119
+ const totalChanges = changes.reduce((sum, c) => {
120
+ const adds = (c.diff?.match(/^\+/gm) || []).length;
121
+ const dels = (c.diff?.match(/^-/gm) || []).length;
122
+ return sum + adds + dels;
123
+ }, 0);
124
+ // ── 1. CI Passing (25 pts) ─────────────────────────────────────────
125
+ const ciScore = pipeline_status === 'success' ? 25 : pipeline_status === 'failed' ? 0 : 10;
126
+ const ciDetail = pipeline_status === 'success' ? 'Pipeline passed' : pipeline_status === 'failed' ? 'Pipeline failed' : 'Pipeline pending';
127
+ // ── 2. Assignment Relevance (25 pts) ──────────────────────────────
128
+ const keywords = issueBody.toLowerCase()
129
+ .replace(/[#*`\[\]()]/g, ' ')
130
+ .split(/\s+/)
131
+ .filter(w => w.length > 3 && !['this', 'that', 'with', 'from', 'have', 'will', 'should', 'must', 'need'].includes(w));
132
+ const uniqueKeywords = [...new Set(keywords)];
133
+ const keywordHits = uniqueKeywords.filter(kw => filenames.some((f) => f.includes(kw)) || prBody.toLowerCase().includes(kw));
134
+ const relevanceRatio = uniqueKeywords.length > 0 ? keywordHits.length / Math.min(uniqueKeywords.length, 15) : 0;
135
+ const relevanceScore = Math.min(25, Math.round(relevanceRatio * 25));
136
+ const relevanceDetail = relevanceScore >= 20 ? 'Strong match to assignment scope' : relevanceScore >= 10 ? 'Partial match' : 'Low relevance';
137
+ // ── 3. Test Coverage (20 pts) ──────────────────────────────────────
138
+ const srcFiles = changes.filter((c) => !c.new_path?.includes('test') && !c.new_path?.includes('spec') &&
139
+ /\.(ts|js|py|rs|go|java)$/.test(c.new_path || ''));
140
+ const testFiles = changes.filter((c) => c.new_path?.includes('test') || c.new_path?.includes('spec'));
141
+ let testScore = 0;
142
+ if (testFiles.length > 0 && srcFiles.length > 0) {
143
+ const ratio = testFiles.length / srcFiles.length;
144
+ testScore = ratio >= 0.5 ? 20 : ratio >= 0.25 ? 15 : 10;
145
+ }
146
+ else if (testFiles.length > 0) {
147
+ testScore = 15;
148
+ }
149
+ else if (srcFiles.length === 0) {
150
+ testScore = 10;
151
+ }
152
+ const testDetail = testFiles.length > 0 ? `${testFiles.length} test file(s) included` : 'No test files';
153
+ // ── 4. Code Quality (15 pts) ──────────────────────────────────────
154
+ let qualityScore = 15;
155
+ const qualityNotes = [];
156
+ if (totalChanges > 1000) {
157
+ qualityScore -= 5;
158
+ qualityNotes.push('Very large MR');
159
+ }
160
+ else if (totalChanges > 500) {
161
+ qualityScore -= 2;
162
+ qualityNotes.push('Large MR');
163
+ }
164
+ const sensitivePatterns = ['.env', 'secret', 'password', 'key', 'credential'];
165
+ const hasSensitive = filenames.some((f) => sensitivePatterns.some((p) => f.includes(p)));
166
+ if (hasSensitive) {
167
+ qualityScore -= 5;
168
+ qualityNotes.push('Sensitive files detected');
169
+ }
170
+ qualityScore = Math.max(0, qualityScore);
171
+ const qualityDetail = qualityNotes.length > 0 ? qualityNotes.join(', ') : 'Clean submission';
172
+ // ── 5. Submission Format (15 pts) ─────────────────────────────────
173
+ let formatScore = 0;
174
+ const goodBranch = /^(assignment|hw|task|fix\/issue)-\d+/i.test(branchName);
175
+ if (goodBranch)
176
+ formatScore += 5;
177
+ else if (branchName !== 'main' && branchName !== 'master')
178
+ formatScore += 2;
179
+ if (/(?:fixes|closes|resolves)\s+#\d+/i.test(prBody))
180
+ formatScore += 5;
181
+ if (prBody.trim().length > 20)
182
+ formatScore += 5;
183
+ else if (prBody.trim().length > 0)
184
+ formatScore += 2;
185
+ const formatDetail = formatScore >= 12 ? 'Well-formatted' : formatScore >= 7 ? 'Acceptable' : 'Missing branch naming/issue ref/description';
186
+ const scores = {
187
+ 'CI Passing': { score: ciScore, max: 25, detail: ciDetail },
188
+ 'Assignment Relevance': { score: relevanceScore, max: 25, detail: relevanceDetail },
189
+ 'Test Coverage': { score: testScore, max: 20, detail: testDetail },
190
+ 'Code Quality': { score: qualityScore, max: 15, detail: qualityDetail },
191
+ 'Submission Format': { score: formatScore, max: 15, detail: formatDetail },
192
+ };
193
+ const totalScore = Object.values(scores).reduce((sum, s) => sum + s.score, 0);
194
+ const { letter, emoji } = getLetterGrade(totalScore);
195
+ const passed = totalScore >= PASS_THRESHOLD;
196
+ console.log(` Total: ${totalScore}/100 — Grade ${letter} ${emoji}`);
197
+ console.log(` Passed: ${passed ? 'Yes' : 'No'}\n`);
198
+ // Get AI personalized feedback
199
+ const aiFeedback = await getAIFeedback({ student, assignmentTitle: issueTitle, assignmentDesc: issueBody.substring(0, 500), scores, totalScore, letter, changes });
200
+ // Build grade card
201
+ let body = `## ${emoji} GitPadi Assignment Grade — MR !${mr_iid}\n\n`;
202
+ body += `**Student:** @${student}\n`;
203
+ body += `**Assignment:** ${issueTitle} (#${assignmentNum})\n\n`;
204
+ body += `| Criteria | Score | Max |\n|----------|-------|-----|\n`;
205
+ Object.entries(scores).forEach(([k, v]) => { body += `| ${k} | ${v.score} | ${v.max} |\n`; });
206
+ body += `| **Total** | **${totalScore}** | **100** |\n\n`;
207
+ body += `**Grade: ${letter}** ${emoji}\n\n`;
208
+ if (passed) {
209
+ body += `> ✅ **Passed** (threshold: ${PASS_THRESHOLD}/100)\n\n`;
210
+ }
211
+ else {
212
+ body += `> ❌ **Did not pass** (need ${PASS_THRESHOLD}/100). Review feedback and re-submit.\n\n`;
213
+ }
214
+ body += `### Criteria Breakdown\n\n`;
215
+ Object.entries(scores).forEach(([k, v]) => {
216
+ const icon = v.score >= v.max * 0.7 ? '✅' : v.score >= v.max * 0.4 ? '⚠️' : '❌';
217
+ body += `- ${icon} **${k}** (${v.score}/${v.max}): ${v.detail}\n`;
218
+ });
219
+ if (aiFeedback) {
220
+ body += `\n### Personalized Feedback\n\n${aiFeedback}\n`;
221
+ }
222
+ body += `\n---\n_📝 Graded by [GitPadi](https://github.com/Netwalls/contributor-agent) using Claude_\n\n${GRADE_MARKER}`;
223
+ // Post or update grade comment
224
+ const notes = await glFetch('GET', `/projects/${project_id}/merge_requests/${mr_iid}/notes?per_page=100`);
225
+ const existing = notes.find(n => n.body?.includes(GRADE_MARKER));
226
+ if (existing) {
227
+ await glFetch('PUT', `/projects/${project_id}/merge_requests/${mr_iid}/notes/${existing.id}`, { body });
228
+ console.log('✅ Updated existing grade card');
229
+ }
230
+ else {
231
+ await glFetch('POST', `/projects/${project_id}/merge_requests/${mr_iid}/notes`, { body });
232
+ console.log('✅ Posted grade card');
233
+ }
234
+ // Auto-merge if passed and CI green
235
+ if (passed && ciScore === 25) {
236
+ try {
237
+ await glFetch('PUT', `/projects/${project_id}/merge_requests/${mr_iid}/merge`, {
238
+ squash: true,
239
+ squash_commit_message: `[Grade ${letter}] ${title} (!${mr_iid})`,
240
+ });
241
+ console.log(`✅ MR !${mr_iid} auto-merged (Grade ${letter})`);
242
+ }
243
+ catch (e) {
244
+ console.log(`⚠️ Auto-merge failed: ${e.message}`);
245
+ }
246
+ }
247
+ else if (!passed) {
248
+ console.log(`❌ Grade ${letter} — below threshold. Not merging.`);
249
+ process.exit(1);
250
+ }
251
+ }
252
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
@@ -0,0 +1,200 @@
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
+ import Anthropic from '@anthropic-ai/sdk';
16
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
17
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
18
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
19
+ const anthropic = GATEWAY_TOKEN
20
+ ? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
21
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
22
+ const MR_REVIEW_MARKER = '<!-- gitpadi-mr-review -->';
23
+ async function glFetch(method, path, body) {
24
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
25
+ method,
26
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
27
+ body: body ? JSON.stringify(body) : undefined,
28
+ });
29
+ if (!res.ok) {
30
+ const text = await res.text();
31
+ throw new Error(`GitLab ${method} ${path} → ${res.status}: ${text}`);
32
+ }
33
+ return res.json();
34
+ }
35
+ async function postOrUpdateMRNote(projectId, mrIid, body) {
36
+ // Check for existing review note to update
37
+ const notes = await glFetch('GET', `/projects/${projectId}/merge_requests/${mrIid}/notes?per_page=100`);
38
+ const existing = notes.find(n => n.body?.includes(MR_REVIEW_MARKER));
39
+ if (existing) {
40
+ await glFetch('PUT', `/projects/${projectId}/merge_requests/${mrIid}/notes/${existing.id}`, { body });
41
+ console.log('✅ Updated existing review note');
42
+ }
43
+ else {
44
+ await glFetch('POST', `/projects/${projectId}/merge_requests/${mrIid}/notes`, { body });
45
+ console.log('✅ Posted new review note');
46
+ }
47
+ }
48
+ async function main() {
49
+ console.log('\n🤖 GitPadi MR Review Agent\n');
50
+ const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
51
+ let context;
52
+ try {
53
+ context = JSON.parse(contextRaw);
54
+ }
55
+ catch {
56
+ context = {};
57
+ }
58
+ const { project_id, project_path = '', mr_iid, title = '', description = '', source_branch = '', target_branch = 'main', author = '', changes = [], notes = [], } = context;
59
+ if (!mr_iid) {
60
+ console.log('⏭️ No MR context — skipping.');
61
+ return;
62
+ }
63
+ console.log(` Project: ${project_path}`);
64
+ console.log(` MR: !${mr_iid} — ${title}`);
65
+ console.log(` Author: @${author}`);
66
+ console.log(` Files: ${changes.length} changed\n`);
67
+ // Build a diff summary for Claude (cap at ~4000 chars to stay within context)
68
+ const diffSummary = changes
69
+ .slice(0, 20)
70
+ .map((c) => {
71
+ const diffLines = (c.diff || '').split('\n').slice(0, 40).join('\n');
72
+ return `File: ${c.new_path}\n${diffLines}`;
73
+ })
74
+ .join('\n\n---\n\n')
75
+ .substring(0, 4000);
76
+ const sensitivePatterns = ['.env', 'secret', 'password', 'credential', '.key', '.pem'];
77
+ const sensitiveFiles = changes
78
+ .filter((c) => sensitivePatterns.some(p => c.new_path?.toLowerCase().includes(p)))
79
+ .map((c) => c.new_path);
80
+ const testFiles = changes.filter((c) => c.new_path?.includes('test') || c.new_path?.includes('spec'));
81
+ const srcFiles = changes.filter((c) => !c.new_path?.includes('test') && !c.new_path?.includes('spec') &&
82
+ /\.(ts|js|py|rs|go|java|rb|php)$/.test(c.new_path || ''));
83
+ const linkedIssues = description?.match(/(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi) || [];
84
+ const totalLines = changes.reduce((sum, c) => {
85
+ const adds = (c.diff?.match(/^\+/gm) || []).length;
86
+ const dels = (c.diff?.match(/^-/gm) || []).length;
87
+ return sum + adds + dels;
88
+ }, 0);
89
+ // Get Claude's analysis
90
+ const prompt = `You are GitPadi, an expert AI code reviewer integrated with GitLab.
91
+
92
+ Review this Merge Request:
93
+ - Title: "${title}"
94
+ - Author: @${author}
95
+ - Branch: ${source_branch} → ${target_branch}
96
+ - Description: ${description?.substring(0, 500) || 'None provided'}
97
+ - Files changed: ${changes.length} (${totalLines} lines)
98
+ - Test files: ${testFiles.length}
99
+ - Source files: ${srcFiles.length}
100
+ - Linked issues: ${linkedIssues.join(', ') || 'None'}
101
+ ${sensitiveFiles.length ? `- ⚠️ SENSITIVE FILES DETECTED: ${sensitiveFiles.join(', ')}` : ''}
102
+
103
+ Code changes (truncated):
104
+ \`\`\`diff
105
+ ${diffSummary || 'No diff available'}
106
+ \`\`\`
107
+
108
+ Provide a thorough, constructive code review. Be specific, actionable, and encouraging.
109
+ Format your response as JSON:
110
+ {
111
+ "verdict": "APPROVE|REQUEST_CHANGES|COMMENT",
112
+ "summary": "2-3 sentence overall assessment",
113
+ "strengths": ["specific strength 1", "specific strength 2"],
114
+ "concerns": [
115
+ { "severity": "critical|warning|suggestion", "file": "filename or null", "issue": "description", "suggestion": "how to fix" }
116
+ ],
117
+ "security": { "clean": true/false, "issues": ["issue1"] },
118
+ "testCoverage": { "adequate": true/false, "comment": "observation" },
119
+ "overallScore": 0-100
120
+ }`;
121
+ let review;
122
+ try {
123
+ const response = await anthropic.messages.create({
124
+ model: 'claude-sonnet-4-20250514',
125
+ max_tokens: 2048,
126
+ messages: [{ role: 'user', content: prompt }],
127
+ });
128
+ const text = response.content[0].type === 'text' ? response.content[0].text : '';
129
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
130
+ if (!jsonMatch)
131
+ throw new Error('No JSON in Claude response');
132
+ review = JSON.parse(jsonMatch[0]);
133
+ }
134
+ catch (e) {
135
+ console.error(`❌ Claude review failed: ${e.message}`);
136
+ process.exit(1);
137
+ }
138
+ // Build the GitLab comment
139
+ const verdictIcon = review.verdict === 'APPROVE' ? '✅' : review.verdict === 'REQUEST_CHANGES' ? '❌' : '💬';
140
+ const scoreEmoji = review.overallScore >= 80 ? '🟢' : review.overallScore >= 60 ? '🟡' : '🔴';
141
+ let comment = `## 🤖 GitPadi AI Code Review\n\n`;
142
+ comment += `**MR:** !${mr_iid} — ${title}\n`;
143
+ comment += `**Verdict:** ${verdictIcon} ${review.verdict?.replace('_', ' ')} | **Score:** ${scoreEmoji} ${review.overallScore}/100\n\n`;
144
+ comment += `### Summary\n\n${review.summary}\n\n`;
145
+ if (review.strengths?.length) {
146
+ comment += `### Strengths\n\n`;
147
+ review.strengths.forEach((s) => { comment += `- ✅ ${s}\n`; });
148
+ comment += '\n';
149
+ }
150
+ if (review.concerns?.length) {
151
+ comment += `### Concerns\n\n`;
152
+ const critical = review.concerns.filter((c) => c.severity === 'critical');
153
+ const warnings = review.concerns.filter((c) => c.severity === 'warning');
154
+ const suggestions = review.concerns.filter((c) => c.severity === 'suggestion');
155
+ const renderConcerns = (items, icon) => items.forEach((c) => {
156
+ comment += `${icon} **${c.file ? `\`${c.file}\`` : 'General'}:** ${c.issue}\n`;
157
+ if (c.suggestion)
158
+ comment += ` > 💡 ${c.suggestion}\n`;
159
+ comment += '\n';
160
+ });
161
+ if (critical.length) {
162
+ comment += `**Critical:**\n`;
163
+ renderConcerns(critical, '🔴');
164
+ }
165
+ if (warnings.length) {
166
+ comment += `**Warnings:**\n`;
167
+ renderConcerns(warnings, '⚠️');
168
+ }
169
+ if (suggestions.length) {
170
+ comment += `**Suggestions:**\n`;
171
+ renderConcerns(suggestions, '💡');
172
+ }
173
+ }
174
+ if (sensitiveFiles.length) {
175
+ comment += `### ⚠️ Security Alert\n\n`;
176
+ comment += `The following files may contain sensitive data:\n`;
177
+ sensitiveFiles.forEach((f) => { comment += `- \`${f}\`\n`; });
178
+ comment += `\nPlease ensure no secrets are committed.\n\n`;
179
+ }
180
+ if (review.testCoverage) {
181
+ comment += `### Test Coverage\n\n`;
182
+ comment += `${review.testCoverage.adequate ? '✅' : '⚠️'} ${review.testCoverage.comment}\n\n`;
183
+ }
184
+ comment += `### Quick Stats\n\n`;
185
+ comment += `| Metric | Value |\n|--------|-------|\n`;
186
+ comment += `| Files changed | ${changes.length} |\n`;
187
+ comment += `| Lines changed | ~${totalLines} |\n`;
188
+ comment += `| Test files | ${testFiles.length} |\n`;
189
+ comment += `| Linked issues | ${linkedIssues.length} |\n`;
190
+ comment += `\n---\n_🤖 Reviewed by [GitPadi](https://github.com/Netwalls/contributor-agent) using Claude ${review.verdict === 'APPROVE' ? '✅' : ''}_\n\n${MR_REVIEW_MARKER}`;
191
+ await postOrUpdateMRNote(project_id, mr_iid, comment);
192
+ // Exit with error code if CI-blocking issues found
193
+ if (review.verdict === 'REQUEST_CHANGES' && review.concerns?.some((c) => c.severity === 'critical')) {
194
+ console.log('❌ Critical issues found — blocking merge.');
195
+ process.exit(1);
196
+ }
197
+ console.log(`\n Verdict: ${review.verdict}`);
198
+ console.log(` Score: ${review.overallScore}/100\n`);
199
+ }
200
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });