diff --git a/CHANGELOG.md b/CHANGELOG.md index 872a969..95f856d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > This file is generated. To add a new changelog entry, run the `changelog` tool. For more info, run `changelog help`. +## [0.2.0] - 03-11-2021 + +### Added +- Add all changelog entry types from the [Keep a Changelog spec](https://keepachangelog.com/en/1.0.0/) + - Types of changes are `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security` +- Add versioning information to the tool + - Run `changelog --version` +- Improve error messaging when a corrupted file is encountered + +### Changed +- Save changelog entries as Markdown files to facilitate easier code review +- Use imperative mood for changelog commands. This feels better when executing a command and will feel more familiar to git users + +### Fixed +- Fix a few typos in the tool's output +- Use `yyyy` date formatting to avoid [weird, sneaky bugs](https://stackoverflow.com/q/15133549/1181439) + ## [0.1.1] - 02-26-2021 ### Fixed @@ -16,9 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release - - Created the Swift package - - Added `help`, `log`, and `publish` commands + - Create the Swift package + - Add `help`, `log`, and `publish` commands - Set up unit test suite +[0.2.0]: https://github.com/pg8wood/changelog-generator/compare/0.1.1...0.2.0 [0.1.1]: https://github.com/pg8wood/changelog-generator/compare/0.1.0...0.1.1 [0.1.0]: https://github.com/pg8wood/changelog-generator/releases/tag/0.1.0 diff --git a/README.md b/README.md index a0f64f9..ea92f22 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,19 @@ Teams that keep an up-to-date changelog are often plagued by merge conflicts int ## Installation -### [Mint](https://github.com/yonaskolb/Mint) (system-wide installation) +### [Mint](https://github.com/yonaskolb/Mint) ```sh $ mint install pg8wood/changelog-generator ``` +### Manual Installation +```sh +$ swift build -c release +$ cp -f .build/release/changelog /usr/local/bin/changelog +``` + ## Usage ### Help To view all the available options, run `$ changelog help` @@ -23,7 +29,7 @@ To view all the available options, run `$ changelog help` Changelog entries may be added interactively with your favorite text editor, or quick entries can be passed as command-line arguments. ```sh -$ changelog log addition "I added something cool" "And something boring" +$ changelog add "I added something cool" "And something boring" ### Added - I added something cool @@ -34,17 +40,18 @@ $ changelog log addition "I added something cool" "And something boring" #### Arguments ``` - The type of changelog entry to create. - Valid entry types are addition, change, and fix. + The type of changelog entry to create. + Valid entry types are add, change, deprecate, remove, fix, and security. - A list of strings separated by spaces to be recorded as a bulleted changelog entry. - If is supplied, the --editor option is ignored and the changelog entry is created for you without opening an interactive text editor. + A list of quoted strings separated by spaces to be recorded as a bulleted changelog entry. + If is supplied, the --editor option is ignored and the changelog entry is created for you without opening an interactive text editor. ``` #### Options ``` -d, --directory A directory where unpublished changelog entries will be written to / read from as Markdown files. (default: changelogs/unreleased/) -e, --editor A terminal-based text editor executable in your $PATH used to write your changelog entry with more precision than the default bulleted list of changes. (default: vim) + --version Show the version. -h, --help Show help information. ``` @@ -52,7 +59,7 @@ $ changelog log addition "I added something cool" "And something boring" ``` $ changelog publish 1.0.1 -## [1.0.1] - 02-26-2021 +## [1.0.1] - ### Added - help @@ -63,8 +70,8 @@ Nice! CHANGELOG.md was updated. Congrats on the release! 🥳🍻 #### Arguments ``` - The version number associated with the changelog entries to be published. - A string representing the date the version was published. Format MM-dd-YYYY. (default: ) + The version number associated with the changelog entries to be published. + A string representing the date the version was published. Format MM-dd-yyyy. (default: ) ``` #### Options diff --git a/Sources/ChangelogCore/Commands/Changelog.swift b/Sources/ChangelogCore/Commands/Changelog.swift index dfe7413..e00d757 100644 --- a/Sources/ChangelogCore/Commands/Changelog.swift +++ b/Sources/ChangelogCore/Commands/Changelog.swift @@ -12,6 +12,7 @@ public struct Changelog: ParsableCommand { public static let configuration = CommandConfiguration( abstract: "Curbing Cumbersome Changelog Conflicts.", discussion: "Creates changelog entries and stores them as single files to avoid merge conflictss in version control. When it's time to release, `changelog publish` collects these files and appends them to your changelog file.", + version: "0.2.0", subcommands: [Log.self, Publish.self], defaultSubcommand: Log.self) diff --git a/Sources/ChangelogCore/Commands/Log.swift b/Sources/ChangelogCore/Commands/Log.swift index d69253d..a693b3f 100644 --- a/Sources/ChangelogCore/Commands/Log.swift +++ b/Sources/ChangelogCore/Commands/Log.swift @@ -45,6 +45,10 @@ struct Log: ParsableCommand { } public func run() throws { + guard fileManager.fileExists(atPath: options.unreleasedChangelogsDirectory.path) else { + throw ChangelogError.changelogDirectoryNotFound(expectedPath: options.unreleasedChangelogsDirectory.relativeString) + } + if text.isEmpty { try openEditor(editor, for: entryType) } else { @@ -61,9 +65,11 @@ struct Log: ParsableCommand { } private func openEditor(_ editor: String, for entryType: EntryType) throws { - let temporaryFilePath = createUniqueChangelogFilepath() - let hint = "" + let temporaryFilePath = options.unreleasedChangelogsDirectory + .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString) + .appendingPathExtension("md") + let hint = "" try Data(hint.utf8) .write(to: temporaryFilePath) @@ -89,27 +95,25 @@ struct Log: ParsableCommand { } private func write(entryText: String) throws { - let entry = ChangelogEntry(type: entryType, text: entryText) - let uniqueFilepath = createUniqueChangelogFilepath() - let data = try JSONEncoder().encode(entry) - try data.write(to: uniqueFilepath) + let uniqueFilepath = options.unreleasedChangelogsDirectory + .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString) + .appendingPathExtension("md") + + let entry = """ + ### \(entryType.title) + \(entryText) + + """ + + try entry.write(toFile: uniqueFilepath.path, atomically: true, encoding: .utf8) let filePathString = OutputController.tryWrap(uniqueFilepath.relativePath, inColor: .white, bold: true) let successString = OutputController.tryWrap("🙌 Created changelog entry at \(filePathString)", inColor: .green, bold: true) - OutputController.write( - """ - - ### \(entryType.title) - \(entry.text) + OutputController.write(""" + \(entry) \(successString) """, inColor: .cyan) } - - private func createUniqueChangelogFilepath() -> URL { - options.unreleasedChangelogsDirectory - .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString) - .appendingPathExtension("json") - } } diff --git a/Sources/ChangelogCore/Commands/Publish.swift b/Sources/ChangelogCore/Commands/Publish.swift index 31bcdb3..f5b4146 100644 --- a/Sources/ChangelogCore/Commands/Publish.swift +++ b/Sources/ChangelogCore/Commands/Publish.swift @@ -60,11 +60,8 @@ struct Publish: ParsableCommand { } func run() throws { - let decoder = JSONDecoder() let changelogFilePaths = try fileManager.contentsOfDirectory(at: unreleasedChangelogsDirectory, includingPropertiesForKeys: nil) - let uncategorizedEntries = try changelogFilePaths.map { - try decoder.decode(ChangelogEntry.self, from: Data(contentsOf: $0)) - } + let uncategorizedEntries = try changelogFilePaths.map(ChangelogEntry.init(contentsOf:)) guard !uncategorizedEntries.isEmpty else { throw ChangelogError.noEntriesFound @@ -75,7 +72,8 @@ struct Publish: ParsableCommand { printChangelogSummary(groupedEntries: groupedEntries, changelogFilePaths: changelogFilePaths) if dryRun { - OutputController.write("\n(Dry run) would have deleted \(changelogFilePaths.count) unreleased changelog entries.", inColor: .yellow) + let entryNoun = changelogFilePaths.count == 1 ? "entry" : "entries" + OutputController.write("\n(Dry run) would have deleted \(changelogFilePaths.count) unreleased changelog \(entryNoun).", inColor: .yellow) return } @@ -89,7 +87,7 @@ struct Publish: ParsableCommand { changelongString.append("\n### \(entryType.title)\n") groupedEntries[entryType]?.forEach { entry in - changelongString.append("\(entry.text)\n") + changelongString.append("\(entry.text)") } }) @@ -123,7 +121,7 @@ struct Publish: ParsableCommand { changelog.write(headerData) groupedEntries[entryType]?.forEach { entry in - changelog.write(Data("\n\(entry.text)".utf8)) + changelog.write(Data("\n\(entry.text.trimmingCharacters(in: .newlines))".utf8)) } } diff --git a/Sources/ChangelogCore/Entry.swift b/Sources/ChangelogCore/Entry.swift index 2fcdd6e..140313b 100644 --- a/Sources/ChangelogCore/Entry.swift +++ b/Sources/ChangelogCore/Entry.swift @@ -8,12 +8,30 @@ import Foundation import ArgumentParser -struct ChangelogEntry: Codable { +struct ChangelogEntry { let type: EntryType let text: String + + init(type: EntryType, text: String) { + self.type = type + self.text = text + } + + init(contentsOf fileURL: URL) throws { + let fileContents = try String(contentsOf: fileURL) + var lines = fileContents.components(separatedBy: .newlines) + let header = lines.removeFirst() + + guard let type = EntryType(title: header.components(separatedBy: .whitespaces).last) else { + throw ChangelogError.malformattedEntry(atPath: fileURL.path) + } + + self.type = type + text = lines.joined(separator: "\n") + } } -/// A type of changelog change as defined by Keep a Changelog 1.0.0. +/// A type of changelog change as defined by Keep a Changelog. /// /// https://keepachangelog.com/en/1.0.0/ enum EntryType: String, Codable, Comparable, ExpressibleByArgument, CaseIterable { @@ -34,16 +52,19 @@ enum EntryType: String, Codable, Comparable, ExpressibleByArgument, CaseIterable case security var title: String { - switch self { - case .add: return "Added" - case .change: return "Changed" - case .deprecate: return "Deprecated" - case .remove: return "Removed" - case .fix: return "Fixed" - case .security: return "Security" - } + EntryType.titles[self]! } + private static let titles: [EntryType: String] = [ + .add: "Added", + .change: "Changed", + .deprecate: "Deprecated", + .remove: "Removed", + .fix: "Fixed", + .security: "Security" + ] + private static var titlesToValues = Dictionary(uniqueKeysWithValues: titles.map({ ($1, $0) })) + init(_ string: String) throws { guard let entryType = EntryType(rawValue: string) else { throw ValidationError("Valid types are \(EntryType.allCasesSentenceString).") @@ -51,4 +72,13 @@ enum EntryType: String, Codable, Comparable, ExpressibleByArgument, CaseIterable self = entryType } + + init?(title: String?) { + guard let title = title, + let entryType = EntryType.titlesToValues[title] else { + return nil + } + + self = entryType + } } diff --git a/Sources/ChangelogCore/Errors.swift b/Sources/ChangelogCore/Errors.swift index e4deb03..bf63d10 100644 --- a/Sources/ChangelogCore/Errors.swift +++ b/Sources/ChangelogCore/Errors.swift @@ -7,7 +7,9 @@ import Foundation -enum ChangelogError: LocalizedError { +enum ChangelogError: LocalizedError, Equatable { + case changelogDirectoryNotFound(expectedPath: String) + case malformattedEntry(atPath: String) case noEntriesFound case noTextEntered case changelogNotFound @@ -15,6 +17,10 @@ enum ChangelogError: LocalizedError { var errorDescription: String? { switch self { + case .changelogDirectoryNotFound(let expectedPath): + return "Couldn't find the changelog directory. Please check that `\(expectedPath)` exists and is readable from your current working directory." + case .malformattedEntry(let path): + return "The changelog entry at \(path) is malformatted. Please fix or remove the file and try again." case .noEntriesFound: return "No unreleased changelog entries were found." case .noTextEntered: diff --git a/Sources/changelog-generator/main.swift b/Sources/changelog-generator/main.swift index 8cba93c..2995e41 100644 --- a/Sources/changelog-generator/main.swift +++ b/Sources/changelog-generator/main.swift @@ -5,7 +5,6 @@ // Created by Patrick Gatewood on 2/19/21. // -import Foundation import ChangelogCore Changelog.main() diff --git a/Tests/changelog-generatorTests/LogCommandTests.swift b/Tests/changelog-generatorTests/LogCommandTests.swift index e88ae8f..523d4fc 100644 --- a/Tests/changelog-generatorTests/LogCommandTests.swift +++ b/Tests/changelog-generatorTests/LogCommandTests.swift @@ -17,7 +17,11 @@ class LogCommandTests: XCTestCase { logCommand.options = Changelog.Options(unreleasedChangelogsDirectory: Changelog.Options.defaultUnreleasedChangelogDirectory) XCTAssertThrowsError(try logCommand.run()) { error in - XCTAssertEqual((error as NSError).code, NSFileNoSuchFileError) + guard let changelogError = error as? ChangelogError, + case .changelogDirectoryNotFound(_) = changelogError else { + XCTFail("Expected ChangelogError.changelogDirectoryNotFound to be thrown") + return + } } } @@ -36,11 +40,9 @@ class LogCommandTests: XCTestCase { try logCommand.run() let entryFile = try XCTUnwrap(try FileManager.default.contentsOfDirectory(at: directory.asURL, includingPropertiesForKeys: nil).first) - let entry = try JSONDecoder().decode( - ChangelogEntry.self, - from: Data(contentsOf: entryFile)) + let entry = try ChangelogEntry(contentsOf: entryFile) - let formattedSampleText = "- \(sampleAdditionText)" + let formattedSampleText = "- \(sampleAdditionText)\n" XCTAssertEqual(entry.type, .add) XCTAssertEqual(entry.text, formattedSampleText) @@ -61,15 +63,14 @@ class LogCommandTests: XCTestCase { try logCommand.run() let entryFile = try XCTUnwrap(try FileManager.default.contentsOfDirectory(at: directory.asURL, includingPropertiesForKeys: nil).first) - let entry = try JSONDecoder().decode( - ChangelogEntry.self, - from: Data(contentsOf: entryFile)) + let entry = try ChangelogEntry(contentsOf: entryFile) let formattedSampleText = """ - - \(sampleFixBullets[0]) - - \(sampleFixBullets[1]) - """ + - \(sampleFixBullets[0]) + - \(sampleFixBullets[1]) + + """ XCTAssertEqual(entry.type, .fix) XCTAssertEqual(entry.text, formattedSampleText) diff --git a/Tests/changelog-generatorTests/PublishCommandTests.swift b/Tests/changelog-generatorTests/PublishCommandTests.swift index ee43b4d..9cad87a 100644 --- a/Tests/changelog-generatorTests/PublishCommandTests.swift +++ b/Tests/changelog-generatorTests/PublishCommandTests.swift @@ -68,8 +68,7 @@ class PublishCommandTests: XCTestCase { let changelogOptions = Changelog.Options(unreleasedChangelogsDirectory: directory.asURL) publishCommand.options = changelogOptions - let changelogEntryData = try Data(contentsOf: changelogEntry.path.asURL) - let entry = try JSONDecoder().decode(ChangelogEntry.self, from: changelogEntryData) + let entry = try ChangelogEntry(contentsOf: changelogEntry.path.asURL) try publishCommand.run() @@ -169,12 +168,14 @@ class PublishCommandTests: XCTestCase { } } - private func withTemporaryChangelogEntry(dir directory: AbsolutePath?, _ body: (TemporaryFile) throws -> Void) throws { try withTemporaryFile(dir: directory, prefix: "fakeEntry", suffix: "md") { changelogEntryFile in - let changelogEntry = ChangelogEntry(type: .add, text: "Added temporarily") - let changelogEntryData = try JSONEncoder().encode(changelogEntry) - changelogEntryFile.fileHandle.write(changelogEntryData) + let entryContent = """ + ### Added + - Added temporarily + """ + + changelogEntryFile.fileHandle.write(Data(entryContent.utf8)) try body(changelogEntryFile) }