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,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); });
@@ -0,0 +1,164 @@
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
+ import Anthropic from '@anthropic-ai/sdk';
15
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
16
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
17
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
18
+ const PROJECT_PATH = process.env.GITLAB_PROJECT_PATH || '';
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 REMINDER_MARKERS = {
23
+ h24: '<!-- gitpadi-reminder-24h -->',
24
+ h48: '<!-- gitpadi-reminder-48h -->',
25
+ h72: '<!-- gitpadi-reminder-72h -->',
26
+ };
27
+ async function glFetch(method, path, body) {
28
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
29
+ method,
30
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
31
+ body: body ? JSON.stringify(body) : undefined,
32
+ });
33
+ if (!res.ok)
34
+ throw new Error(`GitLab ${method} ${path} → ${res.status}`);
35
+ return res.json();
36
+ }
37
+ function hoursAgo(dateStr) {
38
+ return (Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60);
39
+ }
40
+ async function generateReminderMessage(tier, context) {
41
+ const tierDescriptions = {
42
+ '24h': 'gentle first reminder — friendly and encouraging',
43
+ '48h': 'second reminder — more urgent but still supportive, mention help is available',
44
+ '72h': 'final notice — professional but firm, this will be unassigned',
45
+ };
46
+ const prompt = `You are GitPadi, a friendly contributor management bot on GitLab.
47
+
48
+ Write a ${tierDescriptions[tier]} for a stale Merge Request:
49
+ - MR Title: "${context.mrTitle}"
50
+ - Author: @${context.author}
51
+ - Hours since last activity: ${Math.round(context.hoursStale)}
52
+ - Branch: ${context.branchName}
53
+ ${context.linkedIssue ? `- Linked issue: ${context.linkedIssue}` : ''}
54
+
55
+ Requirements:
56
+ - Keep it under 100 words
57
+ - Be human, warm, and specific to the situation
58
+ - ${tier === '72h' ? 'Mention this is the final notice and the issue will be unassigned' : 'Do NOT mention unassignment'}
59
+ - Start with a relevant emoji
60
+ - Do NOT use generic corporate language
61
+
62
+ Return just the message text, no JSON, no explanation.`;
63
+ try {
64
+ const response = await anthropic.messages.create({
65
+ model: 'claude-haiku-4-5-20251001',
66
+ max_tokens: 256,
67
+ messages: [{ role: 'user', content: prompt }],
68
+ });
69
+ return response.content[0].type === 'text' ? response.content[0].text.trim() : getFallbackMessage(tier, context.author);
70
+ }
71
+ catch {
72
+ return getFallbackMessage(tier, context.author);
73
+ }
74
+ }
75
+ function getFallbackMessage(tier, author) {
76
+ const messages = {
77
+ '24h': `👋 @${author} — just a friendly nudge! Your MR has been open for about 24 hours. Any updates or blockers we can help with?`,
78
+ '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!`,
79
+ '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.`,
80
+ };
81
+ return messages[tier];
82
+ }
83
+ async function processMR(mr, projectId) {
84
+ const hoursStale = hoursAgo(mr.updated_at);
85
+ // Skip drafts — they're not ready
86
+ if (mr.draft) {
87
+ console.log(` ⏭️ !${mr.iid} — Draft, skipping`);
88
+ return;
89
+ }
90
+ // Determine escalation tier
91
+ let tier = null;
92
+ if (hoursStale >= 72)
93
+ tier = '72h';
94
+ else if (hoursStale >= 48)
95
+ tier = '48h';
96
+ else if (hoursStale >= 24)
97
+ tier = '24h';
98
+ else
99
+ return; // Not stale enough
100
+ // Check for existing reminders
101
+ const notes = await glFetch('GET', `/projects/${projectId}/merge_requests/${mr.iid}/notes?per_page=100`);
102
+ const hasMarker = (marker) => notes.some(n => n.body?.includes(marker));
103
+ // Don't double-post the same tier
104
+ if (tier === '24h' && hasMarker(REMINDER_MARKERS.h24))
105
+ return;
106
+ if (tier === '48h' && hasMarker(REMINDER_MARKERS.h48))
107
+ return;
108
+ if (tier === '72h' && hasMarker(REMINDER_MARKERS.h72))
109
+ return;
110
+ // Detect linked issue from description
111
+ const issueMatch = mr.description?.match(/(?:fixes|closes|resolves)\s+#(\d+)/i);
112
+ const linkedIssue = issueMatch ? `#${issueMatch[1]}` : undefined;
113
+ console.log(` 📬 MR !${mr.iid} — Tier ${tier} (${Math.round(hoursStale)}h stale)`);
114
+ const message = await generateReminderMessage(tier, {
115
+ mrTitle: mr.title,
116
+ author: mr.author.username,
117
+ hoursStale,
118
+ branchName: mr.source_branch,
119
+ linkedIssue,
120
+ });
121
+ // Post reminder
122
+ 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', '')}`]}`;
123
+ await glFetch('POST', `/projects/${projectId}/merge_requests/${mr.iid}/notes`, { body });
124
+ // Auto-unassign at 72h
125
+ if (tier === '72h' && linkedIssue) {
126
+ const issueNum = parseInt(issueMatch[1]);
127
+ try {
128
+ await glFetch('PUT', `/projects/${projectId}/issues/${issueNum}`, { assignee_ids: [] });
129
+ console.log(` ✓ Unassigned issue ${linkedIssue}`);
130
+ // Also post on the issue
131
+ await glFetch('POST', `/projects/${projectId}/issues/${issueNum}/notes`, {
132
+ 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 -->`,
133
+ });
134
+ }
135
+ catch (e) {
136
+ console.log(` ⚠️ Could not unassign issue: ${e.message}`);
137
+ }
138
+ }
139
+ }
140
+ async function main() {
141
+ console.log('\n🤖 GitPadi Reminder Agent\n');
142
+ if (!PROJECT_PATH) {
143
+ console.error('❌ GITLAB_PROJECT_PATH required (e.g. "mygroup/myproject")');
144
+ process.exit(1);
145
+ }
146
+ const encodedPath = encodeURIComponent(PROJECT_PATH);
147
+ const project = await glFetch(`GET`, `/projects/${encodedPath}`);
148
+ console.log(` Project: ${project.path_with_namespace}\n`);
149
+ // Get all open MRs
150
+ const mrs = await glFetch('GET', `/projects/${project.id}/merge_requests?state=opened&per_page=100`);
151
+ console.log(` Found ${mrs.length} open MR(s)\n`);
152
+ let reminded = 0;
153
+ for (const mr of mrs) {
154
+ try {
155
+ await processMR(mr, project.id);
156
+ reminded++;
157
+ }
158
+ catch (e) {
159
+ console.log(` ⚠️ Error processing MR !${mr.iid}: ${e.message}`);
160
+ }
161
+ }
162
+ console.log(`\n✅ Reminder cycle complete — ${reminded} MR(s) processed`);
163
+ }
164
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ // grade-assignment.ts — Automated assignment grading for GitPadi
3
+ //
4
+ // Triggered on pull_request events. Grades student submissions against
5
+ // linked assignment issues, posts a grade card, and auto-merges passing work.
6
+ //
7
+ // Usage via Action: action: grade-assignment
8
+ import { Octokit } from '@octokit/rest';
9
+ import chalk from 'chalk';
10
+ const TOKEN = process.env.GITHUB_TOKEN || '';
11
+ const OWNER = process.env.GITHUB_OWNER || '';
12
+ const REPO = process.env.GITHUB_REPO || '';
13
+ const PR_NUMBER = parseInt(process.env.PR_NUMBER || '0');
14
+ const PASS_THRESHOLD = parseInt(process.env.PASS_THRESHOLD || '40');
15
+ const octokit = new Octokit({ auth: TOKEN });
16
+ const GRADE_SIGNATURE = '<!-- gitpadi-grade -->';
17
+ function getLetterGrade(score) {
18
+ if (score >= 80)
19
+ return { letter: 'A', emoji: '🟢' };
20
+ if (score >= 60)
21
+ return { letter: 'B', emoji: '🔵' };
22
+ if (score >= 40)
23
+ return { letter: 'C', emoji: '🟡' };
24
+ if (score >= 20)
25
+ return { letter: 'D', emoji: '🟠' };
26
+ return { letter: 'F', emoji: '🔴' };
27
+ }
28
+ // Extract assignment issue number from PR body or branch name
29
+ function detectAssignment(prBody, branchName) {
30
+ // Check PR body: "Fixes #N", "Closes #N", "Assignment #N"
31
+ const bodyMatch = prBody.match(/(?:fixes|closes|resolves|assignment)\s*#(\d+)/i);
32
+ if (bodyMatch)
33
+ return parseInt(bodyMatch[1]);
34
+ // Check branch: "assignment-3", "hw-3", "task-3"
35
+ const branchMatch = branchName.match(/(?:assignment|hw|task|fix\/issue)-(\d+)/i);
36
+ if (branchMatch)
37
+ return parseInt(branchMatch[1]);
38
+ return null;
39
+ }
40
+ // Extract keywords from assignment issue body for relevance matching
41
+ function extractKeywords(issueBody) {
42
+ const words = issueBody.toLowerCase()
43
+ .replace(/[#*`\[\]()]/g, ' ')
44
+ .split(/\s+/)
45
+ .filter(w => w.length > 3);
46
+ // Remove common stop words
47
+ const stopWords = new Set(['this', 'that', 'with', 'from', 'have', 'will', 'should', 'your', 'must',
48
+ 'need', 'make', 'using', 'create', 'please', 'assignment', 'task', 'following', 'requirements']);
49
+ return [...new Set(words.filter(w => !stopWords.has(w)))];
50
+ }
51
+ async function main() {
52
+ if (!PR_NUMBER) {
53
+ console.error('❌ PR_NUMBER is required');
54
+ process.exit(1);
55
+ }
56
+ console.log(`\n📝 GitPadi Assignment Grader — ${OWNER}/${REPO} #${PR_NUMBER}\n`);
57
+ // Get PR details
58
+ const { data: pr } = await octokit.pulls.get({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
59
+ const student = pr.user?.login || 'unknown';
60
+ const branchName = pr.head.ref;
61
+ const prBody = pr.body || '';
62
+ console.log(` Student: @${student}`);
63
+ console.log(` Branch: ${branchName}`);
64
+ // Detect linked assignment
65
+ const assignmentNumber = detectAssignment(prBody, branchName);
66
+ if (!assignmentNumber) {
67
+ console.log(chalk.yellow('⚠️ No assignment issue detected. Skipping grading.'));
68
+ await octokit.issues.createComment({
69
+ owner: OWNER, repo: REPO, issue_number: PR_NUMBER,
70
+ body: `⚠️ **GitPadi Grader:** Could not detect which assignment this PR is for.\n\nPlease include \`Fixes #N\` in your PR description or name your branch \`assignment-N\`.\n\n${GRADE_SIGNATURE}`
71
+ });
72
+ return;
73
+ }
74
+ console.log(` Assignment: #${assignmentNumber}\n`);
75
+ // Fetch assignment issue
76
+ let issueTitle = '';
77
+ let issueBody = '';
78
+ let issueLabels = [];
79
+ try {
80
+ const { data: issue } = await octokit.issues.get({ owner: OWNER, repo: REPO, issue_number: assignmentNumber });
81
+ issueTitle = issue.title;
82
+ issueBody = issue.body || '';
83
+ issueLabels = issue.labels.map((l) => typeof l === 'string' ? l : l.name || '');
84
+ }
85
+ catch {
86
+ console.log(chalk.red(`❌ Assignment issue #${assignmentNumber} not found.`));
87
+ return;
88
+ }
89
+ // Fetch PR files
90
+ const { data: files } = await octokit.pulls.listFiles({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
91
+ const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
92
+ const filenames = files.map(f => f.filename.toLowerCase());
93
+ // Fetch commits
94
+ const { data: commits } = await octokit.pulls.listCommits({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
95
+ const checks = [];
96
+ // ── 1. CI Passing (25 points) ──────────────────────────────────────
97
+ const sha = pr.head.sha;
98
+ const { data: ciChecks } = await octokit.checks.listForRef({ owner: OWNER, repo: REPO, ref: sha });
99
+ const { data: statuses } = await octokit.repos.getCombinedStatusForRef({ owner: OWNER, repo: REPO, ref: sha });
100
+ const ciPassed = statuses.state === 'success' || (ciChecks.total_count > 0 && ciChecks.check_runs.every(c => c.conclusion === 'success'));
101
+ const ciPending = statuses.state === 'pending' || ciChecks.check_runs.some(c => c.status !== 'completed');
102
+ const ciFailed = statuses.state === 'failure' || ciChecks.check_runs.some(c => c.conclusion === 'failure');
103
+ if (ciPassed) {
104
+ checks.push({ criteria: 'CI Passing', score: 25, max: 25, detail: 'All checks green' });
105
+ }
106
+ else if (ciPending) {
107
+ checks.push({ criteria: 'CI Passing', score: 10, max: 25, detail: 'Still running — will re-grade when complete' });
108
+ }
109
+ else {
110
+ const failedNames = ciChecks.check_runs.filter(c => c.conclusion === 'failure').map(c => c.name).join(', ');
111
+ checks.push({ criteria: 'CI Passing', score: 0, max: 25, detail: `Failed: ${failedNames || 'status checks'}` });
112
+ }
113
+ // ── 2. Assignment Relevance (25 points) ────────────────────────────
114
+ const keywords = extractKeywords(issueBody);
115
+ const allFileContent = filenames.join(' ');
116
+ const keywordHits = keywords.filter(kw => allFileContent.includes(kw) || prBody.toLowerCase().includes(kw));
117
+ const relevanceRatio = keywords.length > 0 ? keywordHits.length / Math.min(keywords.length, 15) : 0;
118
+ const relevanceScore = Math.min(25, Math.round(relevanceRatio * 25));
119
+ checks.push({
120
+ criteria: 'Assignment Relevance',
121
+ score: relevanceScore,
122
+ max: 25,
123
+ detail: relevanceScore >= 20 ? 'Matches assignment scope' : relevanceScore >= 10 ? 'Partial match — some expected files/topics missing' : 'Low relevance to assignment requirements'
124
+ });
125
+ // ── 3. Test Coverage (20 points) ───────────────────────────────────
126
+ const srcFiles = files.filter(f => !f.filename.includes('test') && !f.filename.includes('spec') && (f.filename.endsWith('.ts') || f.filename.endsWith('.js') || f.filename.endsWith('.rs') || f.filename.endsWith('.py') || f.filename.endsWith('.jsx') || f.filename.endsWith('.tsx')));
127
+ const testFiles = files.filter(f => f.filename.includes('test') || f.filename.includes('spec'));
128
+ let testScore = 0;
129
+ if (testFiles.length > 0 && srcFiles.length > 0) {
130
+ const testRatio = testFiles.length / srcFiles.length;
131
+ testScore = testRatio >= 0.5 ? 20 : testRatio >= 0.25 ? 15 : 10;
132
+ }
133
+ else if (testFiles.length > 0) {
134
+ testScore = 15;
135
+ }
136
+ else if (srcFiles.length === 0) {
137
+ testScore = 10; // No source files changed, might be docs-only
138
+ }
139
+ checks.push({
140
+ criteria: 'Test Coverage',
141
+ score: testScore,
142
+ max: 20,
143
+ detail: testFiles.length > 0 ? `${testFiles.length} test file(s) included` : 'No test files in submission'
144
+ });
145
+ // ── 4. Code Quality (15 points) ────────────────────────────────────
146
+ let qualityScore = 15;
147
+ const qualityNotes = [];
148
+ // PR size
149
+ if (totalChanges > 1000) {
150
+ qualityScore -= 5;
151
+ qualityNotes.push('Very large PR');
152
+ }
153
+ else if (totalChanges > 500) {
154
+ qualityScore -= 2;
155
+ qualityNotes.push('Large PR');
156
+ }
157
+ // Commit messages — check for conventional format
158
+ const badCommits = commits.filter(c => {
159
+ const msg = c.commit.message.toLowerCase();
160
+ return msg.startsWith('update') || (msg.startsWith('fix') && msg.length < 10) || msg === 'wip';
161
+ });
162
+ if (badCommits.length > commits.length / 2) {
163
+ qualityScore -= 3;
164
+ qualityNotes.push('Vague commit messages');
165
+ }
166
+ // Sensitive files
167
+ const sensitivePatterns = ['.env', 'secret', 'password', 'key', 'credential'];
168
+ const hasSensitive = filenames.some(f => sensitivePatterns.some(p => f.includes(p)));
169
+ if (hasSensitive) {
170
+ qualityScore -= 5;
171
+ qualityNotes.push('Sensitive files detected');
172
+ }
173
+ qualityScore = Math.max(0, qualityScore);
174
+ checks.push({
175
+ criteria: 'Code Quality',
176
+ score: qualityScore,
177
+ max: 15,
178
+ detail: qualityNotes.length > 0 ? qualityNotes.join(', ') : 'Clean submission'
179
+ });
180
+ // ── 5. Submission Format (15 points) ────────────────────────────────
181
+ let formatScore = 0;
182
+ // Branch naming
183
+ const goodBranch = /^(assignment|hw|task|fix\/issue)-\d+/i.test(branchName);
184
+ if (goodBranch) {
185
+ formatScore += 5;
186
+ }
187
+ else if (branchName !== 'main' && branchName !== 'master') {
188
+ formatScore += 2;
189
+ }
190
+ // Issue linked
191
+ const hasIssueRef = /(?:fixes|closes|resolves)\s+#\d+/i.test(prBody);
192
+ if (hasIssueRef)
193
+ formatScore += 5;
194
+ // PR body not empty
195
+ if (prBody.trim().length > 20)
196
+ formatScore += 5;
197
+ else if (prBody.trim().length > 0)
198
+ formatScore += 2;
199
+ checks.push({
200
+ criteria: 'Submission Format',
201
+ score: formatScore,
202
+ max: 15,
203
+ detail: formatScore >= 12 ? 'Well-formatted submission' : formatScore >= 7 ? 'Acceptable format' : 'Missing branch naming, issue reference, or PR description'
204
+ });
205
+ // ── Calculate Final Grade ──────────────────────────────────────────
206
+ const totalScore = checks.reduce((sum, c) => sum + c.score, 0);
207
+ const { letter, emoji } = getLetterGrade(totalScore);
208
+ const passed = totalScore >= PASS_THRESHOLD;
209
+ console.log(`\n Total: ${totalScore}/100 — Grade ${letter} ${emoji}\n`);
210
+ checks.forEach(c => console.log(` ${c.score >= c.max * 0.7 ? '✅' : c.score >= c.max * 0.4 ? '⚠️' : '❌'} ${c.criteria}: ${c.score}/${c.max}`));
211
+ // ── Build Grade Card ───────────────────────────────────────────────
212
+ let body = `## ${emoji} GitPadi Assignment Grade — PR #${PR_NUMBER}\n\n`;
213
+ body += `**Student:** @${student}\n`;
214
+ body += `**Assignment:** ${issueTitle} (#${assignmentNumber})\n\n`;
215
+ body += `| Criteria | Score | Max |\n|----------|-------|-----|\n`;
216
+ checks.forEach(c => { body += `| ${c.criteria} | ${c.score} | ${c.max} |\n`; });
217
+ body += `| **Total** | **${totalScore}** | **100** |\n\n`;
218
+ body += `**Grade: ${letter}** ${emoji}\n\n`;
219
+ if (passed) {
220
+ body += `> ✅ **Passed** (threshold: ${PASS_THRESHOLD}/100). Auto-merging.\n`;
221
+ }
222
+ else {
223
+ body += `> ❌ **Did not pass** (need ${PASS_THRESHOLD}/100). Please review the feedback above and re-submit.\n`;
224
+ }
225
+ // Feedback per check
226
+ body += `\n### Feedback\n\n`;
227
+ checks.forEach(c => {
228
+ const icon = c.score >= c.max * 0.7 ? '✅' : c.score >= c.max * 0.4 ? '⚠️' : '❌';
229
+ body += `- ${icon} **${c.criteria}** (${c.score}/${c.max}): ${c.detail}\n`;
230
+ });
231
+ body += `\n---\n_Graded by [GitPadi](https://github.com/Netwalls/contributor-agent) 📝_\n\n${GRADE_SIGNATURE}`;
232
+ // ── Post or Update Grade Card ──────────────────────────────────────
233
+ const { data: existingComments } = await octokit.issues.listComments({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER });
234
+ const existingGrade = existingComments.find(c => c.body?.includes(GRADE_SIGNATURE));
235
+ if (existingGrade) {
236
+ await octokit.issues.updateComment({ owner: OWNER, repo: REPO, comment_id: existingGrade.id, body });
237
+ console.log('✅ Updated existing grade card');
238
+ }
239
+ else {
240
+ await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, body });
241
+ console.log('✅ Posted grade card');
242
+ }
243
+ // ── Auto-merge if passed ───────────────────────────────────────────
244
+ if (passed && ciPassed) {
245
+ try {
246
+ await octokit.pulls.merge({
247
+ owner: OWNER, repo: REPO, pull_number: PR_NUMBER,
248
+ merge_method: 'squash',
249
+ commit_title: `[Grade ${letter}] ${pr.title} (#${PR_NUMBER})`,
250
+ });
251
+ console.log(chalk.green(`✅ PR #${PR_NUMBER} merged (Grade ${letter}).`));
252
+ }
253
+ catch (e) {
254
+ console.log(chalk.yellow(`⚠️ Auto-merge failed: ${e.message}`));
255
+ }
256
+ }
257
+ else if (!passed) {
258
+ console.log(chalk.red(`❌ Grade ${letter} — below threshold. Not merging.`));
259
+ process.exit(1);
260
+ }
261
+ }
262
+ main().catch((e) => { console.error('Fatal:', e); process.exit(1); });