snipe-pr 1.0.0

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/jest.config.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ testMatch: ['**/__tests__/**/*.test.ts'],
5
+ };
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "snipe-pr",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered PR descriptions that write themselves",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "tsc && ncc build lib/index.js -o dist",
8
+ "test": "jest",
9
+ "all": "npm run build && npm test"
10
+ },
11
+ "dependencies": {
12
+ "@actions/core": "^1.11.1",
13
+ "@actions/github": "^6.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^22.0.0",
17
+ "@vercel/ncc": "^0.38.3",
18
+ "typescript": "^5.7.0",
19
+ "jest": "^29.7.0",
20
+ "@types/jest": "^29.5.0",
21
+ "ts-jest": "^29.2.0"
22
+ },
23
+ "license": "MIT"
24
+ }
package/src/ai.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { FileChange } from './analyzer';
2
+
3
+ const SNIPELINK_API = 'https://snipelink.com/api/snipe-pr/generate';
4
+
5
+ export interface AIDescriptionRequest {
6
+ title: string;
7
+ diff: string;
8
+ files: FileChange[];
9
+ repo: string;
10
+ }
11
+
12
+ export interface AIDescriptionResponse {
13
+ ok: boolean;
14
+ description?: string;
15
+ error?: string;
16
+ code?: string;
17
+ }
18
+
19
+ export async function generateAIDescription(
20
+ request: AIDescriptionRequest,
21
+ apiKey: string
22
+ ): Promise<AIDescriptionResponse> {
23
+ try {
24
+ const response = await fetch(SNIPELINK_API, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ Authorization: `Bearer ${apiKey}`,
29
+ },
30
+ body: JSON.stringify({
31
+ title: request.title,
32
+ diff: request.diff.substring(0, 15000),
33
+ files: request.files.map((f) => ({
34
+ filename: f.filename,
35
+ status: f.status,
36
+ additions: f.additions,
37
+ deletions: f.deletions,
38
+ })),
39
+ repo: request.repo,
40
+ }),
41
+ });
42
+
43
+ if (!response.ok) {
44
+ const body = (await response.json().catch(() => ({}))) as Record<string, string>;
45
+ return {
46
+ ok: false,
47
+ error: body.error || `API returned ${response.status}`,
48
+ code: body.code,
49
+ };
50
+ }
51
+
52
+ const data = (await response.json()) as { description: string };
53
+ return { ok: true, description: data.description };
54
+ } catch (err) {
55
+ return {
56
+ ok: false,
57
+ error: err instanceof Error ? err.message : 'Unknown error calling SnipeLink API',
58
+ };
59
+ }
60
+ }
@@ -0,0 +1,273 @@
1
+ export interface FileChange {
2
+ filename: string;
3
+ status: string;
4
+ additions: number;
5
+ deletions: number;
6
+ patch?: string;
7
+ }
8
+
9
+ export interface AnalysisResult {
10
+ summary: string;
11
+ changeType: ChangeType;
12
+ categories: CategoryGroup[];
13
+ stats: ChangeStats;
14
+ highlights: string[];
15
+ }
16
+
17
+ export interface CategoryGroup {
18
+ name: string;
19
+ emoji: string;
20
+ files: string[];
21
+ }
22
+
23
+ export interface ChangeStats {
24
+ filesChanged: number;
25
+ additions: number;
26
+ deletions: number;
27
+ netLines: number;
28
+ }
29
+
30
+ export type ChangeType =
31
+ | 'feature'
32
+ | 'bugfix'
33
+ | 'refactor'
34
+ | 'docs'
35
+ | 'test'
36
+ | 'config'
37
+ | 'style'
38
+ | 'mixed';
39
+
40
+ const FILE_CATEGORIES: Record<string, { name: string; emoji: string; patterns: RegExp[] }> = {
41
+ test: {
42
+ name: 'Tests',
43
+ emoji: '🧪',
44
+ patterns: [
45
+ /\.(test|spec)\.[jt]sx?$/,
46
+ /__(tests|mocks)__\//,
47
+ /test[s]?\//i,
48
+ /\.test\./,
49
+ ],
50
+ },
51
+ docs: {
52
+ name: 'Documentation',
53
+ emoji: '📝',
54
+ patterns: [/\.md$/i, /docs?\//i, /README/i, /CHANGELOG/i, /LICENSE/i],
55
+ },
56
+ config: {
57
+ name: 'Configuration',
58
+ emoji: '⚙️',
59
+ patterns: [
60
+ /\.(json|ya?ml|toml|ini|env)$/,
61
+ /\.config\.[jt]s$/,
62
+ /Dockerfile/,
63
+ /docker-compose/,
64
+ /\.github\//,
65
+ /\.eslint/,
66
+ /\.prettier/,
67
+ /tsconfig/,
68
+ ],
69
+ },
70
+ style: {
71
+ name: 'Styling',
72
+ emoji: '🎨',
73
+ patterns: [/\.(css|scss|sass|less|styled)\b/, /tailwind/, /\.svg$/],
74
+ },
75
+ migration: {
76
+ name: 'Database',
77
+ emoji: '🗄️',
78
+ patterns: [/migrat/i, /schema/i, /seed/i, /\.sql$/],
79
+ },
80
+ ci: {
81
+ name: 'CI/CD',
82
+ emoji: '🔄',
83
+ patterns: [/\.github\/workflows/, /\.gitlab-ci/, /Jenkinsfile/, /\.circleci/],
84
+ },
85
+ deps: {
86
+ name: 'Dependencies',
87
+ emoji: '📦',
88
+ patterns: [/package(-lock)?\.json$/, /yarn\.lock$/, /pnpm-lock/, /Gemfile/, /requirements.*\.txt$/, /go\.(mod|sum)$/],
89
+ },
90
+ api: {
91
+ name: 'API',
92
+ emoji: '🔌',
93
+ patterns: [/routes?\//i, /api\//i, /controllers?\//i, /handlers?\//i, /endpoints?\//i],
94
+ },
95
+ ui: {
96
+ name: 'UI Components',
97
+ emoji: '🖼️',
98
+ patterns: [/components?\//i, /pages?\//i, /views?\//i, /\.[jt]sx$/],
99
+ },
100
+ core: {
101
+ name: 'Core Logic',
102
+ emoji: '🔧',
103
+ patterns: [/src\//, /lib\//, /\.[jt]s$/],
104
+ },
105
+ };
106
+
107
+ function categorizeFile(filename: string): string {
108
+ for (const [key, cat] of Object.entries(FILE_CATEGORIES)) {
109
+ if (cat.patterns.some((p) => p.test(filename))) {
110
+ return key;
111
+ }
112
+ }
113
+ return 'core';
114
+ }
115
+
116
+ function detectChangeType(files: FileChange[], patches: string): ChangeType {
117
+ const categories = new Set(files.map((f) => categorizeFile(f.filename)));
118
+
119
+ if (categories.size === 1 && categories.has('test')) return 'test';
120
+ if (categories.size === 1 && categories.has('docs')) return 'docs';
121
+ if (categories.size === 1 && categories.has('config')) return 'config';
122
+ if (categories.size === 1 && categories.has('style')) return 'style';
123
+
124
+ const lowerPatch = patches.toLowerCase();
125
+ const bugSignals = ['fix', 'bug', 'patch', 'hotfix', 'issue', 'error', 'crash', 'broken'];
126
+ const featureSignals = ['feat', 'add', 'new', 'implement', 'create', 'introduce'];
127
+ const refactorSignals = ['refactor', 'rename', 'move', 'extract', 'simplify', 'clean'];
128
+
129
+ const bugScore = bugSignals.filter((s) => lowerPatch.includes(s)).length;
130
+ const featureScore = featureSignals.filter((s) => lowerPatch.includes(s)).length;
131
+ const refactorScore = refactorSignals.filter((s) => lowerPatch.includes(s)).length;
132
+
133
+ const maxScore = Math.max(bugScore, featureScore, refactorScore);
134
+ if (maxScore === 0) return 'mixed';
135
+ if (bugScore === maxScore) return 'bugfix';
136
+ if (featureScore === maxScore) return 'feature';
137
+ if (refactorScore === maxScore) return 'refactor';
138
+
139
+ return 'mixed';
140
+ }
141
+
142
+ function extractHighlights(files: FileChange[]): string[] {
143
+ const highlights: string[] = [];
144
+
145
+ const newFiles = files.filter((f) => f.status === 'added');
146
+ const deletedFiles = files.filter((f) => f.status === 'removed');
147
+ const renamedFiles = files.filter((f) => f.status === 'renamed');
148
+
149
+ if (newFiles.length > 0) {
150
+ highlights.push(
151
+ `Added ${newFiles.length} new file${newFiles.length > 1 ? 's' : ''}: ${newFiles
152
+ .slice(0, 3)
153
+ .map((f) => `\`${f.filename.split('/').pop()}\``)
154
+ .join(', ')}${newFiles.length > 3 ? ` and ${newFiles.length - 3} more` : ''}`
155
+ );
156
+ }
157
+
158
+ if (deletedFiles.length > 0) {
159
+ highlights.push(
160
+ `Removed ${deletedFiles.length} file${deletedFiles.length > 1 ? 's' : ''}`
161
+ );
162
+ }
163
+
164
+ if (renamedFiles.length > 0) {
165
+ highlights.push(
166
+ `Renamed ${renamedFiles.length} file${renamedFiles.length > 1 ? 's' : ''}`
167
+ );
168
+ }
169
+
170
+ const bigChanges = files
171
+ .filter((f) => f.additions + f.deletions > 100)
172
+ .sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
173
+
174
+ if (bigChanges.length > 0) {
175
+ const top = bigChanges[0];
176
+ highlights.push(
177
+ `Largest change: \`${top.filename.split('/').pop()}\` (+${top.additions}/-${top.deletions})`
178
+ );
179
+ }
180
+
181
+ return highlights;
182
+ }
183
+
184
+ const CHANGE_TYPE_LABELS: Record<ChangeType, { emoji: string; label: string }> = {
185
+ feature: { emoji: '✨', label: 'New Feature' },
186
+ bugfix: { emoji: '🐛', label: 'Bug Fix' },
187
+ refactor: { emoji: '♻️', label: 'Refactor' },
188
+ docs: { emoji: '📝', label: 'Documentation' },
189
+ test: { emoji: '🧪', label: 'Tests' },
190
+ config: { emoji: '⚙️', label: 'Configuration' },
191
+ style: { emoji: '🎨', label: 'Styling' },
192
+ mixed: { emoji: '🔀', label: 'Mixed Changes' },
193
+ };
194
+
195
+ export function analyzeChanges(files: FileChange[], diff: string): AnalysisResult {
196
+ const stats: ChangeStats = {
197
+ filesChanged: files.length,
198
+ additions: files.reduce((sum, f) => sum + f.additions, 0),
199
+ deletions: files.reduce((sum, f) => sum + f.deletions, 0),
200
+ netLines: files.reduce((sum, f) => sum + f.additions - f.deletions, 0),
201
+ };
202
+
203
+ const changeType = detectChangeType(files, diff);
204
+ const highlights = extractHighlights(files);
205
+
206
+ // Group files by category
207
+ const groups = new Map<string, string[]>();
208
+ for (const file of files) {
209
+ const cat = categorizeFile(file.filename);
210
+ if (!groups.has(cat)) groups.set(cat, []);
211
+ groups.get(cat)!.push(file.filename);
212
+ }
213
+
214
+ const categories: CategoryGroup[] = [];
215
+ for (const [key, filenames] of groups) {
216
+ const catDef = FILE_CATEGORIES[key];
217
+ if (catDef) {
218
+ categories.push({ name: catDef.name, emoji: catDef.emoji, files: filenames });
219
+ }
220
+ }
221
+
222
+ // Sort: largest groups first
223
+ categories.sort((a, b) => b.files.length - a.files.length);
224
+
225
+ const typeInfo = CHANGE_TYPE_LABELS[changeType];
226
+ const summary = `${typeInfo.emoji} **${typeInfo.label}** — ${stats.filesChanged} file${stats.filesChanged !== 1 ? 's' : ''} changed (+${stats.additions}/-${stats.deletions})`;
227
+
228
+ return { summary, changeType, categories, stats, highlights };
229
+ }
230
+
231
+ export function formatDescription(analysis: AnalysisResult, prTitle: string): string {
232
+ const lines: string[] = [];
233
+
234
+ lines.push(`## ${analysis.summary}`);
235
+ lines.push('');
236
+
237
+ // Highlights
238
+ if (analysis.highlights.length > 0) {
239
+ lines.push('### Highlights');
240
+ for (const h of analysis.highlights) {
241
+ lines.push(`- ${h}`);
242
+ }
243
+ lines.push('');
244
+ }
245
+
246
+ // File categories
247
+ lines.push('### Changes');
248
+ for (const cat of analysis.categories) {
249
+ const fileList = cat.files
250
+ .slice(0, 8)
251
+ .map((f) => `\`${f}\``)
252
+ .join(', ');
253
+ const more = cat.files.length > 8 ? ` and ${cat.files.length - 8} more` : '';
254
+ lines.push(`- ${cat.emoji} **${cat.name}**: ${fileList}${more}`);
255
+ }
256
+ lines.push('');
257
+
258
+ // Stats bar
259
+ const total = analysis.stats.additions + analysis.stats.deletions;
260
+ const addPct = total > 0 ? Math.round((analysis.stats.additions / total) * 20) : 0;
261
+ const delPct = 20 - addPct;
262
+ const bar = '🟩'.repeat(addPct) + '🟥'.repeat(delPct);
263
+ lines.push(`<sub>${bar} +${analysis.stats.additions} / -${analysis.stats.deletions} (net ${analysis.stats.netLines >= 0 ? '+' : ''}${analysis.stats.netLines})</sub>`);
264
+ lines.push('');
265
+
266
+ // Footer
267
+ lines.push('---');
268
+ lines.push(
269
+ '<sub>Generated by <a href="https://snipelink.com">Snipe PR</a> — auto PR descriptions for your team | <a href="https://snipelink.com/tools">Get AI-powered descriptions →</a></sub>'
270
+ );
271
+
272
+ return lines.join('\n');
273
+ }
package/src/index.ts ADDED
@@ -0,0 +1,156 @@
1
+ import * as core from '@actions/core';
2
+ import * as github from '@actions/github';
3
+ import { analyzeChanges, formatDescription, FileChange } from './analyzer';
4
+ import { generateAIDescription } from './ai';
5
+
6
+ const COMMENT_MARKER = '<!-- snipe-pr-description -->';
7
+
8
+ async function run(): Promise<void> {
9
+ try {
10
+ const token = core.getInput('github-token', { required: true });
11
+ const snipelinkKey = core.getInput('snipelink-key');
12
+ const mode = core.getInput('mode') || 'comment';
13
+ const includeStats = core.getInput('include-stats') !== 'false';
14
+ const maxDiffSize = parseInt(core.getInput('max-diff-size') || '10000', 10);
15
+
16
+ const octokit = github.getOctokit(token);
17
+ const { owner, repo } = github.context.repo;
18
+ const pr = github.context.payload.pull_request;
19
+
20
+ if (!pr) {
21
+ core.setFailed('This action only runs on pull_request events.');
22
+ return;
23
+ }
24
+
25
+ const pullNumber = pr.number;
26
+ const prTitle = pr.title || '';
27
+
28
+ core.info(`Analyzing PR #${pullNumber}: ${prTitle}`);
29
+
30
+ // Fetch diff
31
+ const { data: diffData } = await octokit.rest.pulls.get({
32
+ owner,
33
+ repo,
34
+ pull_number: pullNumber,
35
+ mediaType: { format: 'diff' },
36
+ });
37
+ const diff = (typeof diffData === 'string' ? diffData : String(diffData)).substring(
38
+ 0,
39
+ maxDiffSize
40
+ );
41
+
42
+ // Fetch changed files
43
+ const allFiles: FileChange[] = [];
44
+ let page = 1;
45
+ while (true) {
46
+ const { data: filesPage } = await octokit.rest.pulls.listFiles({
47
+ owner,
48
+ repo,
49
+ pull_number: pullNumber,
50
+ per_page: 100,
51
+ page,
52
+ });
53
+ if (filesPage.length === 0) break;
54
+ for (const f of filesPage) {
55
+ allFiles.push({
56
+ filename: f.filename,
57
+ status: f.status,
58
+ additions: f.additions,
59
+ deletions: f.deletions,
60
+ patch: f.patch,
61
+ });
62
+ }
63
+ if (filesPage.length < 100) break;
64
+ page++;
65
+ }
66
+
67
+ core.info(`Found ${allFiles.length} changed files`);
68
+
69
+ let description: string;
70
+
71
+ // Try AI-powered description if SnipeLink key provided
72
+ if (snipelinkKey) {
73
+ core.info('SnipeLink API key detected — generating AI-powered description...');
74
+ const aiResult = await generateAIDescription(
75
+ { title: prTitle, diff, files: allFiles, repo: `${owner}/${repo}` },
76
+ snipelinkKey
77
+ );
78
+
79
+ if (aiResult.ok && aiResult.description) {
80
+ description = aiResult.description;
81
+ description += '\n\n---';
82
+ description +=
83
+ '\n<sub>AI-powered by <a href="https://snipelink.com">Snipe PR Pro</a> — smarter PR descriptions for your team</sub>';
84
+ core.info('AI description generated successfully');
85
+ } else {
86
+ core.warning(
87
+ `AI generation failed (${aiResult.error}), falling back to template-based description`
88
+ );
89
+ const analysis = analyzeChanges(allFiles, diff);
90
+ description = formatDescription(analysis, prTitle);
91
+ }
92
+ } else {
93
+ // Free tier: template-based analysis
94
+ const analysis = analyzeChanges(allFiles, diff);
95
+ description = formatDescription(analysis, prTitle);
96
+ }
97
+
98
+ // Prepend marker for idempotent updates
99
+ const body = `${COMMENT_MARKER}\n${description}`;
100
+
101
+ if (mode === 'body') {
102
+ // Update PR body
103
+ await octokit.rest.pulls.update({
104
+ owner,
105
+ repo,
106
+ pull_number: pullNumber,
107
+ body: body,
108
+ });
109
+ core.info('Updated PR body with generated description');
110
+ } else {
111
+ // Post or update comment
112
+ const { data: comments } = await octokit.rest.issues.listComments({
113
+ owner,
114
+ repo,
115
+ issue_number: pullNumber,
116
+ per_page: 100,
117
+ });
118
+
119
+ const existing = comments.find(
120
+ (c) =>
121
+ c.user?.login === 'github-actions[bot]' && c.body?.includes(COMMENT_MARKER)
122
+ );
123
+
124
+ if (existing) {
125
+ await octokit.rest.issues.updateComment({
126
+ owner,
127
+ repo,
128
+ comment_id: existing.id,
129
+ body,
130
+ });
131
+ core.info(`Updated existing comment (ID: ${existing.id})`);
132
+ core.setOutput('comment-id', existing.id.toString());
133
+ } else {
134
+ const { data: newComment } = await octokit.rest.issues.createComment({
135
+ owner,
136
+ repo,
137
+ issue_number: pullNumber,
138
+ body,
139
+ });
140
+ core.info(`Posted new comment (ID: ${newComment.id})`);
141
+ core.setOutput('comment-id', newComment.id.toString());
142
+ }
143
+ }
144
+
145
+ core.setOutput('description', description);
146
+ core.info('Snipe PR completed successfully');
147
+ } catch (error) {
148
+ if (error instanceof Error) {
149
+ core.setFailed(error.message);
150
+ } else {
151
+ core.setFailed('An unexpected error occurred');
152
+ }
153
+ }
154
+ }
155
+
156
+ run();
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./lib",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist", "lib", "__tests__"]
19
+ }