diff --git a/Sources/NnexKit/SwiftData/NnexContext.swift b/Sources/NnexKit/SwiftData/NnexContext.swift index 960788e..3a0c97d 100644 --- a/Sources/NnexKit/SwiftData/NnexContext.swift +++ b/Sources/NnexKit/SwiftData/NnexContext.swift @@ -14,7 +14,6 @@ public final class NnexContext { private let defaults: UserDefaults private let defaultBuildTypeKey = "defaultBuildTypeKey" private let tapListFolderPathKey = "tapListFolderPathKey" - private let aiReleaseEnabledKey = "aiReleaseEnabledKey" /// The model context for interacting with SwiftData models. public let context: ModelContext @@ -64,18 +63,6 @@ extension NnexContext { public func loadDefaultBuildType() -> BuildType { return defaults.object(forKey: defaultBuildTypeKey) as? BuildType ?? .universal } - - /// Saves the AI release enabled flag. - /// - Parameter isEnabled: Whether AI release functionality is enabled. - public func saveAIReleaseEnabled(_ isEnabled: Bool) { - defaults.set(isEnabled, forKey: aiReleaseEnabledKey) - } - - /// Loads the AI release enabled flag. - /// - Returns: The saved flag value or false if not set. - public func loadAIReleaseEnabled() -> Bool { - return defaults.bool(forKey: aiReleaseEnabledKey) - } } // MARK: - SwiftData diff --git a/Sources/NnexKit/Utilities/AIChangeLogError.swift b/Sources/NnexKit/Utilities/AIChangeLogError.swift deleted file mode 100644 index aa403b2..0000000 --- a/Sources/NnexKit/Utilities/AIChangeLogError.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// AIChangeLogError.swift -// nnex -// -// Created by Nikolai Nobadi on 9/8/25. -// - -import Foundation - -public enum AIChangeLogError: Error, LocalizedError { - case missingClaudeCLI - case emptyClaudeResponse - case fileOperationFailed(String) - - public var errorDescription: String? { - switch self { - case .missingClaudeCLI: - return "Claude CLI is not installed. Please install it first: https://claude.ai/cli" - case .emptyClaudeResponse: - return "Claude returned an empty response. Please try again." - case .fileOperationFailed(let message): - return "File operation failed: \(message)" - } - } -} diff --git a/Sources/NnexKit/Utilities/ChangeLogInfo.swift b/Sources/NnexKit/Utilities/ChangeLogInfo.swift deleted file mode 100644 index 147c4ba..0000000 --- a/Sources/NnexKit/Utilities/ChangeLogInfo.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// ChangeLogInfo.swift -// nnex -// -// Created by Nikolai Nobadi on 3/20/25. -// - -import Foundation - -/// Represents a file change in a git diff with its status and filename. -public struct FileChange { - /// The status of the file change (A=Added, M=Modified, D=Deleted, etc.) - public let status: String - - /// The filename that was changed - public let filename: String - - /// Initializes a new FileChange instance. - /// - Parameters: - /// - status: The status of the file change. - /// - filename: The filename that was changed. - public init(status: String, filename: String) { - self.status = status - self.filename = filename - } -} - -/// Contains comprehensive changelog information extracted from git history. -public struct ChangeLogInfo { - /// The previous git tag or commit hash used as the base for comparison - public let previousTag: String - - /// List of commit messages since the previous tag - public let commits: [String] - - /// List of files that were changed with their status - public let filesChanged: [FileChange] - - /// Git diff statistics showing files changed, insertions, and deletions - public let changeStats: String - - /// Compact unified diff with function context (limited to prevent excessive output) - public let compactDiff: String - - /// Initializes a new ChangeLogInfo instance. - /// - Parameters: - /// - previousTag: The previous git tag or commit hash. - /// - commits: List of commit messages. - /// - filesChanged: List of file changes. - /// - changeStats: Git diff statistics. - /// - compactDiff: Compact unified diff. - public init(previousTag: String, commits: [String], filesChanged: [FileChange], changeStats: String, compactDiff: String) { - self.previousTag = previousTag - self.commits = commits - self.filesChanged = filesChanged - self.changeStats = changeStats - self.compactDiff = compactDiff - } -} \ No newline at end of file diff --git a/Sources/NnexKit/Utilities/ChangeLogInfoLoader.swift b/Sources/NnexKit/Utilities/ChangeLogInfoLoader.swift deleted file mode 100644 index 31b4592..0000000 --- a/Sources/NnexKit/Utilities/ChangeLogInfoLoader.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// ChangeLogInfoLoader.swift -// nnex -// -// Created by Nikolai Nobadi on 3/20/25. -// - -import Foundation -import NnShellKit - -/// Loads changelog information by executing git commands to extract commit history, file changes, and diffs. -public struct ChangeLogInfoLoader { - private let shell: any Shell - - /// Initializes a new ChangeLogInfoLoader instance. - /// - Parameter shell: The shell instance for executing git commands. - public init(shell: any Shell) { - self.shell = shell - } -} - - -// MARK: - Load -public extension ChangeLogInfoLoader { - /// Loads comprehensive changelog information from git history. - /// - Returns: A ChangeLogInfo instance containing all changelog data. - /// - Throws: An error if any git command fails or if the repository state is invalid. - func loadChangeLogInfo() throws -> ChangeLogInfo { - let previousTag = try getPreviousTag() - let commits = try getCommits(since: previousTag) - let filesChanged = try getFilesChanged(since: previousTag) - let changeStats = try getChangeStats(since: previousTag) - let compactDiff = try getCompactDiff(since: previousTag) - - return .init(previousTag: previousTag, commits: commits, filesChanged: filesChanged, changeStats: changeStats, compactDiff: compactDiff) - } -} - - -// MARK: - Private Methods -private extension ChangeLogInfoLoader { - /// Finds the previous git tag or falls back to the first commit if no tags exist. - /// - Returns: The previous tag or first commit hash. - /// - Throws: An error if the git command fails. - func getPreviousTag() throws -> String { - do { - if let tag = try? shell.bash("git describe --tags --abbrev=0 HEAD^").trimmingCharacters(in: .whitespacesAndNewlines), - !tag.isEmpty { - return tag - } - // Fallback to first commit if no tags exist or tag is empty - let firstCommit = try shell.bash("git rev-list --max-parents=0 HEAD") - return firstCommit.trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - let firstCommit = try shell.bash("git rev-list --max-parents=0 HEAD") - return firstCommit.trimmingCharacters(in: .whitespacesAndNewlines) - } - } - - /// Gets the list of commit messages since the previous tag. - /// - Parameter previousTag: The previous tag or commit to compare against. - /// - Returns: An array of commit messages. - /// - Throws: An error if the git command fails. - func getCommits(since previousTag: String) throws -> [String] { - let output = try shell.bash("GIT_PAGER= git log \(previousTag)..HEAD --pretty=format:%s") - return output.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - /// Gets the list of files changed since the previous tag with their status. - /// - Parameter previousTag: The previous tag or commit to compare against. - /// - Returns: An array of FileChange instances. - /// - Throws: An error if the git command fails. - func getFilesChanged(since previousTag: String) throws -> [FileChange] { - // -z => NUL separated; name-status fields are tab-separated per record - let output = try shell.bash("GIT_PAGER= git diff --name-status -z \(previousTag)..HEAD") - let parts = output.split(separator: "\0").map(String.init) - var items: [FileChange] = [] - - var i = 0 - while i < parts.count { - // status and first path are in the same NUL-delimited record but tab-separated - let fields = parts[i].split(separator: "\t").map(String.init) - guard let status = fields.first else { break } - - switch status.prefix(1) { - case "R", "C": // rename/copy => status, old, new - // fields may be: [ "R100", "oldpath", "newpath" ] in one record - if fields.count >= 3 { - items.append(FileChange(status: String(status), filename: fields[2])) - } else { - // Some git versions emit status + old in first record, new in next; handle gracefully - if i + 1 < parts.count { - items.append(FileChange(status: String(status), filename: parts[i + 1])) - i += 1 - } - } - default: - // A/M/D etc: fields[1] is the path - let filename = fields.count >= 2 ? fields[1] : (fields.first ?? "") - items.append(FileChange(status: String(status), filename: filename)) - } - i += 1 - } - return items - } - - /// Gets the change statistics (files changed, insertions, deletions) since the previous tag. - /// - Parameter previousTag: The previous tag or commit to compare against. - /// - Returns: A string containing the diff statistics. - /// - Throws: An error if the git command fails. - func getChangeStats(since previousTag: String) throws -> String { - let output = try shell.bash("GIT_PAGER= git diff --no-color --no-ext-diff --stat \(previousTag)..HEAD") - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// Gets a compact unified diff with function context, limited to 800 lines. - /// - Parameter previousTag: The previous tag or commit to compare against. - /// - Returns: A string containing the compact diff. - /// - Throws: An error if the git command fails. - func getCompactDiff(since previousTag: String) throws -> String { - let output = try shell.bash("bash -lc 'GIT_PAGER= git diff --no-color --no-ext-diff --diff-algorithm=histogram --unified=0 --function-context \(previousTag)..HEAD | head -c 120000'") - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Sources/nnex/Commands/Brew/Publish.swift b/Sources/nnex/Commands/Brew/Publish.swift index 5c309e1..654196e 100644 --- a/Sources/nnex/Commands/Brew/Publish.swift +++ b/Sources/nnex/Commands/Brew/Publish.swift @@ -42,11 +42,10 @@ extension Nnex.Brew { let gitHandler = Nnex.makeGitHandler() let context = try Nnex.makeContext() let buildType = buildType ?? context.loadDefaultBuildType() - let aiReleaseEnabled = context.loadAIReleaseEnabled() let projectFolder = try Nnex.Brew.getProjectFolder(at: path) let trashHandler = Nnex.makeTrashHandler() let publishInfoLoader = PublishInfoLoader(shell: shell, picker: picker, projectFolder: projectFolder, context: context, gitHandler: gitHandler, skipTests: skipTests) - let manager = PublishExecutionManager(shell: shell, picker: picker, gitHandler: gitHandler, publishInfoLoader: publishInfoLoader, trashHandler: trashHandler, aiReleaseEnabled: aiReleaseEnabled) + let manager = PublishExecutionManager(shell: shell, picker: picker, gitHandler: gitHandler, publishInfoLoader: publishInfoLoader, trashHandler: trashHandler) try manager.executePublish( projectFolder: projectFolder, diff --git a/Sources/nnex/Commands/Config/Config.swift b/Sources/nnex/Commands/Config/Config.swift index c71e7b3..efb5243 100644 --- a/Sources/nnex/Commands/Config/Config.swift +++ b/Sources/nnex/Commands/Config/Config.swift @@ -15,7 +15,7 @@ extension Nnex { abstract: "Manage configuration settings for Nnex.", subcommands: [ SetListPath.self, ShowListPath.self, OpenListFolder.self, - SetBuildType.self, ShowBuildType.self, EnableAIRelease.self + SetBuildType.self, ShowBuildType.self ] ) } @@ -108,25 +108,3 @@ extension Nnex.Config { } } } - - -// MARK: - EnableAIRelease -extension Nnex.Config { - struct EnableAIRelease: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Toggle AI release functionality.") - - func run() throws { - let picker = Nnex.makePicker() - let context = try Nnex.makeContext() - let currentSetting = context.loadAIReleaseEnabled() - - print("Current AI Release Setting: \(currentSetting ? "Enabled" : "Disabled")") - - try picker.requiredPermission(prompt: "Would you like to toggle this setting?") - - let newSetting = !currentSetting - context.saveAIReleaseEnabled(newSetting) - print("AI Release functionality is now: \(newSetting ? "Enabled" : "Disabled")") - } - } -} diff --git a/Sources/nnex/Domain/Execution/PublishExecutionManager.swift b/Sources/nnex/Domain/Execution/PublishExecutionManager.swift index 5eb378d..a386aa4 100644 --- a/Sources/nnex/Domain/Execution/PublishExecutionManager.swift +++ b/Sources/nnex/Domain/Execution/PublishExecutionManager.swift @@ -16,15 +16,13 @@ struct PublishExecutionManager { private let gitHandler: GitHandler private let publishInfoLoader: PublishInfoLoader private let trashHandler: TrashHandler - private let aiReleaseEnabled: Bool - init(shell: any Shell, picker: NnexPicker, gitHandler: GitHandler, publishInfoLoader: PublishInfoLoader, trashHandler: TrashHandler, aiReleaseEnabled: Bool) { + init(shell: any Shell, picker: NnexPicker, gitHandler: GitHandler, publishInfoLoader: PublishInfoLoader, trashHandler: TrashHandler) { self.shell = shell self.picker = picker self.gitHandler = gitHandler self.publishInfoLoader = publishInfoLoader self.trashHandler = trashHandler - self.aiReleaseEnabled = aiReleaseEnabled } } @@ -103,7 +101,7 @@ private extension PublishExecutionManager { /// - Returns: An array of asset URLs from the GitHub release. /// - Throws: An error if the upload fails. func uploadRelease(folder: Folder, archivedBinaries: [ArchivedBinary], versionInfo: ReleaseVersionInfo, previousVersion: String?, releaseNotesSource: ReleaseNotesSource) throws -> [String] { - let handler = ReleaseHandler(picker: picker, gitHandler: gitHandler, trashHandler: trashHandler, aiReleaseEnabled: aiReleaseEnabled, shell: shell) + let handler = ReleaseHandler(picker: picker, gitHandler: gitHandler, trashHandler: trashHandler) return try handler.uploadRelease(folder: folder, archivedBinaries: archivedBinaries, versionInfo: versionInfo, previousVersion: previousVersion, releaseNotesSource: releaseNotesSource) } diff --git a/Sources/nnex/Domain/Handlers/AIReleaseNotesHandler.swift b/Sources/nnex/Domain/Handlers/AIReleaseNotesHandler.swift deleted file mode 100644 index ccc33d7..0000000 --- a/Sources/nnex/Domain/Handlers/AIReleaseNotesHandler.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// AIReleaseNotesHandler.swift -// nnex -// -// Created by Nikolai Nobadi on 3/24/25. -// - -import Files -import Foundation -import GitCommandGen -import NnShellKit -import NnexKit - -/// Handles generation of AI-powered release notes using Claude Code CLI. -struct AIReleaseNotesHandler { - private let projectName: String - private let shell: any Shell - private let picker: NnexPicker - private let changeLogLoader: ChangeLogInfoLoader - private let fileUtility: ReleaseNotesFileUtility - - /// Initializes a new AIReleaseNotesHandler instance. - /// - Parameters: - /// - projectName: The name of the project for generating release notes. - /// - shell: The shell instance for executing commands. - /// - picker: The picker for user interactions. - /// - fileUtility: The file utility for creating and validating files. - init(projectName: String, shell: any Shell, picker: NnexPicker, fileUtility: ReleaseNotesFileUtility) { - self.projectName = projectName - self.shell = shell - self.picker = picker - self.changeLogLoader = ChangeLogInfoLoader(shell: shell) - self.fileUtility = fileUtility - } -} - - -// MARK: - Methods -extension AIReleaseNotesHandler { - /// Generates AI-powered release notes for a given version. - /// - Parameters: - /// - releaseNumber: The version number for the release. - /// - projectPath: The path to the project directory. - /// - Returns: A ReleaseNoteInfo instance containing the release notes. - /// - Throws: An error if generation fails. - func generateReleaseNotes(releaseNumber: String, projectPath: String) throws -> ReleaseNoteInfo { - if let existingNotes = try checkExistingChangelog(for: releaseNumber, projectPath: projectPath) { - return .init(content: existingNotes, isFromFile: false) - } - - let changeLogInfo = try changeLogLoader.loadChangeLogInfo() - let releaseNotesFile = try fileUtility.createVersionedNoteFile(projectName: projectName, version: releaseNumber) - - try generateWithClaude( - changeLogInfo: changeLogInfo, - releaseNumber: releaseNumber, - outputFile: releaseNotesFile.path - ) - - return try fileUtility.validateAndConfirmNoteFile(releaseNotesFile) - } -} - - -// MARK: - Private Methods -private extension AIReleaseNotesHandler { - func checkExistingChangelog(for version: String, projectPath: String) throws -> String? { - let changelogPath = "\(projectPath)/CHANGELOG.md" - - guard FileManager.default.fileExists(atPath: changelogPath) else { - return nil - } - - let content = try String(contentsOfFile: changelogPath, encoding: .utf8) - let lines = content.components(separatedBy: .newlines) - - let versionHeader = "## [\(version)]" - guard let startIndex = lines.firstIndex(where: { $0.hasPrefix(versionHeader) }) else { - return nil - } - - var endIndex = lines.count - for i in (startIndex + 1).. String { - let commitsSection = changeLogInfo.commits - .map { "- \($0)" } - .joined(separator: "\n") - - let filesSection = changeLogInfo.filesChanged - .map { "- [\($0.status)] \($0.filename)" } - .joined(separator: "\n") - - let diffPreview = String(changeLogInfo.compactDiff.prefix(5000)) - - return """ - Generate release notes for version \(releaseNumber) of \(projectName) in CHANGELOG.md format. - - Use this exact format: - ### Added - - New features added - - ### Changed - - Changes in existing functionality - - ### Fixed - - Bug fixes - - ### Removed - - Features removed - - Only include sections that have relevant changes. Write concise, user-facing descriptions. - - ## Git History Context: - - Previous Version: \(changeLogInfo.previousTag) - - Commits: - \(commitsSection) - - Files Changed: - \(filesSection) - - Statistics: - \(changeLogInfo.changeStats) - - Detailed Changes: - \(diffPreview) - - Based on the above git history, generate professional release notes focusing on user-facing changes. - """ - } -} diff --git a/Sources/nnex/Domain/Handlers/ReleaseHandler.swift b/Sources/nnex/Domain/Handlers/ReleaseHandler.swift index d6f4f77..1059449 100644 --- a/Sources/nnex/Domain/Handlers/ReleaseHandler.swift +++ b/Sources/nnex/Domain/Handlers/ReleaseHandler.swift @@ -15,15 +15,11 @@ struct ReleaseHandler { private let picker: NnexPicker private let gitHandler: GitHandler private let trashHandler: TrashHandler - private let aiReleaseEnabled: Bool - private let shell: (any Shell)? - init(picker: NnexPicker, gitHandler: GitHandler, trashHandler: TrashHandler, aiReleaseEnabled: Bool = false, shell: (any Shell)? = nil) { + init(picker: NnexPicker, gitHandler: GitHandler, trashHandler: TrashHandler) { self.picker = picker self.gitHandler = gitHandler self.trashHandler = trashHandler - self.aiReleaseEnabled = aiReleaseEnabled - self.shell = shell } } @@ -70,7 +66,7 @@ private extension ReleaseHandler { if let notes = releaseNotesSource.notes { return .init(content: notes, isFromFile: false) } - return try ReleaseNotesHandler(picker: picker, projectName: projectName, aiReleaseEnabled: aiReleaseEnabled).getReleaseNoteInfo(releaseNumber: releaseNumber, projectPath: projectPath, shell: shell) + return try ReleaseNotesHandler(picker: picker, projectName: projectName).getReleaseNoteInfo() } func maybeTrashReleaseNotes(_ info: ReleaseNoteInfo) throws { diff --git a/Sources/nnex/Domain/Handlers/ReleaseNotesHandler.swift b/Sources/nnex/Domain/Handlers/ReleaseNotesHandler.swift index ae3e153..d19a659 100644 --- a/Sources/nnex/Domain/Handlers/ReleaseNotesHandler.swift +++ b/Sources/nnex/Domain/Handlers/ReleaseNotesHandler.swift @@ -14,12 +14,10 @@ struct ReleaseNotesHandler { private let picker: NnexPicker private let projectName: String private let fileUtility: ReleaseNotesFileUtility - private let aiReleaseEnabled: Bool - init(picker: NnexPicker, projectName: String, aiReleaseEnabled: Bool = false, fileUtility: ReleaseNotesFileUtility? = nil) { + init(picker: NnexPicker, projectName: String, fileUtility: ReleaseNotesFileUtility? = nil) { self.picker = picker self.projectName = projectName - self.aiReleaseEnabled = aiReleaseEnabled self.fileUtility = fileUtility ?? ReleaseNotesFileUtility(picker: picker) } } @@ -27,10 +25,8 @@ struct ReleaseNotesHandler { // MARK: - Action extension ReleaseNotesHandler { - func getReleaseNoteInfo(releaseNumber: String? = nil, projectPath: String? = nil, shell: (any Shell)? = nil) throws -> ReleaseNoteInfo { - let availableOptions = aiReleaseEnabled ? NoteContentType.allCases : NoteContentType.allCases.filter { $0 != .aiGenerated } - - switch try picker.requiredSingleSelection(title: "How would you like to add your release notes for \(projectName)?", items: availableOptions) { + func getReleaseNoteInfo() throws -> ReleaseNoteInfo { + switch try picker.requiredSingleSelection(title: "How would you like to add your release notes for \(projectName)?", items: NoteContentType.allCases) { case .direct: let notes = try picker.getRequiredInput(prompt: "Enter your release notes.") @@ -43,19 +39,6 @@ extension ReleaseNotesHandler { let releaseNotesFile = try fileUtility.createAndOpenNewNoteFile(projectName: projectName) return try fileUtility.validateAndConfirmNoteFile(releaseNotesFile) - case .aiGenerated: - guard let releaseNumber, let projectPath, let shell else { - throw ReleaseNotesError.missingAIRequirements - } - - let aiHandler = AIReleaseNotesHandler( - projectName: projectName, - shell: shell, - picker: picker, - fileUtility: fileUtility - ) - - return try aiHandler.generateReleaseNotes(releaseNumber: releaseNumber, projectPath: projectPath) } } } @@ -63,7 +46,7 @@ extension ReleaseNotesHandler { extension ReleaseNotesHandler { enum NoteContentType: CaseIterable { - case direct, fromPath, createFile, aiGenerated + case direct, fromPath, createFile } } diff --git a/Sources/nnex/Infrastructure/Picker/DisplayablePickerItemConformance.swift b/Sources/nnex/Infrastructure/Picker/DisplayablePickerItemConformance.swift index 3c4138a..5a91035 100644 --- a/Sources/nnex/Infrastructure/Picker/DisplayablePickerItemConformance.swift +++ b/Sources/nnex/Infrastructure/Picker/DisplayablePickerItemConformance.swift @@ -40,8 +40,6 @@ extension ReleaseNotesHandler.NoteContentType: DisplayablePickerItem { return "Enter path to release notes file" case .createFile: return "Create a new file" - case .aiGenerated: - return "Generate with AI" } } } diff --git a/Tests/nnexTests/Domain/Handlers/AIReleaseNotesHandlerTests.swift b/Tests/nnexTests/Domain/Handlers/AIReleaseNotesHandlerTests.swift deleted file mode 100644 index 826d80c..0000000 --- a/Tests/nnexTests/Domain/Handlers/AIReleaseNotesHandlerTests.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// AIReleaseNotesHandlerTests.swift -// nnex -// -// Created by Nikolai Nobadi on 9/9/25. -// - -import Testing -import Foundation -import NnShellKit -import NnexSharedTestHelpers -import NnexKit -@testable import nnex -@preconcurrency import Files - -@MainActor -final class AIReleaseNotesHandlerTests { - private let projectName = "TestProject" - private let releaseNumber = "1.2.3" - private let projectFolder: Folder - private let testChangelog = """ - # Changelog - - ## [Unreleased] - - Some unreleased change - - ## [1.2.3] - 2025-09-09 - ### Added - - New feature X - - New feature Y - - ### Fixed - - Bug fix A - - ## [1.2.2] - 2025-09-01 - - Previous release - """ - - init() throws { - let tempFolder = Folder.temporary - self.projectFolder = try tempFolder.createSubfolder(named: "TestFolder\(UUID().uuidString)") - } - - deinit { - deleteFolderContents(projectFolder) - } -} - - -// MARK: - generateReleaseNotes Tests -extension AIReleaseNotesHandlerTests { - @Test("Returns existing changelog content when version found") - func returnsExistingChangelogContent() throws { - let (sut, _, _) = makeSUT() - - // Create CHANGELOG.md file in project folder - try createChangelog(content: testChangelog) - - let result = try sut.generateReleaseNotes(releaseNumber: releaseNumber, projectPath: projectFolder.path) - - #expect(result.isFromFile == false) - #expect(result.content.contains("New feature X")) - #expect(result.content.contains("Bug fix A")) - } - - @Test("Generates new notes when changelog exists but version not found") - func generatesNewNotesWhenVersionNotInChangelog() throws { - let changelogWithoutVersion = """ - # Changelog - - ## [Unreleased] - - Some change - - ## [1.0.0] - 2025-01-01 - - Old release - """ - - let testContent = "Generated release notes content" - let (sut, shell, _) = makeSUT( - fileContent: testContent, - permissionResponses: [true] - ) - - // Create CHANGELOG.md file without our version - try createChangelog(content: changelogWithoutVersion) - - let result = try sut.generateReleaseNotes(releaseNumber: releaseNumber, projectPath: projectFolder.path) - - // Verify Claude command was executed since version not found - #expect(shell.executedCommands.contains { $0.contains("claude code edit") }) - #expect(result.isFromFile == true) - } - - @Test("Handles empty file with successful retry") - func handlesEmptyFileWithRetry() throws { - let (sut, _, _) = makeSUT( - fileContent: "", // Initially empty - permissionResponses: [true, true], // Confirm creation, then confirm retry - shouldUseRetryFile: true - ) - - let result = try sut.generateReleaseNotes(releaseNumber: releaseNumber, projectPath: projectFolder.path) - - #expect(result.isFromFile == true) - } - - -} - - -// MARK: - CHANGELOG.md Parsing Tests -extension AIReleaseNotesHandlerTests { - @Test("Parses single section changelog correctly") - func parsesSingleSectionChangelog() throws { - let singleSectionChangelog = """ - # Changelog - - ## [1.2.3] - 2025-09-09 - ### Added - - Feature A - - Feature B - - ### Fixed - - Bug X - """ - - let (sut, _, _) = makeSUT() - - try createChangelog(content: singleSectionChangelog) - - let result = try sut.generateReleaseNotes(releaseNumber: releaseNumber, projectPath: projectFolder.path) - - #expect(result.content.contains("Feature A")) - #expect(result.content.contains("Bug X")) - #expect(!result.content.contains("## [1.2.3]")) // Should not include header - } - - @Test("Handles changelog with empty version section") - func handlesChangelogWithEmptyVersionSection() throws { - let emptyVersionChangelog = """ - # Changelog - - ## [1.2.3] - 2025-09-09 - - ## [1.2.2] - 2025-09-01 - - Previous content - """ - - let (sut, shell, _) = makeSUT( - fileContent: "Generated content", - permissionResponses: [true] - ) - - try createChangelog(content: emptyVersionChangelog) - - let result = try sut.generateReleaseNotes(releaseNumber: releaseNumber, projectPath: projectFolder.path) - - // Should generate new notes since version section is empty - #expect(shell.executedCommands.contains { $0.contains("claude code edit") }) - #expect(result.isFromFile == true) - } -} - - -// MARK: - SUT -private extension AIReleaseNotesHandlerTests { - func makeSUT( - fileContent: String = "Test content", - permissionResponses: [Bool] = [true], - shouldUseRetryFile: Bool = false - ) -> (sut: AIReleaseNotesHandler, shell: MockShell, picker: MockPicker) { - - let shell = MockShell() - - let picker = MockPicker( - selectedItemIndices: [], - inputResponses: [], - permissionResponses: permissionResponses, - shouldThrowError: false - ) - - let fileSystem: any FileSystemProvider - if shouldUseRetryFile { - fileSystem = MockFileSystemProviderWithRetry(fileContent: fileContent) - } else { - fileSystem = MockFileSystemProvider(fileContent: fileContent) - } - - let dateProvider = MockDateProvider(date: Date()) - - let fileUtility = ReleaseNotesFileUtility( - picker: picker, - fileSystem: fileSystem, - dateProvider: dateProvider - ) - - let sut = AIReleaseNotesHandler( - projectName: projectName, - shell: shell, - picker: picker, - fileUtility: fileUtility - ) - - return (sut, shell, picker) - } - - func createChangelog(content: String) throws { - try projectFolder.createFile(named: "CHANGELOG.md").write(content) - } -} - - -// MARK: - Test Helpers -private class MockFileSystemProviderWithRetry: FileSystemProvider { - private(set) var createdFileName: String = "" - private(set) var createdFilePath: String = "" - private let fileContent: String - - init(fileContent: String = "") { - self.fileContent = fileContent - } - - func createFile(in folderPath: String, named: String) throws -> FileProtocol { - createdFileName = named - createdFilePath = "\(folderPath)/\(named)" - - return MockFileWithRetry( - path: createdFilePath, - initialContent: "", - retryContent: "Test content after retry" - ) - } -} - -private class MockFileWithRetry: FileProtocol { - let path: String - private let initialContent: String - private let retryContent: String - private var readCount = 0 - - init(path: String, initialContent: String, retryContent: String) { - self.path = path - self.initialContent = initialContent - self.retryContent = retryContent - } - - func readAsString() throws -> String { - defer { readCount += 1 } - return readCount == 0 ? initialContent : retryContent - } -} diff --git a/Tests/nnexTests/Domain/Handlers/ReleaseNotesHandlerTests.swift b/Tests/nnexTests/Domain/Handlers/ReleaseNotesHandlerTests.swift index 6d42d99..1275953 100644 --- a/Tests/nnexTests/Domain/Handlers/ReleaseNotesHandlerTests.swift +++ b/Tests/nnexTests/Domain/Handlers/ReleaseNotesHandlerTests.swift @@ -111,131 +111,16 @@ extension ReleaseNotesHandlerTests { } -// MARK: - AI Tests -extension ReleaseNotesHandlerTests { - @Test("AI option can be selected when enabled") - func aiOptionCanBeSelectedWhenEnabled() throws { - // Test that we can create a ReleaseNotesHandler with AI enabled and the aiGenerated option - let (sut, _, _) = makeSUT( - selectedOption: .aiGenerated, - aiReleaseEnabled: true - ) - - // This should not throw during initialization, proving AI option is available when enabled - #expect(sut != nil) - - // Test that AI generation throws missing requirements error when parameters are missing - #expect(throws: ReleaseNotesError.missingAIRequirements) { - try sut.getReleaseNoteInfo() // No AI parameters provided - } - } - - @Test("AI option does not appear when disabled") - func aiOptionDoesNotAppearWhenDisabled() throws { - let sut = makeSUT(selectedOption: .direct, inputResponses: [testNotes]).sut - let result = try sut.getReleaseNoteInfo() - - // When AI is disabled, the picker should only have 3 options (direct, fromPath, createFile) - // The actual filtering happens in the picker selection logic - #expect(result.content == testNotes) - #expect(result.isFromFile == false) - } - - @Test("AI generation validates all parameters are present") - func aiGenerationValidatesAllParametersArePresent() throws { - let sut = makeSUT(selectedOption: .aiGenerated, aiReleaseEnabled: true).sut - - // Test that when all parameters are provided, it doesn't throw missingAIRequirements - // Note: This test focuses on parameter validation, not the actual AI generation - let tempFolder = Folder.temporary - let projectFolder = try tempFolder.createSubfolder(named: "TestAIProject\(UUID().uuidString)") - defer { deleteFolderContents(projectFolder) } - - // This should not throw missingAIRequirements error since all params are provided - // It may throw other errors related to the actual AI generation process, but that's expected - #expect(throws: (any Error).self) { - try sut.getReleaseNoteInfo( - releaseNumber: "1.0.0", - projectPath: projectFolder.path, - shell: MockShell() - ) - } - } - - @Test("AI generation throws error when missing release number") - func aiGenerationThrowsErrorWhenMissingReleaseNumber() throws { - let sut = makeSUT(selectedOption: .aiGenerated, aiReleaseEnabled: true).sut - - #expect(throws: ReleaseNotesError.missingAIRequirements) { - try sut.getReleaseNoteInfo( - releaseNumber: nil, - projectPath: "/test/path", - shell: MockShell() - ) - } - } - - @Test("AI generation throws error when missing project path") - func aiGenerationThrowsErrorWhenMissingProjectPath() throws { - let (sut, _, _) = makeSUT( - selectedOption: .aiGenerated, - aiReleaseEnabled: true - ) - - #expect(throws: ReleaseNotesError.missingAIRequirements) { - try sut.getReleaseNoteInfo( - releaseNumber: "1.0.0", - projectPath: nil, - shell: MockShell() - ) - } - } - - @Test("AI generation throws error when missing shell") - func aiGenerationThrowsErrorWhenMissingShell() throws { - let (sut, _, _) = makeSUT( - selectedOption: .aiGenerated, - aiReleaseEnabled: true - ) - - #expect(throws: ReleaseNotesError.missingAIRequirements) { - try sut.getReleaseNoteInfo( - releaseNumber: "1.0.0", - projectPath: "/test/path", - shell: nil - ) - } - } - - @Test("Backward compatibility - existing functionality works with new parameters") - func backwardCompatibilityWorksWithNewParameters() throws { - let (sut, _, _) = makeSUT( - selectedOption: .direct, - inputResponses: [testNotes], - aiReleaseEnabled: false - ) - - // Test that existing functionality still works when called with new parameters - let result = try sut.getReleaseNoteInfo( - releaseNumber: "1.0.0", - projectPath: "/test/path", - shell: MockShell() - ) - - #expect(result.content == testNotes) - #expect(result.isFromFile == false) - } -} // MARK: - SUT private extension ReleaseNotesHandlerTests { - func makeSUT(selectedOption: ReleaseNotesHandler.NoteContentType = .direct, inputResponses: [String] = [], permissionResponses: [Bool] = [], fileContent: String = "", shouldThrowPickerError: Bool = false, aiReleaseEnabled: Bool = false) -> (sut: ReleaseNotesHandler, picker: MockPicker, fileSystem: MockFileSystemProvider) { + func makeSUT(selectedOption: ReleaseNotesHandler.NoteContentType = .direct, inputResponses: [String] = [], permissionResponses: [Bool] = [], fileContent: String = "", shouldThrowPickerError: Bool = false) -> (sut: ReleaseNotesHandler, picker: MockPicker, fileSystem: MockFileSystemProvider) { let picker = MockPicker(selectedItemIndices: [selectedOption.index], inputResponses: inputResponses, permissionResponses: permissionResponses, shouldThrowError: shouldThrowPickerError) let fileSystem = MockFileSystemProvider(fileContent: fileContent) let dateProvider = MockDateProvider(date: testDate) let fileUtility = ReleaseNotesFileUtility(picker: picker, fileSystem: fileSystem, dateProvider: dateProvider) - let sut = ReleaseNotesHandler(picker: picker, projectName: projectName, aiReleaseEnabled: aiReleaseEnabled, fileUtility: fileUtility) + let sut = ReleaseNotesHandler(picker: picker, projectName: projectName, fileUtility: fileUtility) return (sut, picker, fileSystem) } @@ -252,8 +137,6 @@ private extension ReleaseNotesHandler.NoteContentType { return 1 case .createFile: return 2 - case .aiGenerated: - return 3 } } } diff --git a/Tests/nnexTests/PublishTests/PublishExecutionManagerTests.swift b/Tests/nnexTests/PublishTests/PublishExecutionManagerTests.swift index 6d17058..b7bc601 100644 --- a/Tests/nnexTests/PublishTests/PublishExecutionManagerTests.swift +++ b/Tests/nnexTests/PublishTests/PublishExecutionManagerTests.swift @@ -336,8 +336,7 @@ private extension PublishExecutionManagerTests { picker: picker, gitHandler: gitHandler, publishInfoLoader: publishInfoLoader, - trashHandler: trashHandler, - aiReleaseEnabled: false + trashHandler: trashHandler ) }