gitpadi 2.0.6 → 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 +1082 -36
  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 +28 -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 +1119 -35
  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 +29 -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,173 @@
1
+ #!/usr/bin/env node
2
+ // gitlab-agents/ci-recovery-agent.ts
3
+ //
4
+ // GitLab Duo External Agent — AI-powered CI Failure Recovery
5
+ //
6
+ // Triggered when a pipeline fails on an MR.
7
+ // Claude reads the full failure logs, identifies root causes, and:
8
+ // 1. Posts a diagnosis comment on the MR explaining what failed and why
9
+ // 2. Suggests specific fixes with code snippets
10
+ // 3. (Optional) Creates a fix commit if AUTO_FIX=true
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 anthropic = GATEWAY_TOKEN
16
+ ? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
17
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
18
+ const CI_RECOVERY_MARKER = '<!-- gitpadi-ci-recovery -->';
19
+ async function glFetch(method, path, body) {
20
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
21
+ method,
22
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
23
+ body: body ? JSON.stringify(body) : undefined,
24
+ });
25
+ if (!res.ok)
26
+ throw new Error(`GitLab ${method} ${path} → ${res.status}`);
27
+ return res.json();
28
+ }
29
+ async function getJobLog(projectId, jobId) {
30
+ const res = await fetch(`${GITLAB_HOST}/api/v4/projects/${projectId}/jobs/${jobId}/trace`, {
31
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN },
32
+ });
33
+ if (!res.ok)
34
+ return 'Log unavailable';
35
+ const text = await res.text();
36
+ // Return last 3000 chars — most errors are at the end
37
+ return text.length > 3000 ? `...\n${text.slice(-3000)}` : text;
38
+ }
39
+ async function postOrUpdateMRNote(projectId, mrIid, body) {
40
+ const notes = await glFetch('GET', `/projects/${projectId}/merge_requests/${mrIid}/notes?per_page=100`);
41
+ const existing = notes.find(n => n.body?.includes(CI_RECOVERY_MARKER));
42
+ if (existing) {
43
+ await glFetch('PUT', `/projects/${projectId}/merge_requests/${mrIid}/notes/${existing.id}`, { body });
44
+ }
45
+ else {
46
+ await glFetch('POST', `/projects/${projectId}/merge_requests/${mrIid}/notes`, { body });
47
+ }
48
+ }
49
+ async function main() {
50
+ console.log('\n🤖 GitPadi CI Recovery Agent\n');
51
+ const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
52
+ let context;
53
+ try {
54
+ context = JSON.parse(contextRaw);
55
+ }
56
+ catch {
57
+ context = {};
58
+ }
59
+ const { project_id, project_path = '', mr_iid, pipeline_id, title = '', source_branch = '', author = '', failed_jobs = [], } = context;
60
+ if (!mr_iid || !pipeline_id) {
61
+ console.log('⏭️ No MR/pipeline context — skipping.');
62
+ return;
63
+ }
64
+ console.log(` Project: ${project_path}`);
65
+ console.log(` MR: !${mr_iid} — ${title}`);
66
+ console.log(` Pipeline: #${pipeline_id}`);
67
+ console.log(` Failed: ${failed_jobs.length} job(s)\n`);
68
+ // Fetch all failed jobs if not in context
69
+ let jobs = failed_jobs;
70
+ if (!jobs.length) {
71
+ try {
72
+ const allJobs = await glFetch('GET', `/projects/${project_id}/pipelines/${pipeline_id}/jobs?per_page=100`);
73
+ jobs = allJobs.filter((j) => j.status === 'failed');
74
+ }
75
+ catch {
76
+ jobs = [];
77
+ }
78
+ }
79
+ if (!jobs.length) {
80
+ console.log(' No failed jobs found.');
81
+ return;
82
+ }
83
+ // Collect logs from failed jobs
84
+ console.log(' Fetching failure logs...');
85
+ const jobLogs = [];
86
+ for (const job of jobs.slice(0, 3)) { // max 3 jobs to avoid token overload
87
+ const log = await getJobLog(project_id, job.id);
88
+ jobLogs.push({ name: job.name, stage: job.stage || 'unknown', log });
89
+ console.log(` ✓ Fetched log for: ${job.name}`);
90
+ }
91
+ // Ask Claude to diagnose
92
+ const prompt = `You are GitPadi, an AI DevOps assistant integrated with GitLab.
93
+
94
+ A CI pipeline failed on this Merge Request:
95
+ - MR: !${mr_iid} — "${title}"
96
+ - Author: @${author}
97
+ - Branch: ${source_branch}
98
+
99
+ Failed job logs:
100
+ ${jobLogs.map(j => `\n### Job: ${j.name} (stage: ${j.stage})\n\`\`\`\n${j.log}\n\`\`\``).join('\n')}
101
+
102
+ Analyze these failures and provide:
103
+ 1. Root cause diagnosis for each failure
104
+ 2. Specific fix recommendations with code snippets where possible
105
+ 3. Priority order of fixes
106
+
107
+ Respond as JSON:
108
+ {
109
+ "failures": [
110
+ {
111
+ "job": "job name",
112
+ "rootCause": "explanation",
113
+ "errorType": "lint|test|build|type|other",
114
+ "fix": {
115
+ "description": "what to change",
116
+ "codeSnippet": "code if applicable, null otherwise",
117
+ "commands": ["shell commands if applicable"]
118
+ },
119
+ "confidence": "high|medium|low"
120
+ }
121
+ ],
122
+ "quickFix": "single most impactful thing to fix first",
123
+ "estimatedFixTime": "e.g. 5 minutes",
124
+ "summary": "overall diagnosis in 2-3 sentences"
125
+ }`;
126
+ let diagnosis;
127
+ try {
128
+ const response = await anthropic.messages.create({
129
+ model: 'claude-sonnet-4-20250514',
130
+ max_tokens: 2048,
131
+ messages: [{ role: 'user', content: prompt }],
132
+ });
133
+ const text = response.content[0].type === 'text' ? response.content[0].text : '';
134
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
135
+ if (!jsonMatch)
136
+ throw new Error('No JSON');
137
+ diagnosis = JSON.parse(jsonMatch[0]);
138
+ }
139
+ catch (e) {
140
+ console.error(`❌ Claude diagnosis failed: ${e.message}`);
141
+ process.exit(1);
142
+ }
143
+ // Build the recovery comment
144
+ let comment = `## 🤖 GitPadi — CI Failure Recovery\n\n`;
145
+ comment += `**Pipeline:** #${pipeline_id} | **Failed jobs:** ${jobs.length}\n\n`;
146
+ comment += `### Diagnosis\n\n${diagnosis.summary}\n\n`;
147
+ if (diagnosis.quickFix) {
148
+ comment += `> 🚀 **Quick Fix:** ${diagnosis.quickFix}\n`;
149
+ comment += `> ⏱️ **Estimated fix time:** ${diagnosis.estimatedFixTime || 'a few minutes'}\n\n`;
150
+ }
151
+ diagnosis.failures?.forEach((f, i) => {
152
+ const confIcon = f.confidence === 'high' ? '🔴' : f.confidence === 'medium' ? '🟡' : '🟢';
153
+ comment += `### ${i + 1}. \`${f.job}\`\n\n`;
154
+ comment += `**Root Cause:** ${f.rootCause}\n\n`;
155
+ if (f.fix) {
156
+ comment += `**Fix:** ${f.fix.description}\n\n`;
157
+ if (f.fix.codeSnippet) {
158
+ comment += `\`\`\`\n${f.fix.codeSnippet}\n\`\`\`\n\n`;
159
+ }
160
+ if (f.fix.commands?.length) {
161
+ comment += `Run:\n\`\`\`bash\n${f.fix.commands.join('\n')}\n\`\`\`\n\n`;
162
+ }
163
+ }
164
+ comment += `${confIcon} Confidence: ${f.confidence}\n\n`;
165
+ });
166
+ comment += `---\n`;
167
+ comment += `_🤖 Diagnosed by [GitPadi](https://github.com/Netwalls/contributor-agent) using Claude — [Fix & Re-push](https://github.com/Netwalls/contributor-agent#fix--re-push) when ready_\n\n`;
168
+ comment += CI_RECOVERY_MARKER;
169
+ await postOrUpdateMRNote(project_id, mr_iid, comment);
170
+ console.log(`\n✅ Recovery guide posted to MR !${mr_iid}`);
171
+ console.log(` ${diagnosis.failures?.length || 0} failure(s) diagnosed`);
172
+ }
173
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
@@ -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); });