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/.github/workflows/snipe-pr.yml +15 -0
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/__tests__/analyzer.test.ts +107 -0
- package/action.yml +39 -0
- package/dist/index.js +32287 -0
- package/jest.config.js +5 -0
- package/package.json +24 -0
- package/src/ai.ts +60 -0
- package/src/analyzer.ts +273 -0
- package/src/index.ts +156 -0
- package/tsconfig.json +19 -0
package/jest.config.js
ADDED
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
|
+
}
|
package/src/analyzer.ts
ADDED
|
@@ -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
|
+
}
|