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