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,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); });
@@ -0,0 +1,127 @@
1
+ import { initGitHub, getOctokit, getOwner, getRepo } from './core/github.js';
2
+ import chalk from 'chalk';
3
+ // Tiered Escalation Thresholds (in hours)
4
+ const TIER_1_HOURS = 24; // Gentle reminder: "Please create a draft PR"
5
+ const TIER_2_HOURS = 48; // Warning: "Will be unassigned in 24h"
6
+ const TIER_3_HOURS = 72; // Auto-unassign
7
+ // Signature markers for each tier (prevents duplicate comments)
8
+ const SIG_TIER_1 = '<!-- gitpadi-reminder-24h -->';
9
+ const SIG_TIER_2 = '<!-- gitpadi-reminder-48h -->';
10
+ const SIG_TIER_3 = '<!-- gitpadi-unassigned -->';
11
+ async function run() {
12
+ console.log(chalk.bold('\n🚀 GitPadi Escalating Reminder Engine\n'));
13
+ try {
14
+ initGitHub();
15
+ const octokit = getOctokit();
16
+ const owner = getOwner();
17
+ const repo = getRepo();
18
+ if (!owner || !repo) {
19
+ console.error(chalk.red('❌ Owner or Repo not found in environment/config.'));
20
+ process.exit(1);
21
+ }
22
+ console.log(chalk.dim(`🔎 Scanning ${owner}/${repo} for inactive contributors...\n`));
23
+ // 1. Fetch all open issues
24
+ const { data: issues } = await octokit.issues.listForRepo({
25
+ owner, repo, state: 'open', per_page: 100
26
+ });
27
+ const issuesWithAssignees = issues.filter(i => i.assignees && i.assignees.length > 0 && !i.pull_request);
28
+ if (issuesWithAssignees.length === 0) {
29
+ console.log(chalk.green('✅ No open issues with assignees found.'));
30
+ return;
31
+ }
32
+ const now = new Date();
33
+ let remindCount = 0, warnCount = 0, unassignCount = 0;
34
+ for (const issue of issuesWithAssignees) {
35
+ const issueNumber = issue.number;
36
+ console.log(chalk.cyan(` ▸ Issue #${issueNumber}: "${issue.title.substring(0, 50)}..."`));
37
+ // 2. Check for existing reminder comments
38
+ const { data: comments } = await octokit.issues.listComments({
39
+ owner, repo, issue_number: issueNumber
40
+ });
41
+ const hasTier1 = comments.some(c => c.body?.includes(SIG_TIER_1));
42
+ const hasTier2 = comments.some(c => c.body?.includes(SIG_TIER_2));
43
+ const hasTier3 = comments.some(c => c.body?.includes(SIG_TIER_3));
44
+ if (hasTier3) {
45
+ console.log(chalk.dim(` ⏩ Already processed (unassigned). Skipping.`));
46
+ continue;
47
+ }
48
+ // 3. Check for linked draft/open PRs
49
+ const { data: prs } = await octokit.pulls.list({
50
+ owner, repo, state: 'open', per_page: 100
51
+ });
52
+ const linkedPR = prs.find(pr => pr.body?.includes(`#${issueNumber}`) ||
53
+ pr.head.ref.includes(`issue-${issueNumber}`));
54
+ if (linkedPR) {
55
+ console.log(chalk.green(` ✅ PR found: #${linkedPR.number} (${linkedPR.draft ? 'draft' : 'open'}). Skipping.`));
56
+ continue;
57
+ }
58
+ // 4. Find when the user was assigned
59
+ const { data: events } = await octokit.issues.listEvents({
60
+ owner, repo, issue_number: issueNumber
61
+ });
62
+ const assignmentEvent = [...events].reverse().find(e => e.event === 'assigned');
63
+ if (!assignmentEvent) {
64
+ console.log(chalk.dim(` ❓ No assignment event found. Skipping.`));
65
+ continue;
66
+ }
67
+ const assignedAt = new Date(assignmentEvent.created_at);
68
+ const diffHours = Math.floor((now.getTime() - assignedAt.getTime()) / (1000 * 60 * 60));
69
+ const assignee = assignmentEvent.assignee?.login;
70
+ console.log(chalk.dim(` 🕒 Assigned ${diffHours}h ago (@${assignee})`));
71
+ // 5. Tiered escalation
72
+ if (diffHours >= TIER_3_HOURS && !hasTier3) {
73
+ // ── TIER 3: Auto-unassign ──
74
+ const message = `⏰ Hi @${assignee}, it's been **${diffHours} hours** since you were assigned to this issue, and no draft PR has been created.
75
+
76
+ To keep the project moving, we're **unassigning you** from this issue. You're welcome to re-claim it anytime!
77
+
78
+ ${SIG_TIER_3}`;
79
+ await octokit.issues.createComment({
80
+ owner, repo, issue_number: issueNumber, body: message
81
+ });
82
+ await octokit.issues.removeAssignees({
83
+ owner, repo, issue_number: issueNumber,
84
+ assignees: [assignee]
85
+ });
86
+ console.log(chalk.red(` 🔴 TIER 3: @${assignee} unassigned after ${diffHours}h.`));
87
+ unassignCount++;
88
+ }
89
+ else if (diffHours >= TIER_2_HOURS && !hasTier2) {
90
+ // ── TIER 2: Warning ──
91
+ const message = `⚠️ Hi @${assignee}, it's been **${diffHours} hours** since you were assigned to this issue.
92
+
93
+ We haven't seen a draft PR yet. Please create one to confirm you're actively working on this — otherwise, you may be **unassigned in 24 hours** to keep things moving.
94
+
95
+ ${SIG_TIER_2}`;
96
+ await octokit.issues.createComment({
97
+ owner, repo, issue_number: issueNumber, body: message
98
+ });
99
+ console.log(chalk.yellow(` 🟡 TIER 2: Warning sent to @${assignee} (${diffHours}h).`));
100
+ warnCount++;
101
+ }
102
+ else if (diffHours >= TIER_1_HOURS && !hasTier1) {
103
+ // ── TIER 1: Gentle reminder ──
104
+ const message = `👋 Hi @${assignee}, you've been assigned to this issue for **${diffHours} hours**.
105
+
106
+ Just a friendly nudge — please create a **draft PR** when you start working, so we know you're on it! 🚀
107
+
108
+ ${SIG_TIER_1}`;
109
+ await octokit.issues.createComment({
110
+ owner, repo, issue_number: issueNumber, body: message
111
+ });
112
+ console.log(chalk.green(` 🟢 TIER 1: Gentle reminder sent to @${assignee} (${diffHours}h).`));
113
+ remindCount++;
114
+ }
115
+ else {
116
+ console.log(chalk.dim(` ⏳ Within threshold (${diffHours}h). No action needed.`));
117
+ }
118
+ }
119
+ console.log(chalk.bold(`\n✨ Reminder sweep complete.`));
120
+ console.log(chalk.dim(` 📊 Reminded: ${remindCount} | Warned: ${warnCount} | Unassigned: ${unassignCount}\n`));
121
+ }
122
+ catch (error) {
123
+ console.error(chalk.red(`\n❌ Error during reminder sweep: ${error.message}`));
124
+ process.exit(1);
125
+ }
126
+ }
127
+ run();