@theihtisham/ai-release-notes 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/LICENSE +21 -0
- package/README.md +1493 -0
- package/__tests__/analyzer.test.js +63 -0
- package/__tests__/categorizer.test.js +93 -0
- package/__tests__/config.test.js +92 -0
- package/__tests__/formatter.test.js +63 -0
- package/__tests__/formatters.test.js +394 -0
- package/__tests__/migration.test.js +322 -0
- package/__tests__/semver.test.js +94 -0
- package/__tests__/tones.test.js +252 -0
- package/action.yml +113 -0
- package/index.js +73 -0
- package/jest.config.js +10 -0
- package/package.json +41 -0
- package/src/ai-writer.js +108 -0
- package/src/analyzer.js +232 -0
- package/src/analyzers/migration.js +355 -0
- package/src/categorizer.js +182 -0
- package/src/config.js +162 -0
- package/src/constants.js +137 -0
- package/src/contributor.js +144 -0
- package/src/diff-analyzer.js +202 -0
- package/src/formatter.js +336 -0
- package/src/formatters/discord.js +174 -0
- package/src/formatters/html.js +195 -0
- package/src/formatters/index.js +42 -0
- package/src/formatters/markdown.js +123 -0
- package/src/formatters/slack.js +176 -0
- package/src/formatters/twitter.js +242 -0
- package/src/formatters/types.js +48 -0
- package/src/generator.js +297 -0
- package/src/integrations/changelog.js +125 -0
- package/src/integrations/discord.js +96 -0
- package/src/integrations/github-release.js +75 -0
- package/src/integrations/indexer.js +119 -0
- package/src/integrations/slack.js +112 -0
- package/src/integrations/twitter.js +128 -0
- package/src/logger.js +52 -0
- package/src/prompts.js +210 -0
- package/src/rate-limiter.js +92 -0
- package/src/semver.js +129 -0
- package/src/tones/casual.js +114 -0
- package/src/tones/humorous.js +164 -0
- package/src/tones/index.js +38 -0
- package/src/tones/professional.js +125 -0
- package/src/tones/technical.js +164 -0
- package/src/tones/types.js +26 -0
package/src/generator.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const github = require('@actions/github');
|
|
4
|
+
const logger = require('./logger');
|
|
5
|
+
const { analyzeCommits } = require('./analyzer');
|
|
6
|
+
const { analyzeDiff, formatDiffStats } = require('./diff-analyzer');
|
|
7
|
+
const { generateReleaseNotes: aiGenerate } = require('./ai-writer');
|
|
8
|
+
const { formatReleaseNotes, generateSummary } = require('./formatter');
|
|
9
|
+
const { postToIntegrations } = require('./integrations/indexer');
|
|
10
|
+
const { parseVersion, isPrerelease } = require('./semver');
|
|
11
|
+
const { createGitHubRateLimiter } = require('./rate-limiter');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Main orchestrator — generate release notes and post to integrations.
|
|
15
|
+
* @param {Object} config - Validated configuration object
|
|
16
|
+
* @returns {Object} { notes, url, version, summary }
|
|
17
|
+
*/
|
|
18
|
+
async function generate(config) {
|
|
19
|
+
logger.info('Starting release notes generation');
|
|
20
|
+
|
|
21
|
+
const octokit = github.getOctokit(config.githubToken);
|
|
22
|
+
const context = github.context;
|
|
23
|
+
const rateLimiter = createGitHubRateLimiter();
|
|
24
|
+
|
|
25
|
+
// Set repo info on config for formatter
|
|
26
|
+
config.repoFullName = `${context.repo.owner}/${context.repo.repo}`;
|
|
27
|
+
config.repoUrl = `https://github.com/${config.repoFullName}`;
|
|
28
|
+
|
|
29
|
+
// Step 1: Determine version
|
|
30
|
+
const version = determineVersion(config, context);
|
|
31
|
+
logger.info(`Version: ${version}`);
|
|
32
|
+
|
|
33
|
+
// Step 2: Determine previous version
|
|
34
|
+
const previousVersion = await determinePreviousVersion(config, octokit, context, rateLimiter);
|
|
35
|
+
logger.info(`Previous version: ${previousVersion || 'none (first release)'}`);
|
|
36
|
+
|
|
37
|
+
// Step 3: Collect changes
|
|
38
|
+
const { commits, prs } = await collectChanges(
|
|
39
|
+
config, octokit, context, previousVersion, version, rateLimiter
|
|
40
|
+
);
|
|
41
|
+
logger.info(`Collected ${commits.length} commits and ${prs.length} PRs`);
|
|
42
|
+
|
|
43
|
+
// Step 4: Analyze changes
|
|
44
|
+
const analysis = analyzeCommits(commits, config);
|
|
45
|
+
logger.info(`Analysis: ${analysis.stats.total_commits} commits in ${Object.keys(analysis.categories).length} categories`);
|
|
46
|
+
|
|
47
|
+
// Step 5: Analyze diff (optional)
|
|
48
|
+
let diffSummary = null;
|
|
49
|
+
if (config.includeDiffStats) {
|
|
50
|
+
try {
|
|
51
|
+
await rateLimiter.wait();
|
|
52
|
+
diffSummary = await analyzeDiff(commits.slice(0, Math.min(commits.length, 50)), octokit, context);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger.warn('Diff analysis failed, continuing without it', err);
|
|
55
|
+
diffSummary = { files_changed: 0, additions: 0, deletions: 0, impact: 'low', affected_areas: [], potential_breaking: [], files: [], files_added: 0, files_modified: 0, files_deleted: 0 };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Step 6: Generate release notes (AI or template)
|
|
60
|
+
let notes;
|
|
61
|
+
if (config.hasAI) {
|
|
62
|
+
try {
|
|
63
|
+
const aiNotes = await aiGenerate(analysis, diffSummary, version, config);
|
|
64
|
+
if (aiNotes) {
|
|
65
|
+
notes = aiNotes;
|
|
66
|
+
logger.info('Using AI-generated release notes');
|
|
67
|
+
} else {
|
|
68
|
+
notes = formatReleaseNotes(analysis, diffSummary, version, previousVersion, config);
|
|
69
|
+
logger.info('AI failed or returned nothing — using template-based notes');
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logger.warn('AI generation threw error — falling back to template', err);
|
|
73
|
+
notes = formatReleaseNotes(analysis, diffSummary, version, previousVersion, config);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
notes = formatReleaseNotes(analysis, diffSummary, version, previousVersion, config);
|
|
77
|
+
logger.info('Using template-based release notes (no API key)');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Generate summary
|
|
81
|
+
const summary = generateSummary(analysis, version);
|
|
82
|
+
|
|
83
|
+
// Step 7: Post to integrations
|
|
84
|
+
const releaseData = {
|
|
85
|
+
version,
|
|
86
|
+
notes,
|
|
87
|
+
summary,
|
|
88
|
+
url: '',
|
|
89
|
+
contributors: analysis.contributors || [],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (!config.dryRun) {
|
|
93
|
+
try {
|
|
94
|
+
const integrationResults = await postToIntegrations(config, releaseData, octokit, context);
|
|
95
|
+
if (integrationResults.githubRelease && integrationResults.githubRelease.url) {
|
|
96
|
+
releaseData.url = integrationResults.githubRelease.url;
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.error('Integration posting failed (non-fatal)', err);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
logger.info('Dry run mode — skipping all integrations');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
logger.info(`Release notes generated for v${version}`);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
notes: releaseData.notes,
|
|
109
|
+
url: releaseData.url,
|
|
110
|
+
version: version,
|
|
111
|
+
summary: summary,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Determine the current version based on config.
|
|
117
|
+
*/
|
|
118
|
+
function determineVersion(config, context) {
|
|
119
|
+
switch (config.versionFrom) {
|
|
120
|
+
case 'tag': {
|
|
121
|
+
const ref = process.env.GITHUB_REF || '';
|
|
122
|
+
const tagMatch = ref.match(/refs\/tags\/v?(.+)/);
|
|
123
|
+
if (tagMatch) {
|
|
124
|
+
return tagMatch[1];
|
|
125
|
+
}
|
|
126
|
+
// Try to get from context payload
|
|
127
|
+
if (context.payload.release && context.payload.release.tag_name) {
|
|
128
|
+
return context.payload.release.tag_name.replace(/^v/, '');
|
|
129
|
+
}
|
|
130
|
+
logger.warn('Could not determine version from tag');
|
|
131
|
+
return '0.0.0';
|
|
132
|
+
}
|
|
133
|
+
case 'package-json': {
|
|
134
|
+
try {
|
|
135
|
+
const fs = require('fs');
|
|
136
|
+
const path = require('path');
|
|
137
|
+
const pkgPath = path.join(process.env.GITHUB_WORKSPACE || process.cwd(), 'package.json');
|
|
138
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
139
|
+
return pkg.version || '0.0.0';
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.warn('Could not read version from package.json', err);
|
|
142
|
+
return '0.0.0';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
case 'manual':
|
|
146
|
+
return config.version || '0.0.0';
|
|
147
|
+
default:
|
|
148
|
+
return '0.0.0';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Determine the previous version/tag.
|
|
154
|
+
*/
|
|
155
|
+
async function determinePreviousVersion(config, octokit, context, rateLimiter) {
|
|
156
|
+
// Manual override
|
|
157
|
+
if (config.previousTag) {
|
|
158
|
+
return config.previousTag.replace(/^v/, '');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Auto-detect from GitHub releases
|
|
162
|
+
try {
|
|
163
|
+
await rateLimiter.wait();
|
|
164
|
+
const { data: releases } = await octokit.rest.repos.listReleases({
|
|
165
|
+
owner: context.repo.owner,
|
|
166
|
+
repo: context.repo.repo,
|
|
167
|
+
per_page: 10,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const release of releases) {
|
|
171
|
+
const tag = release.tag_name.replace(/^v/, '');
|
|
172
|
+
if (!isPrerelease(tag) || release.prerelease) {
|
|
173
|
+
// Skip prereleases for finding previous stable
|
|
174
|
+
if (!release.prerelease) {
|
|
175
|
+
return tag;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// If no stable release found, use the latest release of any type
|
|
181
|
+
if (releases.length > 0) {
|
|
182
|
+
return releases[0].tag_name.replace(/^v/, '');
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
logger.debug(`Could not auto-detect previous version: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Collect changes (commits and/or PRs) between versions.
|
|
193
|
+
*/
|
|
194
|
+
async function collectChanges(config, octokit, context, previousVersion, currentVersion, rateLimiter) {
|
|
195
|
+
const owner = context.repo.owner;
|
|
196
|
+
const repo = context.repo.repo;
|
|
197
|
+
let commits = [];
|
|
198
|
+
let prs = [];
|
|
199
|
+
|
|
200
|
+
const commitMode = config.commitMode;
|
|
201
|
+
|
|
202
|
+
// Determine if we should use PR mode
|
|
203
|
+
let usePRs = false;
|
|
204
|
+
if (commitMode === 'pull-requests') {
|
|
205
|
+
usePRs = true;
|
|
206
|
+
} else if (commitMode === 'auto') {
|
|
207
|
+
// Check if there are PRs between the versions
|
|
208
|
+
try {
|
|
209
|
+
await rateLimiter.wait();
|
|
210
|
+
const base = previousVersion ? `v${previousVersion}` : undefined;
|
|
211
|
+
const head = `v${currentVersion}`;
|
|
212
|
+
if (base) {
|
|
213
|
+
const { data: compareData } = await octokit.rest.repos.compareCommits({
|
|
214
|
+
owner,
|
|
215
|
+
repo,
|
|
216
|
+
base,
|
|
217
|
+
head,
|
|
218
|
+
});
|
|
219
|
+
// If commits have PR numbers, prefer PR mode
|
|
220
|
+
const commitsWithPRs = (compareData.commits || []).filter(
|
|
221
|
+
c => c.commit.message.match(/\(#\d+\)/)
|
|
222
|
+
);
|
|
223
|
+
usePRs = commitsWithPRs.length > (compareData.commits || []).length * 0.3;
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
logger.debug(`Could not determine auto mode: ${err.message}`);
|
|
227
|
+
usePRs = false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (usePRs) {
|
|
232
|
+
// Collect merged PRs between versions
|
|
233
|
+
try {
|
|
234
|
+
await rateLimiter.wait();
|
|
235
|
+
const prQuery = previousVersion
|
|
236
|
+
? `is:pr is:merged repo:${owner}/${repo} base:main merged:>="${new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}"`
|
|
237
|
+
: `is:pr is:merged repo:${owner}/${repo}`;
|
|
238
|
+
|
|
239
|
+
// Use search API for PRs
|
|
240
|
+
const { data: searchResult } = await octokit.rest.search.issuesAndPullRequests({
|
|
241
|
+
q: prQuery,
|
|
242
|
+
sort: 'updated',
|
|
243
|
+
order: 'desc',
|
|
244
|
+
per_page: Math.min(config.maxCommits, 100),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
prs = searchResult.items || [];
|
|
248
|
+
commits = prs.map(pr => ({
|
|
249
|
+
sha: pr.merge_commit_sha || '',
|
|
250
|
+
message: pr.title || '',
|
|
251
|
+
author: pr.user || {},
|
|
252
|
+
url: pr.html_url,
|
|
253
|
+
_pr: pr.number,
|
|
254
|
+
}));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
logger.warn(`PR collection failed, falling back to commits: ${err.message}`);
|
|
257
|
+
usePRs = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!usePRs) {
|
|
262
|
+
// Collect commits between versions
|
|
263
|
+
try {
|
|
264
|
+
if (previousVersion) {
|
|
265
|
+
await rateLimiter.wait();
|
|
266
|
+
const { data: compareData } = await octokit.rest.repos.compareCommits({
|
|
267
|
+
owner,
|
|
268
|
+
repo,
|
|
269
|
+
base: `v${previousVersion}`,
|
|
270
|
+
head: `v${currentVersion}`,
|
|
271
|
+
});
|
|
272
|
+
commits = (compareData.commits || []).slice(0, config.maxCommits);
|
|
273
|
+
} else {
|
|
274
|
+
// No previous version — get recent commits
|
|
275
|
+
await rateLimiter.wait();
|
|
276
|
+
const { data: commitList } = await octokit.rest.repos.listCommits({
|
|
277
|
+
owner,
|
|
278
|
+
repo,
|
|
279
|
+
per_page: Math.min(config.maxCommits, 100),
|
|
280
|
+
});
|
|
281
|
+
commits = commitList;
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
logger.warn(`Commit collection failed: ${err.message}`);
|
|
285
|
+
commits = [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Handle no changes
|
|
290
|
+
if (commits.length === 0 && prs.length === 0) {
|
|
291
|
+
logger.info('No changes found between versions');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { commits, prs };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { generate, determineVersion, determinePreviousVersion, collectChanges };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('../logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Update CHANGELOG.md with new release entry.
|
|
7
|
+
* @param {Object} octokit - Octokit instance
|
|
8
|
+
* @param {Object} context - GitHub Actions context
|
|
9
|
+
* @param {Object} config - Configuration object
|
|
10
|
+
* @param {string} notes - Release notes body
|
|
11
|
+
* @param {string} version - Version string
|
|
12
|
+
* @returns {Object} { success, message }
|
|
13
|
+
*/
|
|
14
|
+
async function updateChangelog(octokit, context, config, notes, version) {
|
|
15
|
+
try {
|
|
16
|
+
const owner = context.repo.owner;
|
|
17
|
+
const repo = context.repo.repo;
|
|
18
|
+
const changelogPath = config.changelogPath || 'CHANGELOG.md';
|
|
19
|
+
const date = new Date().toISOString().split('T')[0];
|
|
20
|
+
|
|
21
|
+
logger.info(`Updating ${changelogPath} with v${version}`);
|
|
22
|
+
|
|
23
|
+
// Read existing changelog
|
|
24
|
+
let existingContent = '';
|
|
25
|
+
let sha = null;
|
|
26
|
+
try {
|
|
27
|
+
const { data } = await octokit.rest.repos.getContent({
|
|
28
|
+
owner,
|
|
29
|
+
repo,
|
|
30
|
+
path: changelogPath,
|
|
31
|
+
ref: context.payload.ref || 'HEAD',
|
|
32
|
+
});
|
|
33
|
+
existingContent = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
34
|
+
sha = data.sha;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.status === 404) {
|
|
37
|
+
logger.info('No existing CHANGELOG.md — creating new one');
|
|
38
|
+
existingContent = '# Changelog\n\n';
|
|
39
|
+
sha = null;
|
|
40
|
+
} else {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build new entry
|
|
46
|
+
const newEntry = buildChangelogEntry(version, date, notes);
|
|
47
|
+
|
|
48
|
+
// Prepend new entry after the header
|
|
49
|
+
const updatedContent = prependEntry(existingContent, newEntry);
|
|
50
|
+
|
|
51
|
+
// Commit and push
|
|
52
|
+
const commitMessage = `docs(changelog): update for v${version}`;
|
|
53
|
+
|
|
54
|
+
await octokit.rest.repos.createOrUpdateFileContents({
|
|
55
|
+
owner,
|
|
56
|
+
repo,
|
|
57
|
+
path: changelogPath,
|
|
58
|
+
message: commitMessage,
|
|
59
|
+
content: Buffer.from(updatedContent).toString('base64'),
|
|
60
|
+
sha: sha,
|
|
61
|
+
branch: context.payload.ref ? context.payload.ref.replace('refs/heads/', '') : 'main',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
logger.info(`Changelog updated: ${changelogPath}`);
|
|
65
|
+
return { success: true, message: `Updated ${changelogPath}` };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.error('Failed to update changelog', err);
|
|
68
|
+
return { success: false, message: err.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a structured changelog entry from release notes.
|
|
74
|
+
*/
|
|
75
|
+
function buildChangelogEntry(version, date, notes) {
|
|
76
|
+
const lines = [];
|
|
77
|
+
lines.push(`## [${version}] - ${date}`);
|
|
78
|
+
lines.push('');
|
|
79
|
+
|
|
80
|
+
if (notes) {
|
|
81
|
+
// Parse notes into structured sections
|
|
82
|
+
const noteLines = notes.split('\n');
|
|
83
|
+
for (const line of noteLines) {
|
|
84
|
+
// Skip h1 headers and full changelog links
|
|
85
|
+
if (line.match(/^# /)) continue;
|
|
86
|
+
if (line.match(/\*\*Full Changelog\*\*/)) continue;
|
|
87
|
+
lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
lines.push('');
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Prepend a new entry to the existing changelog content.
|
|
97
|
+
* Inserts after the first header (# Changelog) if present.
|
|
98
|
+
*/
|
|
99
|
+
function prependEntry(existingContent, newEntry) {
|
|
100
|
+
const lines = existingContent.split('\n');
|
|
101
|
+
let insertIndex = 0;
|
|
102
|
+
|
|
103
|
+
// Find the first header
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
if (lines[i].match(/^#\s+/)) {
|
|
106
|
+
// Skip the header and any trailing blank lines
|
|
107
|
+
insertIndex = i + 1;
|
|
108
|
+
while (insertIndex < lines.length && lines[insertIndex].trim() === '') {
|
|
109
|
+
insertIndex++;
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Insert new entry
|
|
116
|
+
lines.splice(insertIndex, 0, newEntry);
|
|
117
|
+
|
|
118
|
+
// Clean up excessive blank lines
|
|
119
|
+
let result = lines.join('\n');
|
|
120
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { updateChangelog, buildChangelogEntry, prependEntry };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('../logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Post release notes to Discord via webhook.
|
|
7
|
+
* @param {string} webhookUrl - Discord webhook URL
|
|
8
|
+
* @param {Object} releaseData - { version, notes, summary, url, contributors }
|
|
9
|
+
* @returns {Object} { success, message }
|
|
10
|
+
*/
|
|
11
|
+
async function postToDiscord(webhookUrl, releaseData) {
|
|
12
|
+
try {
|
|
13
|
+
if (!webhookUrl) {
|
|
14
|
+
logger.debug('No Discord webhook provided — skipping');
|
|
15
|
+
return { success: false, message: 'No webhook URL' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { version, summary, url, contributors } = releaseData;
|
|
19
|
+
const topChanges = extractTopChangesDiscord(releaseData.notes, 5);
|
|
20
|
+
|
|
21
|
+
const fields = [
|
|
22
|
+
{
|
|
23
|
+
name: 'Version',
|
|
24
|
+
value: `v${version}`,
|
|
25
|
+
inline: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'Date',
|
|
29
|
+
value: new Date().toISOString().split('T')[0],
|
|
30
|
+
inline: true,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (contributors && contributors.length > 0) {
|
|
35
|
+
fields.push({
|
|
36
|
+
name: 'Contributors',
|
|
37
|
+
value: contributors.slice(0, 5).map(c => `@${c.login}`).join(', ') +
|
|
38
|
+
(contributors.length > 5 ? ` and ${contributors.length - 5} more` : ''),
|
|
39
|
+
inline: false,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let description = summary || `Version ${version} has been released!`;
|
|
44
|
+
if (topChanges.length > 0) {
|
|
45
|
+
description += '\n\n**Key Changes:**\n' + topChanges.map(c => ` - ${c}`).join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const embed = {
|
|
49
|
+
title: `New Release: v${version}`,
|
|
50
|
+
url: url || '',
|
|
51
|
+
color: 0x00ff00, // Green
|
|
52
|
+
description: description.substring(0, 2048), // Discord limit
|
|
53
|
+
fields: fields,
|
|
54
|
+
footer: {
|
|
55
|
+
text: 'Powered by AI Release Notes',
|
|
56
|
+
},
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const response = await fetch(webhookUrl, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
embeds: [embed],
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const text = await response.text();
|
|
70
|
+
throw new Error(`Discord API returned ${response.status}: ${text}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
logger.info('Discord notification sent successfully');
|
|
74
|
+
return { success: true, message: 'Posted to Discord' };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logger.error('Failed to post to Discord', err);
|
|
77
|
+
return { success: false, message: err.message };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract top N changes from release notes for Discord.
|
|
83
|
+
*/
|
|
84
|
+
function extractTopChangesDiscord(notes, count) {
|
|
85
|
+
if (!notes) return [];
|
|
86
|
+
|
|
87
|
+
const lines = notes.split('\n');
|
|
88
|
+
const bullets = lines
|
|
89
|
+
.filter(line => line.match(/^-\s+/))
|
|
90
|
+
.map(line => line.replace(/^-\s+/, '').trim())
|
|
91
|
+
.filter(line => line.length > 0 && line.length < 150);
|
|
92
|
+
|
|
93
|
+
return bullets.slice(0, count);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { postToDiscord };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('../logger');
|
|
4
|
+
const { isPrerelease } = require('../semver');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create or update a GitHub Release.
|
|
8
|
+
* @param {Object} octokit - Octokit instance
|
|
9
|
+
* @param {Object} context - GitHub Actions context
|
|
10
|
+
* @param {string} version - Version string
|
|
11
|
+
* @param {string} notes - Release notes body
|
|
12
|
+
* @param {Object} config - Configuration object
|
|
13
|
+
* @returns {Object} { url, created }
|
|
14
|
+
*/
|
|
15
|
+
async function createRelease(octokit, context, version, notes, config) {
|
|
16
|
+
try {
|
|
17
|
+
const owner = context.repo.owner;
|
|
18
|
+
const repo = context.repo.repo;
|
|
19
|
+
const tag = `v${version}`;
|
|
20
|
+
const prerelease = isPrerelease(version);
|
|
21
|
+
|
|
22
|
+
logger.info(`Creating GitHub Release for ${tag} (prerelease=${prerelease})`);
|
|
23
|
+
|
|
24
|
+
// Check if release already exists for this tag
|
|
25
|
+
let existingRelease = null;
|
|
26
|
+
try {
|
|
27
|
+
const { data } = await octokit.rest.repos.getReleaseByTag({
|
|
28
|
+
owner,
|
|
29
|
+
repo,
|
|
30
|
+
tag,
|
|
31
|
+
});
|
|
32
|
+
existingRelease = data;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
// 404 means no existing release — expected for new releases
|
|
35
|
+
if (err.status !== 404) {
|
|
36
|
+
logger.debug(`Error checking existing release: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (existingRelease) {
|
|
41
|
+
// Update existing release
|
|
42
|
+
logger.info(`Updating existing release for ${tag}`);
|
|
43
|
+
const { data: updated } = await octokit.rest.repos.updateRelease({
|
|
44
|
+
owner,
|
|
45
|
+
repo,
|
|
46
|
+
release_id: existingRelease.id,
|
|
47
|
+
body: notes,
|
|
48
|
+
draft: false,
|
|
49
|
+
prerelease: prerelease,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
logger.info(`Release updated: ${updated.html_url}`);
|
|
53
|
+
return { url: updated.html_url, created: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create new release
|
|
57
|
+
const { data: release } = await octokit.rest.repos.createRelease({
|
|
58
|
+
owner,
|
|
59
|
+
repo,
|
|
60
|
+
tag_name: tag,
|
|
61
|
+
name: tag,
|
|
62
|
+
body: notes,
|
|
63
|
+
draft: false,
|
|
64
|
+
prerelease: prerelease,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
logger.info(`Release created: ${release.html_url}`);
|
|
68
|
+
return { url: release.html_url, created: true };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger.error('Failed to create/update GitHub Release', err);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { createRelease };
|