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.
- 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 +1045 -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/drips.js +351 -0
- 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 +1078 -34
- 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/drips.ts +408 -0
- 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,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); });
|