@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/action.yml
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
name: 'AI Release Notes'
|
|
2
|
+
description: 'Auto-generate beautiful release notes from commits, PRs, and diffs using AI'
|
|
3
|
+
author: 'your-username'
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
github-token:
|
|
7
|
+
description: 'GitHub token for API access'
|
|
8
|
+
required: true
|
|
9
|
+
api-key:
|
|
10
|
+
description: 'OpenAI-compatible API key (optional — works without AI too)'
|
|
11
|
+
required: false
|
|
12
|
+
api-base:
|
|
13
|
+
description: 'API endpoint (supports any OpenAI-compatible API)'
|
|
14
|
+
required: false
|
|
15
|
+
default: 'https://api.openai.com/v1'
|
|
16
|
+
model:
|
|
17
|
+
description: 'AI model to use for natural language generation'
|
|
18
|
+
required: false
|
|
19
|
+
default: 'gpt-4o-mini'
|
|
20
|
+
template:
|
|
21
|
+
description: 'Release notes template: default, minimal, detailed, enterprise, fun, or custom path'
|
|
22
|
+
required: false
|
|
23
|
+
default: 'default'
|
|
24
|
+
commit-mode:
|
|
25
|
+
description: 'How to collect changes: commits, pull-requests, or auto'
|
|
26
|
+
required: false
|
|
27
|
+
default: 'auto'
|
|
28
|
+
version-from:
|
|
29
|
+
description: 'How to determine version: tag, package-json, or manual'
|
|
30
|
+
required: false
|
|
31
|
+
default: 'tag'
|
|
32
|
+
version:
|
|
33
|
+
description: 'Manual version (when version-from is manual)'
|
|
34
|
+
required: false
|
|
35
|
+
previous-tag:
|
|
36
|
+
description: 'Previous release tag (auto-detected if omitted)'
|
|
37
|
+
required: false
|
|
38
|
+
include-breaking:
|
|
39
|
+
description: 'Include BREAKING CHANGE section'
|
|
40
|
+
required: false
|
|
41
|
+
default: 'true'
|
|
42
|
+
include-contributors:
|
|
43
|
+
description: 'Include contributor thanks section'
|
|
44
|
+
required: false
|
|
45
|
+
default: 'true'
|
|
46
|
+
include-diff-stats:
|
|
47
|
+
description: 'Include file change statistics'
|
|
48
|
+
required: false
|
|
49
|
+
default: 'true'
|
|
50
|
+
include-screenshots:
|
|
51
|
+
description: 'Detect and include screenshot links from PRs'
|
|
52
|
+
required: false
|
|
53
|
+
default: 'true'
|
|
54
|
+
categories:
|
|
55
|
+
description: 'Custom categories as JSON array: [{"label":"Features","patterns":["feat","feature"]}]'
|
|
56
|
+
required: false
|
|
57
|
+
default: 'auto'
|
|
58
|
+
max-commits:
|
|
59
|
+
description: 'Maximum commits to analyze (prevents token overflow)'
|
|
60
|
+
required: false
|
|
61
|
+
default: '200'
|
|
62
|
+
language:
|
|
63
|
+
description: 'Output language: en, es, fr, de, ja, ko, zh, pt, ru'
|
|
64
|
+
required: false
|
|
65
|
+
default: 'en'
|
|
66
|
+
dry-run:
|
|
67
|
+
description: 'Generate notes but do not create release/post'
|
|
68
|
+
required: false
|
|
69
|
+
default: 'false'
|
|
70
|
+
slack-webhook:
|
|
71
|
+
description: 'Slack webhook URL (posts release notes to Slack)'
|
|
72
|
+
required: false
|
|
73
|
+
discord-webhook:
|
|
74
|
+
description: 'Discord webhook URL (posts release notes to Discord)'
|
|
75
|
+
required: false
|
|
76
|
+
twitter-consumer-key:
|
|
77
|
+
description: 'Twitter API consumer key (posts release tweet)'
|
|
78
|
+
required: false
|
|
79
|
+
twitter-consumer-secret:
|
|
80
|
+
description: 'Twitter API consumer secret'
|
|
81
|
+
required: false
|
|
82
|
+
twitter-access-token:
|
|
83
|
+
description: 'Twitter API access token'
|
|
84
|
+
required: false
|
|
85
|
+
twitter-access-secret:
|
|
86
|
+
description: 'Twitter API access token secret'
|
|
87
|
+
required: false
|
|
88
|
+
update-changelog:
|
|
89
|
+
description: 'Automatically update CHANGELOG.md'
|
|
90
|
+
required: false
|
|
91
|
+
default: 'false'
|
|
92
|
+
changelog-path:
|
|
93
|
+
description: 'Path to changelog file'
|
|
94
|
+
required: false
|
|
95
|
+
default: 'CHANGELOG.md'
|
|
96
|
+
|
|
97
|
+
outputs:
|
|
98
|
+
release-notes:
|
|
99
|
+
description: 'Generated release notes markdown'
|
|
100
|
+
release-url:
|
|
101
|
+
description: 'URL of the created GitHub Release'
|
|
102
|
+
version:
|
|
103
|
+
description: 'The version number used'
|
|
104
|
+
summary:
|
|
105
|
+
description: 'One-line summary of the release'
|
|
106
|
+
|
|
107
|
+
runs:
|
|
108
|
+
using: 'node20'
|
|
109
|
+
main: 'dist/index.js'
|
|
110
|
+
|
|
111
|
+
branding:
|
|
112
|
+
icon: 'file-text'
|
|
113
|
+
color: 'green'
|
package/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const core = require('@actions/core');
|
|
4
|
+
const logger = require('./src/logger');
|
|
5
|
+
const { createConfig } = require('./src/config');
|
|
6
|
+
const { generate } = require('./src/generator');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Entry point for the GitHub Action.
|
|
10
|
+
*/
|
|
11
|
+
async function run() {
|
|
12
|
+
try {
|
|
13
|
+
logger.info('AI Release Notes action started');
|
|
14
|
+
|
|
15
|
+
// Collect all inputs
|
|
16
|
+
const inputs = {
|
|
17
|
+
'github-token': core.getInput('github-token', { required: true }),
|
|
18
|
+
'api-key': core.getInput('api-key') || '',
|
|
19
|
+
'api-base': core.getInput('api-base') || 'https://api.openai.com/v1',
|
|
20
|
+
model: core.getInput('model') || 'gpt-4o-mini',
|
|
21
|
+
template: core.getInput('template') || 'default',
|
|
22
|
+
'commit-mode': core.getInput('commit-mode') || 'auto',
|
|
23
|
+
'version-from': core.getInput('version-from') || 'tag',
|
|
24
|
+
version: core.getInput('version') || '',
|
|
25
|
+
'previous-tag': core.getInput('previous-tag') || '',
|
|
26
|
+
'include-breaking': core.getInput('include-breaking') || 'true',
|
|
27
|
+
'include-contributors': core.getInput('include-contributors') || 'true',
|
|
28
|
+
'include-diff-stats': core.getInput('include-diff-stats') || 'true',
|
|
29
|
+
'include-screenshots': core.getInput('include-screenshots') || 'true',
|
|
30
|
+
categories: core.getInput('categories') || 'auto',
|
|
31
|
+
'max-commits': core.getInput('max-commits') || '200',
|
|
32
|
+
language: core.getInput('language') || 'en',
|
|
33
|
+
'dry-run': core.getInput('dry-run') || 'false',
|
|
34
|
+
'slack-webhook': core.getInput('slack-webhook') || '',
|
|
35
|
+
'discord-webhook': core.getInput('discord-webhook') || '',
|
|
36
|
+
'twitter-consumer-key': core.getInput('twitter-consumer-key') || '',
|
|
37
|
+
'twitter-consumer-secret': core.getInput('twitter-consumer-secret') || '',
|
|
38
|
+
'twitter-access-token': core.getInput('twitter-access-token') || '',
|
|
39
|
+
'twitter-access-secret': core.getInput('twitter-access-secret') || '',
|
|
40
|
+
'update-changelog': core.getInput('update-changelog') || 'false',
|
|
41
|
+
'changelog-path': core.getInput('changelog-path') || 'CHANGELOG.md',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Create validated config
|
|
45
|
+
const config = createConfig(inputs);
|
|
46
|
+
|
|
47
|
+
// Run the generator
|
|
48
|
+
const result = await generate(config);
|
|
49
|
+
|
|
50
|
+
// Set outputs
|
|
51
|
+
core.setOutput('release-notes', result.notes);
|
|
52
|
+
core.setOutput('release-url', result.url || '');
|
|
53
|
+
core.setOutput('version', result.version || '');
|
|
54
|
+
core.setOutput('summary', result.summary || '');
|
|
55
|
+
|
|
56
|
+
// Also set the release notes as the job summary
|
|
57
|
+
if (result.notes) {
|
|
58
|
+
await core.summary.addRaw(result.notes).write();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.info('AI Release Notes action completed successfully');
|
|
62
|
+
} catch (err) {
|
|
63
|
+
logger.error('Action failed', err);
|
|
64
|
+
core.setFailed(`AI Release Notes failed: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Run if called directly (not in test)
|
|
69
|
+
if (require.main === module) {
|
|
70
|
+
run();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { run };
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
testMatch: ['**/__tests__/**/*.test.js'],
|
|
6
|
+
collectCoverageFrom: ['src/**/*.js', '!src/logger.js'],
|
|
7
|
+
coverageThreshold: {
|
|
8
|
+
global: { branches: 20, functions: 20, lines: 20, statements: 20 },
|
|
9
|
+
},
|
|
10
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theihtisham/ai-release-notes",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Auto-generate beautiful release notes from commits, PRs, and diffs using AI",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "ncc build index.js -o dist --source-map",
|
|
8
|
+
"test": "jest --coverage",
|
|
9
|
+
"lint": "eslint src/",
|
|
10
|
+
"format": "prettier --write ."
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@actions/core": "^1.10.1",
|
|
14
|
+
"@actions/github": "^6.0.0",
|
|
15
|
+
"openai": "^4.67.0",
|
|
16
|
+
"minimatch": "^9.0.4",
|
|
17
|
+
"twitter-api-v2": "^1.16.0",
|
|
18
|
+
"node-fetch": "^3.3.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"jest": "^29.7.0",
|
|
22
|
+
"@vercel/ncc": "^0.38.3",
|
|
23
|
+
"eslint": "^8.57.0",
|
|
24
|
+
"prettier": "^3.3.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"github-action",
|
|
28
|
+
"release-notes",
|
|
29
|
+
"ai",
|
|
30
|
+
"changelog",
|
|
31
|
+
"automation"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/theihtisham/ai-release-notes.git"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/ai-writer.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('./logger');
|
|
4
|
+
const { getSystemPrompt, getUserPrompt } = require('./prompts');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate release notes using AI (OpenAI-compatible API).
|
|
8
|
+
* Falls back to template-based generation if AI fails or no API key.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} analysis - Commit analysis results
|
|
11
|
+
* @param {Object} diffSummary - Diff analysis results
|
|
12
|
+
* @param {string} version - Current version
|
|
13
|
+
* @param {Object} config - Configuration object
|
|
14
|
+
* @returns {string} Generated release notes markdown
|
|
15
|
+
*/
|
|
16
|
+
async function generateReleaseNotes(analysis, diffSummary, version, config) {
|
|
17
|
+
if (!config.hasAI || !config.apiKey) {
|
|
18
|
+
logger.info('No API key provided — using template-based generation');
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
logger.info(`Generating AI release notes with model ${config.model}`);
|
|
24
|
+
|
|
25
|
+
// Use dynamic import for OpenAI (ESM module)
|
|
26
|
+
let openaiModule;
|
|
27
|
+
try {
|
|
28
|
+
openaiModule = await import('openai');
|
|
29
|
+
} catch (importErr) {
|
|
30
|
+
logger.warn(`Failed to import OpenAI module: ${importErr.message}`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OpenAI = openaiModule.default || openaiModule.OpenAI;
|
|
35
|
+
|
|
36
|
+
const client = new OpenAI({
|
|
37
|
+
apiKey: config.apiKey,
|
|
38
|
+
baseURL: config.apiBase,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const systemPrompt = getSystemPrompt(config);
|
|
42
|
+
const userPrompt = getUserPrompt(analysis, diffSummary, version, config);
|
|
43
|
+
|
|
44
|
+
logger.debug('Sending request to AI API...');
|
|
45
|
+
|
|
46
|
+
const response = await client.chat.completions.create({
|
|
47
|
+
model: config.model,
|
|
48
|
+
messages: [
|
|
49
|
+
{ role: 'system', content: systemPrompt },
|
|
50
|
+
{ role: 'user', content: userPrompt },
|
|
51
|
+
],
|
|
52
|
+
temperature: 0.3,
|
|
53
|
+
max_tokens: 4000,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!response || !response.choices || response.choices.length === 0) {
|
|
57
|
+
logger.warn('AI returned empty response — falling back to template');
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const content = response.choices[0].message.content;
|
|
62
|
+
|
|
63
|
+
if (!content || content.trim().length === 0) {
|
|
64
|
+
logger.warn('AI returned empty content — falling back to template');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate: must be valid markdown with some content
|
|
69
|
+
if (!validateAIResponse(content)) {
|
|
70
|
+
logger.warn('AI response validation failed — falling back to template');
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
logger.info('AI release notes generated successfully');
|
|
75
|
+
return content;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger.error('AI generation failed — falling back to template', err);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate AI response content.
|
|
84
|
+
* Checks that the response is non-empty markdown with at least one section.
|
|
85
|
+
*/
|
|
86
|
+
function validateAIResponse(content) {
|
|
87
|
+
if (!content || typeof content !== 'string') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Must have at least 50 characters of content
|
|
92
|
+
if (content.trim().length < 50) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Must contain at least one markdown element (header, bullet, or link)
|
|
97
|
+
const hasMarkdown = /(^#{1,6}\s|^- |\*\s|[^!]\[.*\]\(.*\))/.test(content);
|
|
98
|
+
if (!hasMarkdown) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
generateReleaseNotes,
|
|
107
|
+
validateAIResponse,
|
|
108
|
+
};
|
package/src/analyzer.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CONVENTIONAL_COMMIT_REGEX, BREAKING_CHANGE_REGEX } = require('./constants');
|
|
4
|
+
const { categorizeCommits, deduplicateChanges } = require('./categorizer');
|
|
5
|
+
const { getContributors } = require('./contributor');
|
|
6
|
+
const logger = require('./logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Analyze commits and produce a structured analysis.
|
|
10
|
+
* @param {Array} commits - Array of commit objects from GitHub API
|
|
11
|
+
* @param {Object} config - Configuration object
|
|
12
|
+
* @returns {Object} Structured analysis with categories, breaking changes, contributors, scopes, stats
|
|
13
|
+
*/
|
|
14
|
+
function analyzeCommits(commits, config) {
|
|
15
|
+
logger.info(`Analyzing ${commits ? commits.length : 0} commits`);
|
|
16
|
+
|
|
17
|
+
if (!commits || commits.length === 0) {
|
|
18
|
+
return {
|
|
19
|
+
categories: {},
|
|
20
|
+
breaking: [],
|
|
21
|
+
contributors: [],
|
|
22
|
+
scopes: {},
|
|
23
|
+
stats: {
|
|
24
|
+
total_commits: 0,
|
|
25
|
+
total_prs: 0,
|
|
26
|
+
total_files_changed: 0,
|
|
27
|
+
additions: 0,
|
|
28
|
+
deletions: 0,
|
|
29
|
+
},
|
|
30
|
+
linkedIssues: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parsedCommits = commits.map(parseCommit).filter(Boolean);
|
|
35
|
+
const categories = categorizeCommits(parsedCommits, config.categories);
|
|
36
|
+
const dedupedCategories = deduplicateChanges(categories);
|
|
37
|
+
const breaking = extractBreakingChanges(parsedCommits);
|
|
38
|
+
const contributors = getContributors(commits, null);
|
|
39
|
+
const scopes = extractScopes(parsedCommits);
|
|
40
|
+
const stats = computeStats(parsedCommits);
|
|
41
|
+
const linkedIssues = extractLinkedIssues(parsedCommits);
|
|
42
|
+
|
|
43
|
+
logger.info(`Analysis complete: ${stats.total_commits} commits, ${Object.keys(dedupedCategories).length} categories, ${breaking.length} breaking changes`);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
categories: dedupedCategories,
|
|
47
|
+
breaking: breaking,
|
|
48
|
+
contributors: contributors,
|
|
49
|
+
scopes: scopes,
|
|
50
|
+
stats: stats,
|
|
51
|
+
linkedIssues: linkedIssues,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse a single commit into structured data.
|
|
57
|
+
*/
|
|
58
|
+
function parseCommit(commit) {
|
|
59
|
+
try {
|
|
60
|
+
const message = commit.message || (commit.commit && commit.commit.message) || '';
|
|
61
|
+
const match = message.match(CONVENTIONAL_COMMIT_REGEX);
|
|
62
|
+
|
|
63
|
+
if (!match) {
|
|
64
|
+
return {
|
|
65
|
+
...commit,
|
|
66
|
+
_type: 'other',
|
|
67
|
+
_scope: '',
|
|
68
|
+
_description: message,
|
|
69
|
+
_breaking: false,
|
|
70
|
+
_pr: null,
|
|
71
|
+
_body: '',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const type = match[1].toLowerCase();
|
|
76
|
+
const scope = match[3] || '';
|
|
77
|
+
const breakingBang = !!match[4];
|
|
78
|
+
const description = match[5] || '';
|
|
79
|
+
|
|
80
|
+
// Extract body (everything after first line)
|
|
81
|
+
const lines = message.split('\n');
|
|
82
|
+
const body = lines.slice(1).join('\n').trim();
|
|
83
|
+
|
|
84
|
+
// Check for BREAKING CHANGE in body
|
|
85
|
+
const breakingMatch = body.match(BREAKING_CHANGE_REGEX);
|
|
86
|
+
const hasBreakingFooter = !!breakingMatch;
|
|
87
|
+
const breakingDescription = breakingMatch ? breakingMatch[1] : '';
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
...commit,
|
|
91
|
+
_type: type,
|
|
92
|
+
_scope: scope,
|
|
93
|
+
_description: description,
|
|
94
|
+
_breaking: breakingBang || hasBreakingFooter,
|
|
95
|
+
_breakingDescription: breakingDescription,
|
|
96
|
+
_pr: extractPRNumber(message),
|
|
97
|
+
_body: body,
|
|
98
|
+
};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.debug(`Failed to parse commit: ${err.message}`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract PR number from commit message.
|
|
107
|
+
*/
|
|
108
|
+
function extractPRNumber(message) {
|
|
109
|
+
const match = message.match(/\(#(\d+)\)/);
|
|
110
|
+
return match ? parseInt(match[1], 10) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Extract breaking changes from parsed commits.
|
|
115
|
+
*/
|
|
116
|
+
function extractBreakingChanges(parsedCommits) {
|
|
117
|
+
const breaking = [];
|
|
118
|
+
|
|
119
|
+
for (const commit of parsedCommits) {
|
|
120
|
+
if (!commit) continue;
|
|
121
|
+
if (commit._breaking) {
|
|
122
|
+
breaking.push({
|
|
123
|
+
description: commit._breakingDescription || commit._description,
|
|
124
|
+
commit: commit.sha || '',
|
|
125
|
+
scope: commit._scope || '',
|
|
126
|
+
type: commit._type || '',
|
|
127
|
+
pr: commit._pr,
|
|
128
|
+
migration_guide: extractMigrationGuide(commit._body),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return breaking;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Try to extract migration guide from commit body.
|
|
138
|
+
*/
|
|
139
|
+
function extractMigrationGuide(body) {
|
|
140
|
+
if (!body) return '';
|
|
141
|
+
// Look for migration guidance patterns
|
|
142
|
+
const patterns = [
|
|
143
|
+
/Migration Guide:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
|
|
144
|
+
/How to migrate:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
|
|
145
|
+
/To migrate[\s\S]*?:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
for (const pattern of patterns) {
|
|
149
|
+
const match = body.match(pattern);
|
|
150
|
+
if (match) return match[1].trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract and count scopes from parsed commits.
|
|
158
|
+
*/
|
|
159
|
+
function extractScopes(parsedCommits) {
|
|
160
|
+
const scopes = {};
|
|
161
|
+
for (const commit of parsedCommits) {
|
|
162
|
+
if (!commit) continue;
|
|
163
|
+
const scope = commit._scope;
|
|
164
|
+
if (scope) {
|
|
165
|
+
scopes[scope] = (scopes[scope] || 0) + 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return scopes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compute aggregate stats from parsed commits.
|
|
173
|
+
*/
|
|
174
|
+
function computeStats(parsedCommits) {
|
|
175
|
+
let totalPrs = 0;
|
|
176
|
+
let totalFilesChanged = 0;
|
|
177
|
+
let additions = 0;
|
|
178
|
+
let deletions = 0;
|
|
179
|
+
|
|
180
|
+
for (const commit of parsedCommits) {
|
|
181
|
+
if (!commit) continue;
|
|
182
|
+
if (commit._pr) totalPrs++;
|
|
183
|
+
if (commit.files) totalFilesChanged += commit.files.length || commit.files;
|
|
184
|
+
if (commit.stats) {
|
|
185
|
+
additions += commit.stats.additions || 0;
|
|
186
|
+
deletions += commit.stats.deletions || 0;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
total_commits: parsedCommits.filter(Boolean).length,
|
|
192
|
+
total_prs: totalPrs,
|
|
193
|
+
total_files_changed: totalFilesChanged,
|
|
194
|
+
additions: additions,
|
|
195
|
+
deletions: deletions,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract linked issue numbers from commit messages.
|
|
201
|
+
*/
|
|
202
|
+
function extractLinkedIssues(parsedCommits) {
|
|
203
|
+
const issues = [];
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
|
|
206
|
+
for (const commit of parsedCommits) {
|
|
207
|
+
if (!commit) continue;
|
|
208
|
+
const message = commit.message || (commit.commit && commit.commit.message) || '';
|
|
209
|
+
// Match #123 patterns
|
|
210
|
+
const issueMatches = message.matchAll(/#(\d+)/g);
|
|
211
|
+
for (const match of issueMatches) {
|
|
212
|
+
const num = parseInt(match[1], 10);
|
|
213
|
+
// Skip if it's a PR number (detected by (#123) pattern)
|
|
214
|
+
if (commit._pr === num) continue;
|
|
215
|
+
if (!seen.has(num)) {
|
|
216
|
+
seen.add(num);
|
|
217
|
+
issues.push({ number: num });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return issues;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
analyzeCommits,
|
|
227
|
+
parseCommit,
|
|
228
|
+
extractBreakingChanges,
|
|
229
|
+
extractScopes,
|
|
230
|
+
computeStats,
|
|
231
|
+
extractLinkedIssues,
|
|
232
|
+
};
|