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,283 @@
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
+
9
+ import { Octokit } from '@octokit/rest';
10
+ import chalk from 'chalk';
11
+
12
+ const TOKEN = process.env.GITHUB_TOKEN || '';
13
+ const OWNER = process.env.GITHUB_OWNER || '';
14
+ const REPO = process.env.GITHUB_REPO || '';
15
+ const PR_NUMBER = parseInt(process.env.PR_NUMBER || '0');
16
+ const PASS_THRESHOLD = parseInt(process.env.PASS_THRESHOLD || '40');
17
+
18
+ const octokit = new Octokit({ auth: TOKEN });
19
+
20
+ const GRADE_SIGNATURE = '<!-- gitpadi-grade -->';
21
+
22
+ interface GradeCheck {
23
+ criteria: string;
24
+ score: number;
25
+ max: number;
26
+ detail: string;
27
+ }
28
+
29
+ function getLetterGrade(score: number): { letter: string; emoji: string } {
30
+ if (score >= 80) return { letter: 'A', emoji: '🟢' };
31
+ if (score >= 60) return { letter: 'B', emoji: 'šŸ”µ' };
32
+ if (score >= 40) return { letter: 'C', emoji: '🟔' };
33
+ if (score >= 20) return { letter: 'D', emoji: '🟠' };
34
+ return { letter: 'F', emoji: 'šŸ”“' };
35
+ }
36
+
37
+ // Extract assignment issue number from PR body or branch name
38
+ function detectAssignment(prBody: string, branchName: string): number | null {
39
+ // Check PR body: "Fixes #N", "Closes #N", "Assignment #N"
40
+ const bodyMatch = prBody.match(/(?:fixes|closes|resolves|assignment)\s*#(\d+)/i);
41
+ if (bodyMatch) return parseInt(bodyMatch[1]);
42
+
43
+ // Check branch: "assignment-3", "hw-3", "task-3"
44
+ const branchMatch = branchName.match(/(?:assignment|hw|task|fix\/issue)-(\d+)/i);
45
+ if (branchMatch) return parseInt(branchMatch[1]);
46
+
47
+ return null;
48
+ }
49
+
50
+ // Extract keywords from assignment issue body for relevance matching
51
+ function extractKeywords(issueBody: string): string[] {
52
+ const words = issueBody.toLowerCase()
53
+ .replace(/[#*`\[\]()]/g, ' ')
54
+ .split(/\s+/)
55
+ .filter(w => w.length > 3);
56
+
57
+ // Remove common stop words
58
+ const stopWords = new Set(['this', 'that', 'with', 'from', 'have', 'will', 'should', 'your', 'must',
59
+ 'need', 'make', 'using', 'create', 'please', 'assignment', 'task', 'following', 'requirements']);
60
+ return [...new Set(words.filter(w => !stopWords.has(w)))];
61
+ }
62
+
63
+ async function main(): Promise<void> {
64
+ if (!PR_NUMBER) {
65
+ console.error('āŒ PR_NUMBER is required');
66
+ process.exit(1);
67
+ }
68
+
69
+ console.log(`\nšŸ“ GitPadi Assignment Grader — ${OWNER}/${REPO} #${PR_NUMBER}\n`);
70
+
71
+ // Get PR details
72
+ const { data: pr } = await octokit.pulls.get({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
73
+ const student = pr.user?.login || 'unknown';
74
+ const branchName = pr.head.ref;
75
+ const prBody = pr.body || '';
76
+
77
+ console.log(` Student: @${student}`);
78
+ console.log(` Branch: ${branchName}`);
79
+
80
+ // Detect linked assignment
81
+ const assignmentNumber = detectAssignment(prBody, branchName);
82
+
83
+ if (!assignmentNumber) {
84
+ console.log(chalk.yellow('āš ļø No assignment issue detected. Skipping grading.'));
85
+ await octokit.issues.createComment({
86
+ owner: OWNER, repo: REPO, issue_number: PR_NUMBER,
87
+ 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}`
88
+ });
89
+ return;
90
+ }
91
+
92
+ console.log(` Assignment: #${assignmentNumber}\n`);
93
+
94
+ // Fetch assignment issue
95
+ let issueTitle = '';
96
+ let issueBody = '';
97
+ let issueLabels: string[] = [];
98
+ try {
99
+ const { data: issue } = await octokit.issues.get({ owner: OWNER, repo: REPO, issue_number: assignmentNumber });
100
+ issueTitle = issue.title;
101
+ issueBody = issue.body || '';
102
+ issueLabels = issue.labels.map((l: any) => typeof l === 'string' ? l : l.name || '');
103
+ } catch {
104
+ console.log(chalk.red(`āŒ Assignment issue #${assignmentNumber} not found.`));
105
+ return;
106
+ }
107
+
108
+ // Fetch PR files
109
+ const { data: files } = await octokit.pulls.listFiles({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
110
+ const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
111
+ const filenames = files.map(f => f.filename.toLowerCase());
112
+
113
+ // Fetch commits
114
+ const { data: commits } = await octokit.pulls.listCommits({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
115
+
116
+ const checks: GradeCheck[] = [];
117
+
118
+ // ── 1. CI Passing (25 points) ──────────────────────────────────────
119
+ const sha = pr.head.sha;
120
+ const { data: ciChecks } = await octokit.checks.listForRef({ owner: OWNER, repo: REPO, ref: sha });
121
+ const { data: statuses } = await octokit.repos.getCombinedStatusForRef({ owner: OWNER, repo: REPO, ref: sha });
122
+
123
+ const ciPassed = statuses.state === 'success' || (ciChecks.total_count > 0 && ciChecks.check_runs.every(c => c.conclusion === 'success'));
124
+ const ciPending = statuses.state === 'pending' || ciChecks.check_runs.some(c => c.status !== 'completed');
125
+ const ciFailed = statuses.state === 'failure' || ciChecks.check_runs.some(c => c.conclusion === 'failure');
126
+
127
+ if (ciPassed) {
128
+ checks.push({ criteria: 'CI Passing', score: 25, max: 25, detail: 'All checks green' });
129
+ } else if (ciPending) {
130
+ checks.push({ criteria: 'CI Passing', score: 10, max: 25, detail: 'Still running — will re-grade when complete' });
131
+ } else {
132
+ const failedNames = ciChecks.check_runs.filter(c => c.conclusion === 'failure').map(c => c.name).join(', ');
133
+ checks.push({ criteria: 'CI Passing', score: 0, max: 25, detail: `Failed: ${failedNames || 'status checks'}` });
134
+ }
135
+
136
+ // ── 2. Assignment Relevance (25 points) ────────────────────────────
137
+ const keywords = extractKeywords(issueBody);
138
+ const allFileContent = filenames.join(' ');
139
+ const keywordHits = keywords.filter(kw => allFileContent.includes(kw) || prBody.toLowerCase().includes(kw));
140
+ const relevanceRatio = keywords.length > 0 ? keywordHits.length / Math.min(keywords.length, 15) : 0;
141
+ const relevanceScore = Math.min(25, Math.round(relevanceRatio * 25));
142
+
143
+ checks.push({
144
+ criteria: 'Assignment Relevance',
145
+ score: relevanceScore,
146
+ max: 25,
147
+ detail: relevanceScore >= 20 ? 'Matches assignment scope' : relevanceScore >= 10 ? 'Partial match — some expected files/topics missing' : 'Low relevance to assignment requirements'
148
+ });
149
+
150
+ // ── 3. Test Coverage (20 points) ───────────────────────────────────
151
+ 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')));
152
+ const testFiles = files.filter(f => f.filename.includes('test') || f.filename.includes('spec'));
153
+
154
+ let testScore = 0;
155
+ if (testFiles.length > 0 && srcFiles.length > 0) {
156
+ const testRatio = testFiles.length / srcFiles.length;
157
+ testScore = testRatio >= 0.5 ? 20 : testRatio >= 0.25 ? 15 : 10;
158
+ } else if (testFiles.length > 0) {
159
+ testScore = 15;
160
+ } else if (srcFiles.length === 0) {
161
+ testScore = 10; // No source files changed, might be docs-only
162
+ }
163
+
164
+ checks.push({
165
+ criteria: 'Test Coverage',
166
+ score: testScore,
167
+ max: 20,
168
+ detail: testFiles.length > 0 ? `${testFiles.length} test file(s) included` : 'No test files in submission'
169
+ });
170
+
171
+ // ── 4. Code Quality (15 points) ────────────────────────────────────
172
+ let qualityScore = 15;
173
+ const qualityNotes: string[] = [];
174
+
175
+ // PR size
176
+ if (totalChanges > 1000) { qualityScore -= 5; qualityNotes.push('Very large PR'); }
177
+ else if (totalChanges > 500) { qualityScore -= 2; qualityNotes.push('Large PR'); }
178
+
179
+ // Commit messages — check for conventional format
180
+ const badCommits = commits.filter(c => {
181
+ const msg = c.commit.message.toLowerCase();
182
+ return msg.startsWith('update') || (msg.startsWith('fix') && msg.length < 10) || msg === 'wip';
183
+ });
184
+ if (badCommits.length > commits.length / 2) { qualityScore -= 3; qualityNotes.push('Vague commit messages'); }
185
+
186
+ // Sensitive files
187
+ const sensitivePatterns = ['.env', 'secret', 'password', 'key', 'credential'];
188
+ const hasSensitive = filenames.some(f => sensitivePatterns.some(p => f.includes(p)));
189
+ if (hasSensitive) { qualityScore -= 5; qualityNotes.push('Sensitive files detected'); }
190
+
191
+ qualityScore = Math.max(0, qualityScore);
192
+ checks.push({
193
+ criteria: 'Code Quality',
194
+ score: qualityScore,
195
+ max: 15,
196
+ detail: qualityNotes.length > 0 ? qualityNotes.join(', ') : 'Clean submission'
197
+ });
198
+
199
+ // ── 5. Submission Format (15 points) ────────────────────────────────
200
+ let formatScore = 0;
201
+
202
+ // Branch naming
203
+ const goodBranch = /^(assignment|hw|task|fix\/issue)-\d+/i.test(branchName);
204
+ if (goodBranch) { formatScore += 5; } else if (branchName !== 'main' && branchName !== 'master') { formatScore += 2; }
205
+
206
+ // Issue linked
207
+ const hasIssueRef = /(?:fixes|closes|resolves)\s+#\d+/i.test(prBody);
208
+ if (hasIssueRef) formatScore += 5;
209
+
210
+ // PR body not empty
211
+ if (prBody.trim().length > 20) formatScore += 5;
212
+ else if (prBody.trim().length > 0) formatScore += 2;
213
+
214
+ checks.push({
215
+ criteria: 'Submission Format',
216
+ score: formatScore,
217
+ max: 15,
218
+ detail: formatScore >= 12 ? 'Well-formatted submission' : formatScore >= 7 ? 'Acceptable format' : 'Missing branch naming, issue reference, or PR description'
219
+ });
220
+
221
+ // ── Calculate Final Grade ──────────────────────────────────────────
222
+ const totalScore = checks.reduce((sum, c) => sum + c.score, 0);
223
+ const { letter, emoji } = getLetterGrade(totalScore);
224
+ const passed = totalScore >= PASS_THRESHOLD;
225
+
226
+ console.log(`\n Total: ${totalScore}/100 — Grade ${letter} ${emoji}\n`);
227
+ checks.forEach(c => console.log(` ${c.score >= c.max * 0.7 ? 'āœ…' : c.score >= c.max * 0.4 ? 'āš ļø' : 'āŒ'} ${c.criteria}: ${c.score}/${c.max}`));
228
+
229
+ // ── Build Grade Card ───────────────────────────────────────────────
230
+ let body = `## ${emoji} GitPadi Assignment Grade — PR #${PR_NUMBER}\n\n`;
231
+ body += `**Student:** @${student}\n`;
232
+ body += `**Assignment:** ${issueTitle} (#${assignmentNumber})\n\n`;
233
+ body += `| Criteria | Score | Max |\n|----------|-------|-----|\n`;
234
+ checks.forEach(c => { body += `| ${c.criteria} | ${c.score} | ${c.max} |\n`; });
235
+ body += `| **Total** | **${totalScore}** | **100** |\n\n`;
236
+ body += `**Grade: ${letter}** ${emoji}\n\n`;
237
+
238
+ if (passed) {
239
+ body += `> āœ… **Passed** (threshold: ${PASS_THRESHOLD}/100). Auto-merging.\n`;
240
+ } else {
241
+ body += `> āŒ **Did not pass** (need ${PASS_THRESHOLD}/100). Please review the feedback above and re-submit.\n`;
242
+ }
243
+
244
+ // Feedback per check
245
+ body += `\n### Feedback\n\n`;
246
+ checks.forEach(c => {
247
+ const icon = c.score >= c.max * 0.7 ? 'āœ…' : c.score >= c.max * 0.4 ? 'āš ļø' : 'āŒ';
248
+ body += `- ${icon} **${c.criteria}** (${c.score}/${c.max}): ${c.detail}\n`;
249
+ });
250
+
251
+ body += `\n---\n_Graded by [GitPadi](https://github.com/Netwalls/contributor-agent) šŸ“_\n\n${GRADE_SIGNATURE}`;
252
+
253
+ // ── Post or Update Grade Card ──────────────────────────────────────
254
+ const { data: existingComments } = await octokit.issues.listComments({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER });
255
+ const existingGrade = existingComments.find(c => c.body?.includes(GRADE_SIGNATURE));
256
+
257
+ if (existingGrade) {
258
+ await octokit.issues.updateComment({ owner: OWNER, repo: REPO, comment_id: existingGrade.id, body });
259
+ console.log('āœ… Updated existing grade card');
260
+ } else {
261
+ await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, body });
262
+ console.log('āœ… Posted grade card');
263
+ }
264
+
265
+ // ── Auto-merge if passed ───────────────────────────────────────────
266
+ if (passed && ciPassed) {
267
+ try {
268
+ await octokit.pulls.merge({
269
+ owner: OWNER, repo: REPO, pull_number: PR_NUMBER,
270
+ merge_method: 'squash',
271
+ commit_title: `[Grade ${letter}] ${pr.title} (#${PR_NUMBER})`,
272
+ });
273
+ console.log(chalk.green(`āœ… PR #${PR_NUMBER} merged (Grade ${letter}).`));
274
+ } catch (e: any) {
275
+ console.log(chalk.yellow(`āš ļø Auto-merge failed: ${e.message}`));
276
+ }
277
+ } else if (!passed) {
278
+ console.log(chalk.red(`āŒ Grade ${letter} — below threshold. Not merging.`));
279
+ process.exit(1);
280
+ }
281
+ }
282
+
283
+ main().catch((e) => { console.error('Fatal:', e); process.exit(1); });
@@ -0,0 +1,159 @@
1
+ import { initGitHub, getOctokit, getOwner, getRepo } from './core/github.js';
2
+ import chalk from 'chalk';
3
+
4
+ // Tiered Escalation Thresholds (in hours)
5
+ const TIER_1_HOURS = 24; // Gentle reminder: "Please create a draft PR"
6
+ const TIER_2_HOURS = 48; // Warning: "Will be unassigned in 24h"
7
+ const TIER_3_HOURS = 72; // Auto-unassign
8
+
9
+ // Signature markers for each tier (prevents duplicate comments)
10
+ const SIG_TIER_1 = '<!-- gitpadi-reminder-24h -->';
11
+ const SIG_TIER_2 = '<!-- gitpadi-reminder-48h -->';
12
+ const SIG_TIER_3 = '<!-- gitpadi-unassigned -->';
13
+
14
+ async function run() {
15
+ console.log(chalk.bold('\nšŸš€ GitPadi Escalating Reminder Engine\n'));
16
+
17
+ try {
18
+ initGitHub();
19
+ const octokit = getOctokit();
20
+ const owner = getOwner();
21
+ const repo = getRepo();
22
+
23
+ if (!owner || !repo) {
24
+ console.error(chalk.red('āŒ Owner or Repo not found in environment/config.'));
25
+ process.exit(1);
26
+ }
27
+
28
+ console.log(chalk.dim(`šŸ”Ž Scanning ${owner}/${repo} for inactive contributors...\n`));
29
+
30
+ // 1. Fetch all open issues
31
+ const { data: issues } = await octokit.issues.listForRepo({
32
+ owner, repo, state: 'open', per_page: 100
33
+ });
34
+
35
+ const issuesWithAssignees = issues.filter(i => i.assignees && i.assignees.length > 0 && !i.pull_request);
36
+
37
+ if (issuesWithAssignees.length === 0) {
38
+ console.log(chalk.green('āœ… No open issues with assignees found.'));
39
+ return;
40
+ }
41
+
42
+ const now = new Date();
43
+ let remindCount = 0, warnCount = 0, unassignCount = 0;
44
+
45
+ for (const issue of issuesWithAssignees) {
46
+ const issueNumber = issue.number;
47
+ console.log(chalk.cyan(` ā–ø Issue #${issueNumber}: "${issue.title.substring(0, 50)}..."`));
48
+
49
+ // 2. Check for existing reminder comments
50
+ const { data: comments } = await octokit.issues.listComments({
51
+ owner, repo, issue_number: issueNumber
52
+ });
53
+
54
+ const hasTier1 = comments.some(c => c.body?.includes(SIG_TIER_1));
55
+ const hasTier2 = comments.some(c => c.body?.includes(SIG_TIER_2));
56
+ const hasTier3 = comments.some(c => c.body?.includes(SIG_TIER_3));
57
+
58
+ if (hasTier3) {
59
+ console.log(chalk.dim(` ā© Already processed (unassigned). Skipping.`));
60
+ continue;
61
+ }
62
+
63
+ // 3. Check for linked draft/open PRs
64
+ const { data: prs } = await octokit.pulls.list({
65
+ owner, repo, state: 'open', per_page: 100
66
+ });
67
+ const linkedPR = prs.find(pr =>
68
+ pr.body?.includes(`#${issueNumber}`) ||
69
+ pr.head.ref.includes(`issue-${issueNumber}`)
70
+ );
71
+
72
+ if (linkedPR) {
73
+ console.log(chalk.green(` āœ… PR found: #${linkedPR.number} (${linkedPR.draft ? 'draft' : 'open'}). Skipping.`));
74
+ continue;
75
+ }
76
+
77
+ // 4. Find when the user was assigned
78
+ const { data: events } = await octokit.issues.listEvents({
79
+ owner, repo, issue_number: issueNumber
80
+ });
81
+
82
+ const assignmentEvent = [...events].reverse().find(e => e.event === 'assigned') as any;
83
+ if (!assignmentEvent) {
84
+ console.log(chalk.dim(` ā“ No assignment event found. Skipping.`));
85
+ continue;
86
+ }
87
+
88
+ const assignedAt = new Date(assignmentEvent.created_at);
89
+ const diffHours = Math.floor((now.getTime() - assignedAt.getTime()) / (1000 * 60 * 60));
90
+ const assignee = assignmentEvent.assignee?.login;
91
+
92
+ console.log(chalk.dim(` šŸ•’ Assigned ${diffHours}h ago (@${assignee})`));
93
+
94
+ // 5. Tiered escalation
95
+ if (diffHours >= TIER_3_HOURS && !hasTier3) {
96
+ // ── TIER 3: Auto-unassign ──
97
+ const message = `ā° Hi @${assignee}, it's been **${diffHours} hours** since you were assigned to this issue, and no draft PR has been created.
98
+
99
+ To keep the project moving, we're **unassigning you** from this issue. You're welcome to re-claim it anytime!
100
+
101
+ ${SIG_TIER_3}`;
102
+
103
+ await octokit.issues.createComment({
104
+ owner, repo, issue_number: issueNumber, body: message
105
+ });
106
+
107
+ await octokit.issues.removeAssignees({
108
+ owner, repo, issue_number: issueNumber,
109
+ assignees: [assignee]
110
+ });
111
+
112
+ console.log(chalk.red(` šŸ”“ TIER 3: @${assignee} unassigned after ${diffHours}h.`));
113
+ unassignCount++;
114
+
115
+ } else if (diffHours >= TIER_2_HOURS && !hasTier2) {
116
+ // ── TIER 2: Warning ──
117
+ const message = `āš ļø Hi @${assignee}, it's been **${diffHours} hours** since you were assigned to this issue.
118
+
119
+ 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.
120
+
121
+ ${SIG_TIER_2}`;
122
+
123
+ await octokit.issues.createComment({
124
+ owner, repo, issue_number: issueNumber, body: message
125
+ });
126
+
127
+ console.log(chalk.yellow(` 🟔 TIER 2: Warning sent to @${assignee} (${diffHours}h).`));
128
+ warnCount++;
129
+
130
+ } else if (diffHours >= TIER_1_HOURS && !hasTier1) {
131
+ // ── TIER 1: Gentle reminder ──
132
+ const message = `šŸ‘‹ Hi @${assignee}, you've been assigned to this issue for **${diffHours} hours**.
133
+
134
+ Just a friendly nudge — please create a **draft PR** when you start working, so we know you're on it! šŸš€
135
+
136
+ ${SIG_TIER_1}`;
137
+
138
+ await octokit.issues.createComment({
139
+ owner, repo, issue_number: issueNumber, body: message
140
+ });
141
+
142
+ console.log(chalk.green(` 🟢 TIER 1: Gentle reminder sent to @${assignee} (${diffHours}h).`));
143
+ remindCount++;
144
+
145
+ } else {
146
+ console.log(chalk.dim(` ā³ Within threshold (${diffHours}h). No action needed.`));
147
+ }
148
+ }
149
+
150
+ console.log(chalk.bold(`\n✨ Reminder sweep complete.`));
151
+ console.log(chalk.dim(` šŸ“Š Reminded: ${remindCount} | Warned: ${warnCount} | Unassigned: ${unassignCount}\n`));
152
+
153
+ } catch (error: any) {
154
+ console.error(chalk.red(`\nāŒ Error during reminder sweep: ${error.message}`));
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ run();
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // review-and-merge.ts — Reviews a PR, checks CI, and auto-merges if all pass
3
+ //
4
+ // Usage via Action: action: review-and-merge
5
+ // Usage via CLI: npx tsx src/review-and-merge.ts
6
+
7
+ import { Octokit } from '@octokit/rest';
8
+ import chalk from 'chalk';
9
+
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 AUTO_MERGE = process.env.AUTO_MERGE !== 'false'; // default: true
15
+
16
+ const octokit = new Octokit({ auth: TOKEN });
17
+
18
+ interface CheckResult {
19
+ name: string;
20
+ status: 'āœ…' | 'āš ļø' | 'āŒ';
21
+ detail: string;
22
+ }
23
+
24
+ async function main(): Promise<void> {
25
+ if (!PR_NUMBER) {
26
+ console.error('āŒ PR_NUMBER is required');
27
+ process.exit(1);
28
+ }
29
+
30
+ console.log(`\nšŸ¤– GitPadi Review & Merge — ${OWNER}/${REPO} #${PR_NUMBER}\n`);
31
+
32
+ const { data: pr } = await octokit.pulls.get({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
33
+ const checks: CheckResult[] = [];
34
+
35
+ // ── 1. PR Size Check ──────────────────────────────────────────────
36
+ const { data: files } = await octokit.pulls.listFiles({ owner: OWNER, repo: REPO, pull_number: PR_NUMBER });
37
+ const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
38
+
39
+ if (totalChanges > 1000) {
40
+ checks.push({ name: 'PR Size', status: 'āŒ', detail: `${totalChanges} lines. Consider splitting.` });
41
+ } else if (totalChanges > 500) {
42
+ checks.push({ name: 'PR Size', status: 'āš ļø', detail: `${totalChanges} lines. Large PR.` });
43
+ } else {
44
+ checks.push({ name: 'PR Size', status: 'āœ…', detail: `${totalChanges} lines changed.` });
45
+ }
46
+
47
+ // ── 2. Linked Issues ──────────────────────────────────────────────
48
+ const hasIssueRef = /(?:fixes|closes|resolves)\s+#\d+/i.test(pr.body || '');
49
+ checks.push({
50
+ name: 'Linked Issues',
51
+ status: hasIssueRef ? 'āœ…' : 'āš ļø',
52
+ detail: hasIssueRef ? 'Issue reference found' : 'No issue reference (Fixes #N)',
53
+ });
54
+
55
+ // ── 3. Test Files ─────────────────────────────────────────────────
56
+ const srcFiles = files.filter(f => !f.filename.includes('test') && !f.filename.includes('spec') && (f.filename.endsWith('.ts') || f.filename.endsWith('.rs') || f.filename.endsWith('.js')));
57
+ const testFiles = files.filter(f => f.filename.includes('test') || f.filename.includes('spec'));
58
+ if (srcFiles.length > 0 && testFiles.length === 0) {
59
+ checks.push({ name: 'Test Coverage', status: 'āš ļø', detail: 'No test files changed.' });
60
+ } else {
61
+ checks.push({ name: 'Test Coverage', status: 'āœ…', detail: `${testFiles.length} test file(s) included.` });
62
+ }
63
+
64
+ // ── 4. CI Status ──────────────────────────────────────────────────
65
+ const sha = pr.head.sha;
66
+ const { data: ciChecks } = await octokit.checks.listForRef({ owner: OWNER, repo: REPO, ref: sha });
67
+ const { data: statuses } = await octokit.repos.getCombinedStatusForRef({ owner: OWNER, repo: REPO, ref: sha });
68
+
69
+ const ciPassed = statuses.state === 'success' || (ciChecks.total_count > 0 && ciChecks.check_runs.every(c => c.conclusion === 'success'));
70
+ const ciPending = statuses.state === 'pending' || ciChecks.check_runs.some(c => c.status !== 'completed');
71
+ const ciFailed = statuses.state === 'failure' || ciChecks.check_runs.some(c => c.conclusion === 'failure');
72
+
73
+ if (ciFailed) {
74
+ const failedNames = ciChecks.check_runs.filter(c => c.conclusion === 'failure').map(c => c.name).join(', ');
75
+ checks.push({ name: 'CI Status', status: 'āŒ', detail: `Failed: ${failedNames || 'status checks'}` });
76
+ } else if (ciPending) {
77
+ checks.push({ name: 'CI Status', status: 'āš ļø', detail: 'CI is still running...' });
78
+ } else {
79
+ checks.push({ name: 'CI Status', status: 'āœ…', detail: 'All checks passed.' });
80
+ }
81
+
82
+ // ── Build Review Comment ──────────────────────────────────────────
83
+ const hasCritical = checks.some(c => c.status === 'āŒ');
84
+ const hasWarning = checks.some(c => c.status === 'āš ļø');
85
+ const headerEmoji = hasCritical ? '🚨' : hasWarning ? 'āš ļø' : 'āœ…';
86
+
87
+ let body = `## ${headerEmoji} GitPadi — Automated PR Review\n\n`;
88
+ body += `| Check | Status | Details |\n|-------|--------|--------|\n`;
89
+ checks.forEach(c => { body += `| ${c.name} | ${c.status} | ${c.detail} |\n`; });
90
+ body += `\n`;
91
+
92
+ if (ciFailed) {
93
+ body += `> 🚨 **CI Failed.** Please fix the failing checks and push again. Use \`gitpadi\` → \`Fix & Re-push\` for a guided workflow.\n`;
94
+ } else if (hasCritical) {
95
+ body += `> 🚨 **Action Required:** Critical issues found. Please address before merging.\n`;
96
+ } else if (ciPassed && !hasCritical) {
97
+ body += `> āœ… **All checks passed.** ${AUTO_MERGE ? 'Auto-merging...' : 'Ready for manual merge.'}\n`;
98
+ }
99
+
100
+ body += `\n---\n_Review by [GitPadi](https://github.com/Netwalls/contributor-agent) šŸ¤–_`;
101
+
102
+ // Post the review comment
103
+ await octokit.issues.createComment({ owner: OWNER, repo: REPO, issue_number: PR_NUMBER, body });
104
+ console.log('āœ… Posted review comment');
105
+
106
+ // ── Auto-Merge Logic ──────────────────────────────────────────────
107
+ if (ciPassed && !hasCritical && AUTO_MERGE) {
108
+ console.log('šŸ”€ CI passed. Attempting auto-merge...');
109
+ try {
110
+ await octokit.pulls.merge({
111
+ owner: OWNER, repo: REPO, pull_number: PR_NUMBER,
112
+ merge_method: 'squash',
113
+ commit_title: `${pr.title} (#${PR_NUMBER})`,
114
+ });
115
+ console.log(chalk.green(`āœ… PR #${PR_NUMBER} merged successfully!`));
116
+
117
+ // Close any linked issues (handles non-default-branch merges too)
118
+ const issueRefs = (pr.body || '').matchAll(/(?:fixes|closes|resolves)\s+#(\d+)/gi);
119
+ for (const match of issueRefs) {
120
+ const issueNumber = parseInt(match[1]);
121
+ try {
122
+ await octokit.issues.update({ owner: OWNER, repo: REPO, issue_number: issueNumber, state: 'closed' });
123
+ await octokit.issues.createComment({
124
+ owner: OWNER, repo: REPO, issue_number: issueNumber,
125
+ body: `āœ… Closed by PR #${PR_NUMBER} — merged via GitPadi šŸ¤–`,
126
+ });
127
+ console.log(`āœ… Closed linked issue #${issueNumber}`);
128
+ } catch {
129
+ console.log(`āš ļø Could not close issue #${issueNumber}`);
130
+ }
131
+ }
132
+ } catch (e: any) {
133
+ console.log(chalk.yellow(`āš ļø Auto-merge failed: ${e.message}`));
134
+ }
135
+ } else if (ciFailed) {
136
+ console.log(chalk.red(`āŒ CI failed. Contributor notified to fix.`));
137
+ process.exit(1);
138
+ }
139
+
140
+ checks.forEach(c => console.log(` ${c.status} ${c.name}: ${c.detail}`));
141
+ }
142
+
143
+ main().catch((e) => { console.error('Fatal:', e); process.exit(1); });