diff --git a/README.md b/README.md index 47095f1..03f96a0 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ Depending on the use-case additional settings can be provided to the action | `fetchReleaseInformation` | Will enable fetching additional release information from tags. Default: false | | `fetchReviews` | Will enable fetching the reviews on of the PR. Default: false | | `mode` | Defines the mode used to retrieve the information. Available options: [`PR`, `COMMIT`, `HYBRID`]. Defaults to `PR`. Hybrid mode treats commits like pull requests. Commit mode is a special configuration for projects which work without PRs. Uses commit messages as changelog. This mode looses access to information only available for PRs. Formerly set as `commitMode: true`, this setting is now deprecated and should be converted to `mode: "COMMIT"`. Note: the commit or hybrid modes are not fully supported. | +| `offlineMode` | [EXPERIMENTAL] Enables offline mode which disables API requests to GitHub or Gitea. Only works with commitMode and retrieves tags and diffs from the local repository. Default: false | | `exportCache` | Will enable exporting the fetched PR information to a cache, which can be re-used by later runs. Default: false | | `exportOnly` | When enabled, will result in only exporting the cache, without generating a changelog. Default: false (Requires `exportCache` to be enabled) | | `cache` | The file path to write/read the cache to/from. | diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 288e4ba..9301ff5 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -5,6 +5,9 @@ import * as fs from 'fs' import {clear} from '../src/transform.js' import {jest} from '@jest/globals' import { fileURLToPath } from 'url'; +import {mergeConfiguration, resolveConfiguration} from '../src/utils.js' +import {ReleaseNotesBuilder} from '../src/releaseNotesBuilder.js' +import {OfflineRepository} from '../src/repositories/OfflineRepository.js' jest.setTimeout(180000) clear() @@ -71,3 +74,34 @@ test('should write result to file', () => { expect(readOutput.toString()).not.toBe('') }) + +test('offline mode should work with commit mode', () => { + // This test verifies that the offlineMode parameter is correctly passed to the configuration + // and that the OfflineRepository is used when offlineMode is enabled. + + // Set up environment variables for the test + process.env['GITHUB_WORKSPACE'] = '.' + process.env['INPUT_CONFIGURATION'] = 'configuration.json' + process.env['INPUT_OWNER'] = 'mikepenz' + process.env['INPUT_REPO'] = 'release-changelog-builder-action' + process.env['INPUT_MODE'] = 'PR' + process.env['INPUT_OFFLINEMODE'] = 'true' + process.env['INPUT_OUTPUTFILE'] = 'test.md' + + const ip = path.join(__dirname, '..', 'lib', 'main.js') + const options: cp.ExecSyncOptions = { + env: process.env + } + + const result = cp.execSync(`node ${ip}`, options).toString() + // should succeed + expect(result).toBeDefined() + + const readOutput = fs.readFileSync('test.md') + fs.unlinkSync('test.md') + + expect(readOutput.toString()).not.toBe("- no changes") + expect(readOutput.toString()).not.toBe('') + + console.log('Offline mode test succeeded') +}) diff --git a/__tests__/offline/offlineMode.test.ts b/__tests__/offline/offlineMode.test.ts new file mode 100644 index 0000000..4c2c740 --- /dev/null +++ b/__tests__/offline/offlineMode.test.ts @@ -0,0 +1,67 @@ +/** + * @file offlineMode.test.ts + * @description Test file for validating the behavior of the new offline mode feature. + * + * This test verifies that: + * 1. The offlineMode parameter is correctly passed to the configuration + * 2. The OfflineRepository is used when offlineMode is enabled + * 3. Local tag and diff retrieval works correctly + * + * To run this test: + * npm run test-offline + * + * Note: This test requires a local git repository with tags to work properly. + */ + +import {mergeConfiguration, resolveConfiguration} from '../../src/utils.js' +import {ReleaseNotesBuilder} from '../../src/releaseNotesBuilder.js' +import {OfflineRepository} from '../../src/repositories/OfflineRepository.js' +import {jest} from '@jest/globals' + +jest.setTimeout(180000) + +// This test validates the behavior of the new offline mode +test('Test offline mode functionality', async () => { + // Define the configuration file to use + const configuration = mergeConfiguration(undefined, resolveConfiguration('', 'configs/configuration_commit.json')) + + // Set offlineMode to true in the configuration + configuration.offlineMode = true + + // Create an instance of OfflineRepository + const offlineRepository = new OfflineRepository( '.') + + const releaseNotesBuilder = new ReleaseNotesBuilder( + null, // The base url used for the API requests (not needed for offline mode) + offlineRepository, // Use the OfflineRepository implementation + '.', // Root path to the checked out sources + 'mikepenz', // The owner of the repo to test + 'release-changelog-builder-action', // The repository name - using this repo itself for the test + null, // fromTag - will be resolved automatically + null, // toTag - will be resolved automatically + false, // includeOpen - not supported in offline mode + false, // failOnError + false, // ignorePrePrerelease + false, // fetchViaCommits - not needed in offline mode + false, // fetchReviewers - not supported in offline mode + false, // fetchReleaseInformation + false, // fetchReviews - not supported in offline mode + 'COMMIT', // mode - must be COMMIT for offline mode + false, // exportCache + false, // exportOnly + null, // cache + configuration // The configuration to use + ) + + // Build the changelog + const changeLog = await releaseNotesBuilder.build() + + // Verify that a changelog was generated + expect(changeLog).toBeDefined() + expect(changeLog).not.toBe("- no changes") + expect(changeLog?.length).toBeGreaterThan(0) + + // Log the changelog for inspection + console.log('Generated changelog in offline mode:') + console.log(changeLog) +}) \ No newline at end of file diff --git a/action.yml b/action.yml index 7d4e2b8..cb2fdfb 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,9 @@ inputs: commitMode: description: '[Deprecated] Enables the commit based mode. This mode generates changelogs based on the commits. Please note that this lacks a lot of features only possible with PRs.' default: "false" + offlineMode: + description: 'Enables offline mode which disables API requests to GitHub or Gitea. Only works with commitMode and retrieves tags and diffs from the local repository.' + default: "false" outputFile: description: 'If defined, the changelog will get written to this file. (relative to the checkout dir)' token: diff --git a/configs/configuration_commit.json b/configs/configuration_commit.json new file mode 100644 index 0000000..1fed47a --- /dev/null +++ b/configs/configuration_commit.json @@ -0,0 +1,26 @@ +{ + "categories": [ + { + "title": "## 🚀 Features", + "labels": ["feature"] + }, + { + "title": "## 🐛 Fixes", + "labels": ["fix"] + }, + { + "title": "## 🧪 Tests", + "labels": ["test"] + }, + { + "title": "## Other", + "labels": [] + } + ], + "sort": "ASC", + "template": "${{CHANGELOG}}", + "pr_template": "- ${{TITLE}}", + "empty_template": "- no changes", + "max_pull_requests": 1000, + "max_back_track_time_days": 1000 +} \ No newline at end of file diff --git a/package.json b/package.json index a2f94dc..b4dcf33 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test-github": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/*.test.ts", "test-gitea": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/gitea/*.test.ts", "test-demo": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/demo/*.test.ts", + "test-offline": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/offline/*.test.ts", "all": "npm run build && npm run format && npm run lint && npm run package && npm run test-github" }, "repository": { diff --git a/src/configuration.ts b/src/configuration.ts index 27705d1..fe02288 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -104,7 +104,8 @@ export const DefaultConfiguration: Configuration = { }, base_branches: [], // target branches for the merged PR ignoring PRs with different target branch, by default it will get all PRs custom_placeholders: [], - trim_values: false // defines if values are being trimmed prior to inserting + trim_values: false, // defines if values are being trimmed prior to inserting + offlineMode: false // defines if the action should run in offline mode, disabling API requests } export const DefaultCommitConfiguration: Configuration = { diff --git a/src/main.ts b/src/main.ts index f444e97..3f8813f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import {ReleaseNotesBuilder} from './releaseNotesBuilder.js' import {Configuration} from './configuration.js' import {GithubRepository} from './repositories/GithubRepository.js' import {GiteaRepository} from './repositories/GiteaRepository.js' +import {OfflineRepository} from './repositories/OfflineRepository.js' async function run(): Promise { const supportedPlatform = { @@ -52,7 +53,15 @@ async function run(): Promise { } // mode of the action (PR, COMMIT, HYBRID) - const mode = resolveMode(core.getInput('mode'), core.getInput('commitMode') === 'true') + let mode = resolveMode(core.getInput('mode'), core.getInput('commitMode') === 'true') + const offlineMode = core.getInput('offlineMode') === 'true' + + // If offline mode is enabled, ensure commit mode is used + if (offlineMode && mode !== 'COMMIT') { + core.warning('⚠️ Offline mode requires commit mode. Switching to commit mode.') + mode = 'COMMIT' + } + core.info(`ℹ️ Running in ${mode} mode.`) // merge configs, use default values from DefaultConfig on missing definition @@ -78,7 +87,10 @@ async function run(): Promise { const exportOnly = core.getInput('exportOnly') === 'true' const cache = core.getInput('cache') - const repositoryUtils = new supportedPlatform[platform](token, baseUrl, repositoryPath) + // Use OfflineRepository if offline mode is enabled, otherwise use the selected platform + const repositoryUtils = offlineMode + ? new OfflineRepository(repositoryPath) + : new supportedPlatform[platform](token, baseUrl, repositoryPath) const result = await new ReleaseNotesBuilder( baseUrl, repositoryUtils, diff --git a/src/pr-collector/gitHelper.ts b/src/pr-collector/gitHelper.ts index 281e404..b675a88 100644 --- a/src/pr-collector/gitHelper.ts +++ b/src/pr-collector/gitHelper.ts @@ -33,6 +33,80 @@ class GitCommandManager { return creationDate.stdout.trim().replace(/"/g, '') } + async getAllTags(): Promise { + const tagsOutput = await this.execGit(['tag', '-l']) + return tagsOutput.stdout.trim().split('\n').filter(tag => tag.trim() !== '') + } + + async getTagCommit(tagName: string): Promise { + const commitOutput = await this.execGit(['rev-list', '-n', '1', tagName]) + return commitOutput.stdout.trim() + } + + async getDiffStats(base: string, head: string): Promise<{ + changedFiles: number; + additions: number; + deletions: number; + changes: number; + }> { + const diffOutput = await this.execGit(['diff', '--numstat', `${base}..${head}`]) + const lines = diffOutput.stdout.trim().split('\n').filter(line => line.trim() !== '') + + let additions = 0 + let deletions = 0 + + for (const line of lines) { + const parts = line.split('\t') + if (parts.length >= 2) { + additions += parseInt(parts[0], 10) || 0 + deletions += parseInt(parts[1], 10) || 0 + } + } + + return { + changedFiles: lines.length, + additions, + deletions, + changes: additions + deletions + } + } + + async getCommitsBetween(base: string, head: string): Promise<{ + count: number; + commits: { + sha: string; + subject: string + message: string; + author: string; + authorName: string; + authorDate: string; + }[]; + }> { + const logOutput = await this.execGit([ + 'log', + '--pretty=format:%H|%an|%ae|%aI|%s|%b', + `${base}..${head}` + ]) + + const lines = logOutput.stdout.trim().split('\n').filter(line => line.trim() !== '') + const commits = lines.map(line => { + const [sha, authorName, authorEmail, authorDate, subject, body] = line.split('|') + return { + sha, + subject, + message: body, + author: authorEmail, + authorName, + authorDate + } + }) + + return { + count: commits.length, + commits + } + } + static async createCommandManager(workingDirectory: string): Promise { const result = new GitCommandManager() await result.initializeCommandManager(workingDirectory) diff --git a/src/pr-collector/types.ts b/src/pr-collector/types.ts index 43c623f..e256835 100644 --- a/src/pr-collector/types.ts +++ b/src/pr-collector/types.ts @@ -6,6 +6,7 @@ export interface PullConfiguration { sort: Sort | string // "ASC" or "DESC" tag_resolver: TagResolver base_branches: string[] + offlineMode?: boolean } /** diff --git a/src/repositories/OfflineRepository.ts b/src/repositories/OfflineRepository.ts new file mode 100644 index 0000000..0c55603 --- /dev/null +++ b/src/repositories/OfflineRepository.ts @@ -0,0 +1,113 @@ +import * as core from "@actions/core"; +import moment from "moment"; +import { BaseRepository } from "./BaseRepository.js"; +import { TagInfo } from "../pr-collector/tags.js"; +import { DiffInfo } from "../pr-collector/commits.js"; +import { PullRequestInfo } from "../pr-collector/pullRequests.js"; +import { createCommandManager } from "../pr-collector/gitHelper.js"; + +export class OfflineRepository extends BaseRepository { + + constructor(repositoryPath: string) { + super("", "offline", repositoryPath); + this.url = this.defaultUrl; + } + + get defaultUrl(): string { + return "offline"; + } + + get homeUrl(): string { + return "offline"; + } + + async getTags(owner: string, repo: string, maxTagsToFetch: number): Promise { + core.info(`ℹ️ Retrieving tags from local repository in offline mode`); + const gitHelper = await createCommandManager(this.repositoryPath); + const tags = await gitHelper.getAllTags(); + + // Limit the number of tags to maxTagsToFetch + const limitedTags = tags.slice(0, maxTagsToFetch); + + // Convert to TagInfo objects + const tagInfos: TagInfo[] = []; + for (const tag of limitedTags) { + const commit = await gitHelper.getTagCommit(tag); + tagInfos.push({ + name: tag, + commit + }); + } + + core.info(`ℹ️ Retrieved ${tagInfos.length} tags from local repository`); + return tagInfos; + } + + async fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise { + return this.getTagByCreateTime(repositoryPath, tagInfo); + } + + async getDiffRemote(owner: string, repo: string, base: string, head: string): Promise { + core.info(`ℹ️ Getting diff information from local repository in offline mode`); + const gitHelper = await createCommandManager(this.repositoryPath); + + // Get diff stats + const diffStats = await gitHelper.getDiffStats(base, head); + + // Get commits + const commitInfo = await gitHelper.getCommitsBetween(base, head); + + return { + changedFiles: diffStats.changedFiles, + additions: diffStats.additions, + deletions: diffStats.deletions, + changes: diffStats.changes, + commits: commitInfo.count, + commitInfo: commitInfo.commits.map(commit => ({ + sha: commit.sha, + summary: commit.subject.split('\n')[0], + message: commit.message, + author: commit.author, + authorName: commit.authorName, + authorDate: moment(commit.authorDate), + committer: "", + committerName: "", + commitDate: moment(commit.authorDate), + prNumber: undefined + })) + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise { + core.info(`⚠️ getForCommitHash not supported in offline mode`); + return []; + } + + async getBetweenDates( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + owner: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + repo: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fromDate: moment.Moment, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + toDate: moment.Moment, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + maxPullRequests: number + ): Promise { + core.info(`⚠️ getBetweenDates not supported in offline mode`); + return []; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getOpen(owner: string, repo: string, maxPullRequests: number): Promise { + core.info(`⚠️ getOpen not supported in offline mode`); + return []; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise { + core.info(`⚠️ getReviews not supported in offline mode`); + } +}