Compare commits

...

106 Commits

Author SHA1 Message Date
github-actions[bot]
c02718855d chore(release): Update version to v1.4.268 2025-07-26 23:03:37 +00:00
Kayvan Sylvan
4f16222b31 Merge pull request #1652 from ksylvan/0726-gemini-tts-voices
Implement Voice Selection for Gemini Text-to-Speech
2025-07-26 16:01:07 -07:00
Kayvan Sylvan
8c27b34d0f chore: differentiate voice descriptions 2025-07-26 15:58:29 -07:00
Changelog Bot
0b71b54698 chore: incoming 1652 changelog entry 2025-07-26 15:23:00 -07:00
Kayvan Sylvan
614b1322d5 feat: add Gemini TTS voice selection and listing functionality
## CHANGES

- Add `--voice` flag for TTS voice selection
- Add `--list-gemini-voices` command for voice discovery
- Implement voice validation for Gemini TTS models
- Update shell completions for voice options
- Add comprehensive Gemini TTS documentation
- Create voice samples directory structure
- Extend spell checker dictionary with voice names
2025-07-26 15:11:30 -07:00
github-actions[bot]
eab335873e chore(release): Update version to v1.4.267 2025-07-26 20:06:49 +00:00
Kayvan Sylvan
577dc9896d Merge pull request #1650 from ksylvan/0726-google-gemini-tts-support
Update Gemini Plugin to New SDK with TTS Support
2025-07-26 13:04:32 -07:00
Kayvan Sylvan
3a4bb4b9b2 fix: correct audio data extraction to avoid double byte conversion
## CHANGES

- Remove redundant byte conversion from audio data extraction
- Extract audio data as string before converting once
- Simplify audio data processing in chat handler
- Fix potential data corruption in audio output
2025-07-26 12:18:29 -07:00
Kayvan Sylvan
c766915764 fix: initialize Parts slice in genai.Content struct to prevent nil pointer errors
## CHANGES

- Initialize Parts slice with empty slice in Content struct
- Prevent potential nil pointer dereference during message conversion
- Ensure Parts field is ready for append operations
- Improve robustness of convertMessages function in Gemini client
2025-07-26 12:06:39 -07:00
Kayvan Sylvan
71c08648c6 chore: minor format fix 2025-07-26 11:33:50 -07:00
Kayvan Sylvan
95e2e6a5ac chore: more spelling words 2025-07-26 11:30:04 -07:00
Kayvan Sylvan
5cdf297d85 refactor: extract TTS methods and add audio validation with security limits
## CHANGES

- Extract text extraction logic into separate method
- Add GenAI client creation helper function
- Split TTS generation into focused helper methods
- Add audio data size validation with security limits
- Implement MIME type validation for audio responses
- Add WAV file generation input validation checks
- Pre-allocate buffer capacity for better performance
- Define audio constants for reusable configuration
- Add comprehensive error handling for edge cases
- Validate generated WAV data before returning results
2025-07-26 11:29:12 -07:00
Kayvan Sylvan
5d7137804a chore: update changelog generation to sync database
### CHANGES

- Add database sync command to changelog workflow
- Remove unnecessary newline addition in changelog processing
2025-07-26 11:14:02 -07:00
Changelog Bot
8b6b8fbd44 chore: incoming 1650 changelog entry 2025-07-26 11:07:12 -07:00
Kayvan Sylvan
3e75aa260f chore: update Gemini SDK to new genai library and add TTS audio output support
## CHANGES

- Replace deprecated generative-ai-go with google.golang.org/genai library
- Add TTS model detection and audio output validation
- Implement WAV file generation for TTS audio responses
- Add audio format checking utilities in CLI output
- Update Gemini client to support streaming with new SDK
- Add "Kore" and "subchunk" to VSCode spell checker dictionary
- Remove extra blank line from changelog formatting
- Update dependency imports and remove unused packages
2025-07-26 10:54:34 -07:00
github-actions[bot]
92aca524a4 chore(release): Update version to v1.4.266 2025-07-26 00:04:58 +00:00
Kayvan Sylvan
f70eff2e41 Merge pull request #1649 from ksylvan/0725-fix-ollama-and-bedrock-false-is-configured-during-setup
Fix Conditional API Initialization to Prevent Unnecessary Error Messages
2025-07-25 17:02:40 -07:00
Kayvan Sylvan
489c481acc docs: minor formatting of CHANGELOG 2025-07-25 16:59:51 -07:00
Changelog Bot
3a1eaf375f chore: incoming 1649 changelog entry 2025-07-25 16:58:59 -07:00
Kayvan Sylvan
52246dda28 feat: prevent unconfigured API initialization and add Docker test suite
## CHANGES

- Add BEDROCK_AWS_REGION requirement for Bedrock initialization
- Implement IsConfigured check for Ollama API URL
- Create comprehensive Docker testing environment with 6 scenarios
- Add interactive test runner with shell access
- Include environment files for different API configurations
- Update spell checker dictionary with new test terms
- Document testing workflow and expected results
2025-07-25 16:50:44 -07:00
github-actions[bot]
3c200e2883 chore(release): Update version to v1.4.265 2025-07-25 14:48:36 +00:00
Kayvan Sylvan
bda6505d5c Merge pull request #1647 from ksylvan/0725-fix-release-workflow-race-condition
Simplify Workflow with Single Version Retrieval Step
2025-07-25 07:45:58 -07:00
Changelog Bot
a241c98837 chore: incoming 1647 changelog entry 2025-07-25 07:39:52 -07:00
Kayvan Sylvan
12d7803044 chore: replace git tag lookup with version.nix file reading for release workflow
## CHANGES

- Remove OS-specific git tag retrieval steps
- Add unified version extraction from nix file
- Include version format validation with regex check
- Add error handling for missing version file
- Consolidate cross-platform version logic into single step
- Use bash shell for consistent version parsing
2025-07-25 07:33:18 -07:00
github-actions[bot]
d37a1acc9b chore(release): Update version to v1.4.264 2025-07-22 04:55:41 +00:00
Kayvan Sylvan
7254571501 Merge pull request #1642 from ksylvan/0721-fix-changelog-processing
Add --sync-db to `generate_changelog`, plus many fixes
2025-07-21 21:53:20 -07:00
Changelog Bot
c300262804 chore: incoming 1642 changelog entry 2025-07-21 21:17:44 -07:00
Kayvan Sylvan
e8ba57be90 fix: improve error message formatting in version date parsing
## CHANGES

- Add actual error details to date parsing failure message
- Include error variable in stderr output formatting
- Enhance debugging information for invalid date formats
2025-07-21 21:17:06 -07:00
Kayvan Sylvan
15fad3da87 refactor: simplify merge pattern management by removing unnecessary struct wrapper
## CHANGES

- Remove mergePatternManager struct wrapper for patterns
- Replace struct fields with package-level variables
- Simplify getMergePatterns function implementation
- Clean up merge commit detection documentation
- Reduce code complexity in pattern initialization
- Maintain thread-safe lazy initialization with sync.Once
2025-07-21 20:59:25 -07:00
Kayvan Sylvan
e2b0d3c368 chore: standardize logging output format and improve error messages in changelog generator
## CHANGES

- Replace emoji prefixes with bracketed text labels
- Standardize synchronization step logging format across methods
- Simplify version existence check error message text
- Update commit author email extraction comment clarity
- Maintain consistent stderr output formatting throughout sync process
2025-07-21 20:36:11 -07:00
Changelog Bot
3de85eb50e chore: incoming 1642 changelog entry 2025-07-21 20:26:17 -07:00
Kayvan Sylvan
58e635c873 refactor: improve error handling and simplify merge pattern management in changelog generation
## CHANGES

- Remove unused runtime import from processing.go
- Simplify date parsing error messages in cache
- Replace global merge pattern variables with struct
- Use sync.Once for thread-safe pattern initialization
- Remove OS-specific file deletion instructions from errors
- Clean up merge commit detection function documentation
- Eliminate redundant error variable in date parsing
2025-07-21 20:25:52 -07:00
Changelog Bot
dde21d2337 chore: incoming 1642 changelog entry 2025-07-21 20:08:33 -07:00
Kayvan Sylvan
e3fcbcb12b fix: improve error reporting in date parsing and merge commit detection
## CHANGES

- Capture first RFC3339Nano parsing error for better diagnostics
- Display both RFC3339Nano and RFC3339 errors in output
- Extract merge patterns to variable for cleaner code
- Improve error message clarity in date parsing failures
2025-07-21 20:08:13 -07:00
Kayvan Sylvan
839296e3ba feat: add cross-platform file removal instructions for changelog generation
## CHANGES

- Import runtime package for OS detection
- Add Windows-specific file deletion commands in error messages
- Provide both Command Prompt and PowerShell alternatives
- Maintain existing Unix/Linux rm command for non-Windows systems
- Improve user experience across different operating systems
2025-07-21 19:58:56 -07:00
Changelog Bot
5b97b0e56a chore: incoming 1642 changelog entry 2025-07-21 19:45:07 -07:00
Kayvan Sylvan
38ff2288da refactor: replace sync.Once with mutex for merge patterns initialization
## CHANGES

- Replace sync.Once with mutex and boolean flag
- Add thread-safe initialization check for merge patterns
- Remove overly broad merge pattern regex rule
- Improve error messaging for file removal failures
- Clarify filesystem vs git index error contexts
- Add detailed manual intervention instructions for failures
2025-07-21 19:44:46 -07:00
Changelog Bot
771a1ac2e6 chore: incoming 1642 changelog entry 2025-07-21 18:59:17 -07:00
Kayvan Sylvan
f6fd6f535a perf: optimize merge pattern matching with lazy initialization and sync.Once
## CHANGES

- Add sync package import for concurrency safety
- Implement lazy initialization for merge patterns using sync.Once
- Wrap merge patterns in getMergePatterns function
- Replace direct mergePatterns access with function call
- Ensure thread-safe pattern compilation on first use
2025-07-21 18:57:51 -07:00
Changelog Bot
f548ca5f82 chore: incoming 1642 changelog entry 2025-07-21 18:39:15 -07:00
Kayvan Sylvan
616f51748e refactor: improve merge commit detection and update error messages
## CHANGES

- Move merge patterns to package-level variables
- Update date parsing error message for clarity
- Simplify author email field comment
- Extract regex compilation from function scope
- Improve merge commit detection performance
- Clarify RFC3339 fallback error context
2025-07-21 18:37:31 -07:00
Kayvan Sylvan
db5aaf9da6 fix: improve warning message clarity for invalid commit timestamps
## CHANGES

- Simplify warning message for invalid commit timestamps
- Remove parenthetical explanation about git history rewrites
- Make error message more concise and readable
2025-07-21 18:24:03 -07:00
Kayvan Sylvan
a922032756 chore: optimize error logging and regex pattern matching for better performance
## CHANGES

- Remove redundant RFC3339Nano parsing error message
- Enhance RFC3339 error message with version name
- Pre-compile regex patterns for merge commit detection
- Replace regexp.MatchString with compiled pattern matching
- Improve merge commit pattern matching performance
- Add structured regex compilation for better efficiency
2025-07-21 18:21:23 -07:00
Kayvan Sylvan
a415409a48 fix: improve error handling and guidance for file removal failures
## CHANGES

- Replace generic warning with detailed error message
- Add step-by-step manual intervention instructions
- Provide multiple recovery options for users
- Separate git and filesystem error reporting
- Include specific commands for manual cleanup
2025-07-21 17:35:53 -07:00
Kayvan Sylvan
19d95b9014 docs: improve code comments for version pattern and PR commit fields
## CHANGES

- Expand version pattern regex documentation with examples
- Add matching and non-matching commit message examples
- Clarify version pattern behavior with optional prefix
- Update PR commit field comments for clarity
- Document email field availability from GitHub API
- Simplify timestamp and parents field descriptions
2025-07-21 17:21:28 -07:00
Kayvan Sylvan
73c7a8c147 feat: improve changelog entry creation and error messages
### CHANGES

- Rename `changelogDate` to `versionDate` for clarity
- Enhance error message for git index removal failure
- Add comments for `versionPattern` regex in `walker.go`
2025-07-21 17:14:14 -07:00
Changelog Bot
4dc84bd64d chore: incoming 1642 changelog entry 2025-07-21 17:00:03 -07:00
Kayvan Sylvan
dd96014f9b chore: improve error message clarity in changelog generation and cache operations
## CHANGES

- Clarify RFC3339Nano date parsing error message
- Improve PR batch cache save error description
- Add context for commit timestamp fallback warning
- Specify git index in file removal error message
2025-07-21 16:59:50 -07:00
Changelog Bot
3cf2557af3 chore: incoming 1642 changelog entry 2025-07-21 16:46:04 -07:00
Kayvan Sylvan
fcda0338cb chore: improve error logging and documentation in changelog generation components
## CHANGES

- Add date string to RFC3339 parsing error messages
- Enhance isMergeCommit function documentation with detailed explanation
- Document calculateVersionDate function with comprehensive behavior description
- Improve error context for date parsing failures
- Add implementation details for merge commit detection methods
- Clarify fallback behavior in version date calculation
2025-07-21 16:45:52 -07:00
Changelog Bot
ac19c81ef0 chore: incoming 1642 changelog entry 2025-07-21 16:37:49 -07:00
Kayvan Sylvan
a83d57065f chore: improve error message clarity in version existence check for git history sync
## CHANGES

- Enhance warning message with additional context details
- Add guidance for users when version check fails
- Improve error handling feedback in sync operation
- Provide actionable steps for troubleshooting sync issues
2025-07-21 16:37:32 -07:00
Changelog Bot
055ed32ab8 chore: incoming 1642 changelog entry 2025-07-21 16:15:16 -07:00
Kayvan Sylvan
8d62165444 feat: add email field support and improve error logging in changelog generation
## CHANGES

- Add Email field to PRCommit struct for author information
- Extract version date calculation into reusable helper function
- Redirect error messages from stdout to stderr properly
- Populate commit email from GitHub API responses correctly
- Add comprehensive test coverage for email field handling
- Remove duplicate version date calculation code blocks
- Import os package for proper stderr output handling
2025-07-21 16:14:12 -07:00
Kayvan Sylvan
63bc7a7e79 feat: improve timestamp handling and merge commit detection in changelog generator
## CHANGES

- Add debug logging for date parsing failures
- Pass forcePRSync parameter explicitly to fetchPRs method
- Implement comprehensive merge commit detection using parents
- Capture actual commit timestamps from GitHub API
- Calculate version dates from most recent commit
- Add parent commit SHAs for merge detection
- Use real commit dates instead of current time
- Add timestamp validation with fallback handling
2025-07-21 15:43:14 -07:00
Changelog Bot
f2b2501767 chore: incoming 1642 changelog entry 2025-07-21 14:38:32 -07:00
Kayvan Sylvan
be1e2485ee feat: add database synchronization and improve changelog processing workflow
## CHANGES

- Add database sync command with comprehensive validation
- Implement version and commit existence checking methods
- Enhance time parsing with RFC3339Nano fallback support
- Cache fetched PRs during changelog entry creation
- Remove individual incoming files using git operations
- Add sync-db flag for database integrity validation
- Improve commit-PR mapping verification process
- Exclude incoming directory from workflow trigger paths
2025-07-21 14:33:05 -07:00
Kayvan Sylvan
38c4211649 docs: clean up duplicate CHANGELOG for v1.4.262 2025-07-21 13:27:37 -07:00
Kayvan Sylvan
71e6355c10 docs: Update CHANGELOG after v1.4.263 2025-07-21 12:35:55 -07:00
github-actions[bot]
64411cdc02 chore(release): Update version to v1.4.263 2025-07-21 19:29:11 +00:00
Kayvan Sylvan
9a2ff983a4 Merge pull request #1641 from ksylvan/0721-web-timeout-fix
Fix Fabric Web timeout error
2025-07-21 12:26:53 -07:00
Changelog Bot
a522d4a411 chore: incoming 1641 changelog entry 2025-07-21 12:24:36 -07:00
Kayvan Sylvan
9bdd77c277 chore: extend proxy timeout in vite.config.ts to 15 minutes
### CHANGES

- Increase `/api` proxy timeout to 900,000 ms
- Increase `/names` proxy timeout to 900,000 ms
2025-07-21 12:16:15 -07:00
github-actions[bot]
cc68dddfe8 chore(release): Update version to v1.4.262 2025-07-21 18:20:58 +00:00
Kayvan Sylvan
07ee7f8b21 Merge pull request #1640 from ksylvan/0720-changelog-during-ci-cd
Implement Automated Changelog System for CI/CD Integration
2025-07-21 11:18:26 -07:00
Kayvan Sylvan
15a355f08a docs: Remove duplicated section. 2025-07-21 11:07:27 -07:00
Kayvan Sylvan
c50486b611 chore: fix tests for generate_changelog 2025-07-21 10:52:43 -07:00
Kayvan Sylvan
edaca7a045 chore: adjust insertVersionAtTop for consistent newline handling 2025-07-21 10:33:00 -07:00
Kayvan Sylvan
28432a50f0 chore: adjust newline handling in insertVersionAtTop method 2025-07-21 10:28:42 -07:00
Kayvan Sylvan
8ab891fcff chore: trim leading newline in changelog entry content 2025-07-21 10:16:31 -07:00
Kayvan Sylvan
cab6df88ea chore: simplify direct commits content handling in changelog generation 2025-07-21 09:56:17 -07:00
Kayvan Sylvan
42afd92f31 refactor: rename ProcessIncomingPRs to CreateNewChangelogEntry for clarity
## CHANGES

- Rename ProcessIncomingPRs method to CreateNewChangelogEntry
- Update method comment to reflect new name
- Update main.go to call renamed method
- Reduce newline spacing in content formatting
2025-07-21 09:37:02 -07:00
Changelog Bot
76d6b1721e chore: incoming 1640 changelog entry 2025-07-21 08:42:35 -07:00
Kayvan Sylvan
7d562096d1 fix: formatting fixes in tests. 2025-07-21 08:25:13 -07:00
Kayvan Sylvan
91c1aca0dd feat: enhance changelog generator to accept version parameter for PR processing
## CHANGES

- Pass version parameter to changelog generation workflow
- Update ProcessIncomingPRs method to accept version string
- Add commit SHA tracking to prevent duplicate entries
- Modify process-prs flag to require version parameter
- Improve changelog formatting with proper spacing
- Update configuration to use ProcessPRsVersion string field
- Enhance direct commit filtering with SHA exclusion
- Update documentation to reflect version parameter requirement
2025-07-21 07:36:30 -07:00
Kayvan Sylvan
b8008a34fb feat: enhance changelog generation to avoid duplicate commit entries
## CHANGES

- Extract PR numbers from processed changelog files
- Pass processed PRs map to direct commits function
- Filter out commits already included via PR files
- Reduce extra newlines in changelog version insertion
- Add strconv import for PR number parsing
- Prevent duplicate entries between PRs and direct commits
- Improve changelog formatting consistency
2025-07-20 22:49:05 -07:00
Kayvan Sylvan
482759ae72 fix: ensure the PR#.txt file ends with a newline. 2025-07-20 20:38:52 -07:00
Kayvan Sylvan
b0d096d0ea feat: change push behavior from opt-out to opt-in with GitHub token auth
## CHANGES

- Change `NoPush` config field to `Push` boolean
- Update CLI flag from `--no-push` to `--push`
- Add GitHub token authentication for push operations
- Import `os` and HTTP transport packages
- Reverse push logic to require explicit enable
- Update documentation for new push behavior
- Add automatic GitHub repository detection for auth
2025-07-20 20:33:23 -07:00
Kayvan Sylvan
e56ecfb7ae chore: update gitignore and simplify changelog generator error handling
## CHANGES

- Add .claude/ directory to gitignore exclusions
- Update comment clarity for SilenceUsage flag
- Remove redundant error handling in main function
- Simplify command execution without explicit error checking
2025-07-20 18:49:32 -07:00
Kayvan Sylvan
951bd134eb Fix CLI error handling and improve git status validation
- Add SilenceUsage to prevent help output on errors
- Add GetStatusDetails method to show which files are dirty
- Include direct commits in ProcessIncomingPRs for complete AI summaries
2025-07-20 18:20:53 -07:00
Kayvan Sylvan
7ff04658f3 chore: add automated changelog processing for CI/CD integration
## CHANGES

- Add incoming PR preprocessing with validation
- Implement release aggregation for incoming files
- Create git operations for staging changes
- Add comprehensive test coverage for processing
- Extend GitHub client with validation methods
- Support version detection from nix files
- Include documentation for automated workflow
- Add command flags for PR processing
2025-07-20 17:47:34 -07:00
Kayvan Sylvan
272f04dd32 docs: Update CHANGELOG after v1.4.261 2025-07-19 07:22:24 -07:00
github-actions[bot]
29cb3796bf Update version to v1.4.261 and commit 2025-07-19 14:20:50 +00:00
Kayvan Sylvan
f51f9e75a9 Merge pull request #1637 from ksylvan/0719-add-mistral-to-list-of-raw-mode-models
chore: update `NeedsRawMode` to include `mistral` prefix for Ollama
2025-07-19 07:19:13 -07:00
Kayvan Sylvan
63475784c7 chore: update NeedsRawMode to include mistral prefix
### CHANGES

- Add `mistral` to `ollamaPrefixes` list.
2025-07-19 07:13:23 -07:00
Kayvan Sylvan
1a7bb27370 docs: Update CHANGELOG after v1.4.260 2025-07-18 13:01:15 -07:00
github-actions[bot]
4badaa4c85 Update version to v1.4.260 and commit 2025-07-18 19:57:27 +00:00
Kayvan Sylvan
bf6be964fd Merge pull request #1634 from ksylvan/0718-fix-exo-labs-client
Fix abort in Exo-Labs provider plugin; with credit to @sakithahSenid
2025-07-18 12:55:58 -07:00
Kayvan Sylvan
cdbcb0a512 chore: add API key setup question to Exolab AI plugin configuration
## CHANGES

- Add "openaiapi" to VSCode spell check dictionary
- Include API key setup question in Exolab client
- Configure API key as required field for setup
- Maintain existing API base URL configuration order
2025-07-18 12:40:55 -07:00
Kayvan Sylvan
f81cf193a2 docs: Update CHANGELOG after v1.4.259 2025-07-18 12:01:19 -07:00
github-actions[bot]
cba56fcde6 Update version to v1.4.259 and commit 2025-07-18 18:57:13 +00:00
Kayvan Sylvan
72cbd13917 Merge pull request #1633 from ksylvan/0718-youtube-vtt-transcript-duplication-bug
YouTube VTT Processing Enhancement
2025-07-18 11:55:44 -07:00
Kayvan Sylvan
dc722f9724 feat: improve timestamp parsing to handle fractional seconds in YouTube tool
## CHANGES

- Move timestamp regex initialization to init function
- Add parseSeconds helper function for fractional seconds
- Replace direct strconv.Atoi calls with parseSeconds function
- Support decimal seconds in timestamp format parsing
- Extract seconds parsing logic into reusable function
2025-07-18 11:53:28 -07:00
Kayvan Sylvan
1a35f32a48 fix: Youtube VTT parsing gap test 2025-07-18 11:27:23 -07:00
Kayvan Sylvan
65bd2753c2 feat: enhance VTT duplicate filtering to allow legitimate repeated content
## CHANGES

- Fix regex escape sequence for timestamp parsing
- Add configurable time gap constant for repeat detection
- Track content with timestamps instead of simple deduplication
- Implement time-based repeat inclusion logic for choruses
- Add timestamp parsing helper functions for calculations
- Allow repeated content after significant time gaps
- Preserve legitimate recurring phrases while filtering duplicates
2025-07-18 11:08:37 -07:00
Kayvan Sylvan
570c9a9404 chore: refactor timestamp regex and seenSegments logic
### CHANGES

- Update `timestampRegex` to support optional seconds/milliseconds
- Change `seenSegments` to use `struct{}` for memory efficiency
- Refactor duplicate check using `struct{}` pattern
- Improve readability by restructuring timestamp logic
2025-07-18 09:44:31 -07:00
Kayvan Sylvan
15151fe9ee chore: refactor timestamp regex to global scope and add spell check words
## CHANGES

- Move timestamp regex to global package scope
- Remove duplicate regex compilation from isTimeStamp function
- Add "horts", "mbed", "WEBVTT", "youtu" to spell checker
- Improve regex performance by avoiding repeated compilation
- Clean up code organization in YouTube module
2025-07-18 09:33:46 -07:00
Kayvan Sylvan
2aad4caf9b fix: prevent duplicate segments in VTT file processing
- Add deduplication map to track seen segments
- Skip duplicate text segments in plain VTT processing
- Skip duplicate segments in timestamped VTT processing
- Improve timestamp regex to handle more formats
- Use clean text as deduplication key consistently
2025-07-18 09:17:03 -07:00
Kayvan Sylvan
289fda8c74 docs: Update CHANGELOG after v1.4.258 2025-07-17 15:30:50 -07:00
github-actions[bot]
fd40778472 Update version to v1.4.258 and commit 2025-07-17 22:28:08 +00:00
Kayvan Sylvan
bc1641a68c Merge pull request #1629 from ksylvan/0717-ensure-envFile
Create Default (empty) .env in ~/.config/fabric on Demand
2025-07-17 15:26:37 -07:00
Kayvan Sylvan
5cf15d22d3 chore: define constants for file and directory permissions 2025-07-17 15:14:15 -07:00
Kayvan Sylvan
2b2a25daaa chore: improve error handling in ensureEnvFile function 2025-07-17 15:00:26 -07:00
Kayvan Sylvan
75a7f25642 refactor: improve error handling and permissions in ensureEnvFile 2025-07-17 14:46:03 -07:00
Kayvan Sylvan
8bab58f225 feat: add startup check to initialize config and .env file
### CHANGES
- Introduce ensureEnvFile function to create ~/.config/fabric/.env if missing.
- Add directory creation for config path in ensureEnvFile.
- Integrate setup flag in CLI to call ensureEnvFile on demand.
- Handle errors for home directory detection and file operations.
2025-07-17 14:27:19 -07:00
Kayvan Sylvan
8ec006e02c docs: Update README and CHANGELOG after v1.4.257 2025-07-17 11:15:24 -07:00
50 changed files with 3820 additions and 220 deletions

View File

@@ -93,19 +93,24 @@ jobs:
name: fabric-windows-${{ matrix.arch }}.exe
path: fabric-windows-${{ matrix.arch }}.exe
- name: Get latest tag
if: matrix.os != 'windows-latest'
id: get_latest_tag
- name: Get version from source
id: get_version
shell: bash
run: |
latest_tag=$(git tag --sort=-creatordate | head -n 1)
echo "latest_tag=$latest_tag" >> $GITHUB_ENV
- name: Get latest tag
if: matrix.os == 'windows-latest'
id: get_latest_tag_windows
run: |
$latest_tag = git tag --sort=-creatordate | Select-Object -First 1
Add-Content -Path $env:GITHUB_ENV -Value "latest_tag=$latest_tag"
if [ ! -f "nix/pkgs/fabric/version.nix" ]; then
echo "Error: version.nix file not found"
exit 1
fi
version=$(cat nix/pkgs/fabric/version.nix | tr -d '"' | tr -cd '0-9.')
if [ -z "$version" ]; then
echo "Error: version is empty"
exit 1
fi
if ! echo "$version" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' > /dev/null; then
echo "Error: Invalid version format: $version"
exit 1
fi
echo "latest_tag=v$version" >> $GITHUB_ENV
- name: Create release if it doesn't exist
shell: bash

View File

@@ -9,6 +9,7 @@ on:
- "**/*.md"
- "data/strategies/**"
- "cmd/generate_changelog/*.db"
- "cmd/generate_changelog/incoming/*.txt"
- "scripts/pattern_descriptions/*.json"
- "web/static/data/pattern_descriptions.json"
@@ -83,14 +84,24 @@ jobs:
run: |
nix run .#gomod2nix -- --outdir nix/pkgs/fabric
- name: Generate Changelog Entry
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go run ./cmd/generate_changelog --process-prs ${{ env.new_tag }}
go run ./cmd/generate_changelog --sync-db
- name: Commit changes
run: |
# These files are modified by the version bump process
git add cmd/fabric/version.go
git add nix/pkgs/fabric/version.nix
git add nix/pkgs/fabric/gomod2nix.toml
git add .
# The changelog tool is responsible for staging CHANGELOG.md, changelog.db,
# and removing the incoming/ directory.
if ! git diff --staged --quiet; then
git commit -m "Update version to ${{ env.new_tag }} and commit $commit_hash"
git commit -m "chore(release): Update version to ${{ env.new_tag }}"
else
echo "No changes to commit."
fi

3
.gitignore vendored
View File

@@ -350,3 +350,6 @@ web/static/*.png
# Local tmp directory
.tmp/
tmp/
# Ignore .claude/
.claude/

33
.vscode/settings.json vendored
View File

@@ -1,21 +1,32 @@
{
"cSpell.words": [
"Achird",
"addextension",
"adduser",
"AIML",
"anthropics",
"Aoede",
"atotto",
"Autonoe",
"badfile",
"Behrens",
"blindspots",
"Bombal",
"Callirhoe",
"Callirrhoe",
"Cerebras",
"compadd",
"compdef",
"compinit",
"creatordate",
"curcontext",
"custompatterns",
"danielmiessler",
"davidanson",
"Debugf",
"dedup",
"deepseek",
"Despina",
"direnv",
"dryrun",
"dsrp",
@@ -23,11 +34,14 @@
"Eisler",
"elif",
"envrc",
"Erinome",
"Errorf",
"eugeis",
"Eugen",
"excalidraw",
"exolab",
"fabriclogo",
"flac",
"fpath",
"frequencypenalty",
"fsdb",
@@ -53,13 +67,16 @@
"hasura",
"hormozi",
"Hormozi's",
"horts",
"HTMLURL",
"jaredmontoya",
"jessevdk",
"Jina",
"joho",
"Kore",
"ksylvan",
"Langdock",
"Laomedeia",
"ldflags",
"libexec",
"listcontexts",
@@ -74,12 +91,16 @@
"markmap",
"matplotlib",
"mattn",
"mbed",
"Miessler",
"nometa",
"numpy",
"ollama",
"ollamaapi",
"openaiapi",
"opencode",
"openrouter",
"Orus",
"otiai",
"pdflatex",
"pipx",
@@ -88,11 +109,14 @@
"presencepenalty",
"printcontext",
"printsession",
"Pulcherrima",
"pycache",
"pyperclip",
"readystream",
"restapi",
"rmextension",
"Sadachbia",
"Sadaltager",
"samber",
"sashabaranov",
"sdist",
@@ -102,23 +126,30 @@
"storer",
"Streamlit",
"stretchr",
"subchunk",
"Sulafat",
"talkpanel",
"Telos",
"testpattern",
"testuser",
"Thacker",
"tidwall",
"topp",
"ttrc",
"unalias",
"unconfigured",
"unmarshalling",
"updatepatterns",
"videoid",
"webp",
"WEBVTT",
"wipecontext",
"wipesession",
"Worktree",
"writeups",
"xclip",
"yourpatternname"
"yourpatternname",
"youtu"
],
"cSpell.ignorePaths": ["go.mod", ".gitignore", "CHANGELOG.md"],
"markdownlint.config": {

View File

@@ -1,5 +1,151 @@
# Changelog
## v1.4.268 (2025-07-26)
### PR [#1652](https://github.com/danielmiessler/Fabric/pull/1652) by [ksylvan](https://github.com/ksylvan): Implement Voice Selection for Gemini Text-to-Speech
- Feat: add Gemini TTS voice selection and listing functionality
- Add `--voice` flag for TTS voice selection
- Add `--list-gemini-voices` command for voice discovery
- Implement voice validation for Gemini TTS models
- Update shell completions for voice options
## v1.4.267 (2025-07-26)
### PR [#1650](https://github.com/danielmiessler/Fabric/pull/1650) by [ksylvan](https://github.com/ksylvan): Update Gemini Plugin to New SDK with TTS Support
- Update Gemini SDK to new genai library and add TTS audio output support
- Replace deprecated generative-ai-go with google.golang.org/genai library
- Add TTS model detection and audio output validation
- Implement WAV file generation for TTS audio responses
- Add audio format checking utilities in CLI output
## v1.4.266 (2025-07-25)
### PR [#1649](https://github.com/danielmiessler/Fabric/pull/1649) by [ksylvan](https://github.com/ksylvan): Fix Conditional API Initialization to Prevent Unnecessary Error Messages
- Prevent unconfigured API initialization and add Docker test suite
- Add BEDROCK_AWS_REGION requirement for Bedrock initialization
- Implement IsConfigured check for Ollama API URL
- Create comprehensive Docker testing environment with 6 scenarios
- Add interactive test runner with shell access
## v1.4.265 (2025-07-25)
### PR [#1647](https://github.com/danielmiessler/Fabric/pull/1647) by [ksylvan](https://github.com/ksylvan): Simplify Workflow with Single Version Retrieval Step
- Replace git tag lookup with version.nix file reading for release workflow
- Remove OS-specific git tag retrieval steps and add unified version extraction from nix file
- Include version format validation with regex check
- Add error handling for missing version file
- Consolidate cross-platform version logic into single step with bash shell for consistent version parsing
## v1.4.264 (2025-07-22)
### PR [#1642](https://github.com/danielmiessler/Fabric/pull/1642) by [ksylvan](https://github.com/ksylvan): Add --sync-db to `generate_changelog`, plus many fixes
- Add database synchronization command with comprehensive validation and sync-db flag for database integrity validation
- Implement version and commit existence checking methods with enhanced time parsing using RFC3339Nano fallback support
- Improve timestamp handling and merge commit detection in changelog generator with comprehensive merge commit detection using parents
- Add email field support to PRCommit struct for author information and improve error logging throughout changelog generation
- Optimize merge pattern matching with lazy initialization and thread-safe pattern compilation for better performance
### Direct commits
- Chore: incoming 1642 changelog entry
- Fix: improve error message formatting in version date parsing
- Add actual error details to date parsing failure message
- Include error variable in stderr output formatting
- Enhance debugging information for invalid date formats
- Docs: Update CHANGELOG after v1.4.263
## v1.4.263 (2025-07-21)
### PR [#1641](https://github.com/danielmiessler/Fabric/pull/1641) by [ksylvan](https://github.com/ksylvan): Fix Fabric Web timeout error
- Chore: extend proxy timeout in `vite.config.ts` to 15 minutes
- Increase `/api` proxy timeout to 900,000 ms
- Increase `/names` proxy timeout to 900,000 ms
## v1.4.262 (2025-07-21)
### PR [#1640](https://github.com/danielmiessler/Fabric/pull/1640) by [ksylvan](https://github.com/ksylvan): Implement Automated Changelog System for CI/CD Integration
- Add automated changelog processing for CI/CD integration with comprehensive test coverage and GitHub client validation methods
- Implement release aggregation for incoming files with git operations for staging changes and support for version detection from nix files
- Change push behavior from opt-out to opt-in with GitHub token authentication and automatic repository detection
- Enhance changelog generation to avoid duplicate commit entries by extracting PR numbers and filtering commits already included via PR files
- Add version parameter requirement for PR processing with commit SHA tracking to prevent duplicate entries and improve formatting consistency
### Direct commits
- Docs: Update CHANGELOG after v1.4.261
## v1.4.261 (2025-07-19)
### PR [#1637](https://github.com/danielmiessler/Fabric/pull/1637) by [ksylvan](https://github.com/ksylvan): chore: update `NeedsRawMode` to include `mistral` prefix for Ollama
- Updated `NeedsRawMode` to include `mistral` prefix for Ollama compatibility
- Added `mistral` to `ollamaPrefixes` list for improved model support
### Direct commits
- Updated CHANGELOG after v1.4.260 release
## v1.4.260 (2025-07-18)
### PR [#1634](https://github.com/danielmiessler/Fabric/pull/1634) by [ksylvan](https://github.com/ksylvan): Fix abort in Exo-Labs provider plugin; with credit to @sakithahSenid
- Fix abort issue in Exo-Labs provider plugin
- Add API key setup question to Exolab AI plugin configuration
- Include API key setup question in Exolab client with required field validation
- Add "openaiapi" to VSCode spell check dictionary
- Maintain existing API base URL configuration order
### Direct commits
- Update CHANGELOG after v1.4.259
## v1.4.259 (2025-07-18)
### PR [#1633](https://github.com/danielmiessler/Fabric/pull/1633) by [ksylvan](https://github.com/ksylvan): YouTube VTT Processing Enhancement
- Fix: prevent duplicate segments in VTT file processing by adding deduplication map to track seen segments
- Feat: enhance VTT duplicate filtering to allow legitimate repeated content with configurable time gap detection
- Feat: improve timestamp parsing to handle fractional seconds and optional seconds/milliseconds formats
- Chore: refactor timestamp regex to global scope and improve performance by avoiding repeated compilation
- Fix: Youtube VTT parsing gap test and extract seconds parsing logic into reusable function
### Direct commits
- Docs: Update CHANGELOG after v1.4.258
## v1.4.258 (2025-07-17)
### PR [#1629](https://github.com/danielmiessler/Fabric/pull/1629) by [ksylvan](https://github.com/ksylvan): Create Default (empty) .env in ~/.config/fabric on Demand
- Add startup check to initialize config and .env file automatically
- Introduce ensureEnvFile function to create ~/.config/fabric/.env if missing
- Add directory creation for config path in ensureEnvFile
- Integrate setup flag in CLI to call ensureEnvFile on demand
- Improve error handling and permissions in ensureEnvFile function
### Direct commits
- Update README and CHANGELOG after v1.4.257
## v1.4.257 (2025-07-17)
### PR [#1628](https://github.com/danielmiessler/Fabric/pull/1628) by [ksylvan](https://github.com/ksylvan): Introduce CLI Flag to Disable OpenAI Responses API
- Add `--disable-responses-api` CLI flag for OpenAI control and llama-server compatibility
- Implement `SetResponsesAPIEnabled` method in OpenAI client with configuration control
- Update default config path to `~/.config/fabric/config.yaml`
- Add CLI completions for new API flag across zsh, bash, and fish shells
- Update CHANGELOG after v1.4.256 release
## v1.4.256 (2025-07-17)
### PR [#1624](https://github.com/danielmiessler/Fabric/pull/1624) by [ksylvan](https://github.com/ksylvan): Feature: Add Automatic ~/.fabric.yaml Config Detection

View File

@@ -544,10 +544,16 @@ Application Options:
--image-compression= Compression level 0-100 for JPEG/WebP formats (default: not set)
--image-background= Background type: opaque, transparent (default: opaque, only for
PNG/WebP)
--suppress-think Suppress text enclosed in thinking tags
--think-start-tag= Start tag for thinking sections (default: <think>)
--think-end-tag= End tag for thinking sections (default: </think>)
--disable-responses-api Disable OpenAI Responses API (default: false)
--voice= TTS voice name for supported models (e.g., Kore, Charon, Puck)
(default: Kore)
--list-gemini-voices List all available Gemini TTS voices
Help Options:
-h, --help Show this help message
```
## Our approach to prompting

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.4.257"
var version = "v1.4.268"

Binary file not shown.

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"os"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/git"
@@ -201,7 +202,14 @@ func (c *Cache) GetVersions() (map[string]*git.Version, error) {
}
if dateStr.Valid {
v.Date, _ = time.Parse(time.RFC3339, dateStr.String)
// Try RFC3339Nano first (for nanosecond precision), then fall back to RFC3339
v.Date, err = time.Parse(time.RFC3339Nano, dateStr.String)
if err != nil {
v.Date, err = time.Parse(time.RFC3339, dateStr.String)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing date '%s' for version '%s': %v. Expected format: RFC3339 or RFC3339Nano.\n", dateStr.String, v.Name, err)
}
}
}
if prNumbersJSON != "" {
@@ -260,6 +268,26 @@ func (c *Cache) Clear() error {
return nil
}
// VersionExists checks if a version already exists in the cache
func (c *Cache) VersionExists(version string) (bool, error) {
var count int
err := c.db.QueryRow("SELECT COUNT(*) FROM versions WHERE name = ?", version).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// CommitExists checks if a commit already exists in the cache
func (c *Cache) CommitExists(hash string) (bool, error) {
var count int
err := c.db.QueryRow("SELECT COUNT(*) FROM commits WHERE sha = ?", hash).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetLastPRSync returns the timestamp of the last PR sync
func (c *Cache) GetLastPRSync() (time.Time, error) {
var timestamp string

View File

@@ -65,7 +65,7 @@ func (g *Generator) Generate() (string, error) {
return "", fmt.Errorf("failed to collect data: %w", err)
}
if err := g.fetchPRs(); err != nil {
if err := g.fetchPRs(g.cfg.ForcePRSync); err != nil {
return "", fmt.Errorf("failed to fetch PRs: %w", err)
}
@@ -193,7 +193,7 @@ func (g *Generator) collectData() error {
return nil
}
func (g *Generator) fetchPRs() error {
func (g *Generator) fetchPRs(forcePRSync bool) error {
// First, load all cached PRs
if g.cache != nil {
cachedPRs, err := g.cache.GetAllPRs()
@@ -229,7 +229,7 @@ func (g *Generator) fetchPRs() error {
}
// If we have never synced or it's been more than 24 hours, do a full sync
// Also sync if we have versions with PR numbers that aren't cached
needsSync := lastSync.IsZero() || time.Since(lastSync) > 24*time.Hour || g.cfg.ForcePRSync || missingPRs
needsSync := lastSync.IsZero() || time.Since(lastSync) > 24*time.Hour || forcePRSync || missingPRs
if !needsSync {
fmt.Fprintf(os.Stderr, "Using cached PR data (last sync: %s)\n", lastSync.Format("2006-01-02 15:04:05"))
@@ -697,3 +697,109 @@ func hashContent(content string) string {
hash := sha256.Sum256([]byte(content))
return fmt.Sprintf("%x", hash)
}
// SyncDatabase performs a comprehensive database synchronization and validation
func (g *Generator) SyncDatabase() error {
if g.cache == nil {
return fmt.Errorf("cache is disabled, cannot sync database")
}
fmt.Fprintf(os.Stderr, "[SYNC] Starting database synchronization...\n")
// Step 1: Force PR sync (pass true explicitly)
fmt.Fprintf(os.Stderr, "[PR_SYNC] Forcing PR sync from GitHub...\n")
if err := g.fetchPRs(true); err != nil {
return fmt.Errorf("failed to sync PRs: %w", err)
}
// Step 2: Rebuild git history and verify versions/commits completeness
fmt.Fprintf(os.Stderr, "[VERIFY] Verifying git history and version completeness...\n")
if err := g.syncGitHistory(); err != nil {
return fmt.Errorf("failed to sync git history: %w", err)
}
// Step 3: Verify commit-PR mappings
fmt.Fprintf(os.Stderr, "[MAPPING] Verifying commit-PR mappings...\n")
if err := g.verifyCommitPRMappings(); err != nil {
return fmt.Errorf("failed to verify commit-PR mappings: %w", err)
}
fmt.Fprintf(os.Stderr, "[SUCCESS] Database synchronization completed successfully!\n")
return nil
}
// syncGitHistory walks the complete git history and ensures all versions and commits are cached
func (g *Generator) syncGitHistory() error {
// Walk complete git history (reuse existing logic)
versions, err := g.gitWalker.WalkHistory()
if err != nil {
return fmt.Errorf("failed to walk git history: %w", err)
}
// Save only new versions and commits (preserve existing data)
var newVersions, newCommits int
for _, version := range versions {
// Only save version if it doesn't exist
exists, err := g.cache.VersionExists(version.Name)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check existence of version %s: %v. This may affect the completeness of the sync operation.\n", version.Name, err)
continue
}
if !exists {
if err := g.cache.SaveVersion(version); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save version %s: %v\n", version.Name, err)
} else {
newVersions++
}
}
// Only save commits that don't exist
for _, commit := range version.Commits {
exists, err := g.cache.CommitExists(commit.SHA)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check commit %s existence: %v\n", commit.SHA, err)
continue
}
if !exists {
if err := g.cache.SaveCommit(commit, version.Name); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save commit %s: %v\n", commit.SHA, err)
} else {
newCommits++
}
}
}
}
// Update last processed tag
if latestTag, err := g.gitWalker.GetLatestTag(); err == nil && latestTag != "" {
if err := g.cache.SetLastProcessedTag(latestTag); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to update last processed tag: %v\n", err)
}
}
fmt.Fprintf(os.Stderr, " Added %d new versions and %d new commits (preserved existing data)\n", newVersions, newCommits)
return nil
}
// verifyCommitPRMappings ensures all PR commits have proper mappings
func (g *Generator) verifyCommitPRMappings() error {
// Get all cached PRs
allPRs, err := g.cache.GetAllPRs()
if err != nil {
return fmt.Errorf("failed to get cached PRs: %w", err)
}
// Convert to slice for batch operations (reuse existing logic)
var prSlice []*github.PR
for _, pr := range allPRs {
prSlice = append(prSlice, pr)
}
// Save commit-PR mappings (reuse existing logic)
if err := g.cache.SaveCommitPRMappings(prSlice); err != nil {
return fmt.Errorf("failed to save commit-PR mappings: %w", err)
}
fmt.Fprintf(os.Stderr, " Verified mappings for %d PRs\n", len(prSlice))
return nil
}

View File

@@ -0,0 +1,115 @@
package changelog
import (
"os"
"path/filepath"
"regexp"
"testing"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/config"
)
func TestDetectVersionFromNix(t *testing.T) {
tempDir := t.TempDir()
t.Run("version.nix exists", func(t *testing.T) {
versionNixContent := `"1.2.3"`
versionNixPath := filepath.Join(tempDir, "version.nix")
err := os.WriteFile(versionNixPath, []byte(versionNixContent), 0644)
if err != nil {
t.Fatalf("Failed to write version.nix: %v", err)
}
data, err := os.ReadFile(versionNixPath)
if err != nil {
t.Fatalf("Failed to read version.nix: %v", err)
}
versionRegex := regexp.MustCompile(`"([^"]+)"`)
matches := versionRegex.FindStringSubmatch(string(data))
if len(matches) <= 1 {
t.Fatalf("No version found in version.nix")
}
version := matches[1]
if version != "1.2.3" {
t.Errorf("Expected version 1.2.3, got %s", version)
}
})
}
func TestEnsureIncomingDir(t *testing.T) {
tempDir := t.TempDir()
incomingDir := filepath.Join(tempDir, "incoming")
cfg := &config.Config{
IncomingDir: incomingDir,
}
g := &Generator{cfg: cfg}
err := g.ensureIncomingDir()
if err != nil {
t.Fatalf("ensureIncomingDir failed: %v", err)
}
if _, err := os.Stat(incomingDir); os.IsNotExist(err) {
t.Errorf("Incoming directory was not created")
}
}
func TestInsertVersionAtTop(t *testing.T) {
tempDir := t.TempDir()
changelogPath := filepath.Join(tempDir, "CHANGELOG.md")
cfg := &config.Config{
RepoPath: tempDir,
}
g := &Generator{cfg: cfg}
t.Run("new changelog", func(t *testing.T) {
entry := "## v1.0.0 (2025-01-01)\n\n- Initial release"
err := g.insertVersionAtTop(entry)
if err != nil {
t.Fatalf("insertVersionAtTop failed: %v", err)
}
content, err := os.ReadFile(changelogPath)
if err != nil {
t.Fatalf("Failed to read changelog: %v", err)
}
expected := "# Changelog\n\n## v1.0.0 (2025-01-01)\n\n- Initial release\n"
if string(content) != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, string(content))
}
})
t.Run("existing changelog", func(t *testing.T) {
existingContent := "# Changelog\n\n## v0.9.0 (2024-12-01)\n\n- Previous release"
err := os.WriteFile(changelogPath, []byte(existingContent), 0644)
if err != nil {
t.Fatalf("Failed to write existing changelog: %v", err)
}
entry := "## v1.0.0 (2025-01-01)\n\n- New release"
err = g.insertVersionAtTop(entry)
if err != nil {
t.Fatalf("insertVersionAtTop failed: %v", err)
}
content, err := os.ReadFile(changelogPath)
if err != nil {
t.Fatalf("Failed to read changelog: %v", err)
}
expected := "# Changelog\n\n## v1.0.0 (2025-01-01)\n\n- New release\n## v0.9.0 (2024-12-01)\n\n- Previous release"
if string(content) != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, string(content))
}
})
}

View File

@@ -0,0 +1,82 @@
package changelog
import (
"testing"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/github"
)
func TestIsMergeCommit(t *testing.T) {
tests := []struct {
name string
commit github.PRCommit
expected bool
}{
{
name: "Regular commit with single parent",
commit: github.PRCommit{
SHA: "abc123",
Message: "Fix bug in user authentication",
Author: "John Doe",
Date: time.Now(),
Parents: []string{"def456"},
},
expected: false,
},
{
name: "Merge commit with multiple parents",
commit: github.PRCommit{
SHA: "abc123",
Message: "Merge pull request #42 from feature/auth",
Author: "GitHub",
Date: time.Now(),
Parents: []string{"def456", "ghi789"},
},
expected: true,
},
{
name: "Merge commit detected by message pattern only",
commit: github.PRCommit{
SHA: "abc123",
Message: "Merge pull request #123 from user/feature-branch",
Author: "GitHub",
Date: time.Now(),
Parents: []string{}, // Empty parents - fallback to message detection
},
expected: true,
},
{
name: "Merge branch commit pattern",
commit: github.PRCommit{
SHA: "abc123",
Message: "Merge branch 'feature' into main",
Author: "Developer",
Date: time.Now(),
Parents: []string{"def456"}, // Single parent but merge pattern
},
expected: true,
},
{
name: "Regular commit with no merge patterns",
commit: github.PRCommit{
SHA: "abc123",
Message: "Add new feature for user management",
Author: "Jane Doe",
Date: time.Now(),
Parents: []string{"def456"},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isMergeCommit(tt.commit)
if result != tt.expected {
t.Errorf("isMergeCommit() = %v, expected %v for commit: %s",
result, tt.expected, tt.commit.Message)
}
})
}
}

View File

@@ -0,0 +1,521 @@
package changelog
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/git"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/github"
)
var (
mergePatterns []*regexp.Regexp
mergePatternsOnce sync.Once
)
// getMergePatterns returns the compiled merge patterns, initializing them lazily
func getMergePatterns() []*regexp.Regexp {
mergePatternsOnce.Do(func() {
mergePatterns = []*regexp.Regexp{
regexp.MustCompile(`^Merge pull request #\d+`), // "Merge pull request #123 from..."
regexp.MustCompile(`^Merge branch '.*' into .*`), // "Merge branch 'feature' into main"
regexp.MustCompile(`^Merge remote-tracking branch`), // "Merge remote-tracking branch..."
regexp.MustCompile(`^Merge '.*' into .*`), // "Merge 'feature' into main"
}
})
return mergePatterns
}
// isMergeCommit determines if a commit is a merge commit based on its parents and message patterns.
func isMergeCommit(commit github.PRCommit) bool {
// Primary method: Check parent count (merge commits have multiple parents)
if len(commit.Parents) > 1 {
return true
}
// Fallback method: Check commit message patterns
mergePatterns := getMergePatterns()
for _, pattern := range mergePatterns {
if pattern.MatchString(commit.Message) {
return true
}
}
return false
}
// calculateVersionDate determines the version date based on the most recent commit date from the provided PRs.
//
// If no valid commit dates are found, the function falls back to the current time.
// The function iterates through the provided PRs and their associated commits, comparing commit dates
// to identify the most recent one. If a valid date is found, it is returned; otherwise, the fallback is used.
func calculateVersionDate(fetchedPRs []*github.PR) time.Time {
versionDate := time.Now() // fallback to current time
if len(fetchedPRs) > 0 {
var mostRecentCommitDate time.Time
for _, pr := range fetchedPRs {
for _, commit := range pr.Commits {
if commit.Date.After(mostRecentCommitDate) {
mostRecentCommitDate = commit.Date
}
}
}
if !mostRecentCommitDate.IsZero() {
versionDate = mostRecentCommitDate
}
}
return versionDate
}
// ProcessIncomingPR processes a single PR for changelog entry creation
func (g *Generator) ProcessIncomingPR(prNumber int) error {
if err := g.validatePRState(prNumber); err != nil {
return fmt.Errorf("PR validation failed: %w", err)
}
if err := g.validateGitStatus(); err != nil {
return fmt.Errorf("git status validation failed: %w", err)
}
// Now fetch the full PR with commits for content generation
pr, err := g.ghClient.GetPRWithCommits(prNumber)
if err != nil {
return fmt.Errorf("failed to fetch PR %d: %w", prNumber, err)
}
content := g.formatPR(pr)
if g.cfg.EnableAISummary {
aiContent, err := SummarizeVersionContent(content)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: AI summarization failed: %v\n", err)
} else if !checkForAIError(aiContent) {
content = strings.TrimSpace(aiContent)
}
}
if err := g.ensureIncomingDir(); err != nil {
return fmt.Errorf("failed to create incoming directory: %w", err)
}
filename := filepath.Join(g.cfg.IncomingDir, fmt.Sprintf("%d.txt", prNumber))
// Ensure content ends with a single newline
content = strings.TrimSpace(content) + "\n"
if err := os.WriteFile(filename, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write incoming file: %w", err)
}
if err := g.commitAndPushIncoming(prNumber, filename); err != nil {
return fmt.Errorf("failed to commit and push: %w", err)
}
fmt.Printf("Successfully created incoming changelog entry: %s\n", filename)
return nil
}
// CreateNewChangelogEntry aggregates all incoming PR files for release and includes direct commits
func (g *Generator) CreateNewChangelogEntry(version string) error {
files, err := filepath.Glob(filepath.Join(g.cfg.IncomingDir, "*.txt"))
if err != nil {
return fmt.Errorf("failed to scan incoming directory: %w", err)
}
var content strings.Builder
var processingErrors []string
// First, aggregate all incoming PR files
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
processingErrors = append(processingErrors, fmt.Sprintf("failed to read %s: %v", file, err))
continue // Continue to attempt processing other files
}
content.WriteString(string(data))
// Note: No extra newline needed here as each incoming file already ends with a newline
}
if len(processingErrors) > 0 {
return fmt.Errorf("encountered errors while processing incoming files: %s", strings.Join(processingErrors, "; "))
}
// Extract PR numbers and their commit SHAs from processed files to avoid including their commits as "direct"
processedPRs := make(map[int]bool)
processedCommitSHAs := make(map[string]bool)
var fetchedPRs []*github.PR
var prNumbers []int
for _, file := range files {
// Extract PR number from filename (e.g., "1640.txt" -> 1640)
filename := filepath.Base(file)
if prNumStr := strings.TrimSuffix(filename, ".txt"); prNumStr != filename {
if prNum, err := strconv.Atoi(prNumStr); err == nil {
processedPRs[prNum] = true
prNumbers = append(prNumbers, prNum)
// Fetch the PR to get its commit SHAs
if pr, err := g.ghClient.GetPRWithCommits(prNum); err == nil {
fetchedPRs = append(fetchedPRs, pr)
for _, commit := range pr.Commits {
processedCommitSHAs[commit.SHA] = true
}
}
}
}
}
// Now add direct commits since the last release, excluding commits from processed PRs
directCommitsContent, err := g.getDirectCommitsSinceLastRelease(processedPRs, processedCommitSHAs)
if err != nil {
return fmt.Errorf("failed to get direct commits since last release: %w", err)
}
content.WriteString(directCommitsContent)
// Check if we have any content at all
if content.Len() == 0 {
if len(files) == 0 {
fmt.Fprintf(os.Stderr, "No incoming PR files found in %s and no direct commits since last release\n", g.cfg.IncomingDir)
} else {
fmt.Fprintf(os.Stderr, "No content found in incoming files and no direct commits since last release\n")
}
return nil
}
// Calculate the version date for the changelog entry as the most recent commit date from processed PRs
versionDate := calculateVersionDate(fetchedPRs)
entry := fmt.Sprintf("## %s (%s)\n\n%s",
version, versionDate.Format("2006-01-02"), strings.TrimLeft(content.String(), "\n"))
if err := g.insertVersionAtTop(entry); err != nil {
return fmt.Errorf("failed to update CHANGELOG.md: %w", err)
}
if g.cache != nil {
// Cache the fetched PRs using the same logic as normal changelog generation
if len(fetchedPRs) > 0 {
// Save PRs to cache
if err := g.cache.SavePRBatch(fetchedPRs); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save PR batch to cache: %v\n", err)
}
// Save SHA→PR mappings for lightning-fast git operations
if err := g.cache.SaveCommitPRMappings(fetchedPRs); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to cache commit mappings: %v\n", err)
}
// Save individual commits to cache for each PR
for _, pr := range fetchedPRs {
for _, commit := range pr.Commits {
// Use actual commit timestamp, with fallback to current time if invalid
commitDate := commit.Date
if commitDate.IsZero() {
commitDate = time.Now()
fmt.Fprintf(os.Stderr, "Warning: Commit %s has invalid timestamp, using current time as fallback\n", commit.SHA)
}
// Convert github.PRCommit to git.Commit
gitCommit := &git.Commit{
SHA: commit.SHA,
Message: commit.Message,
Author: commit.Author,
Email: commit.Email, // Use email from GitHub API
Date: commitDate, // Use actual commit timestamp from GitHub API
IsMerge: isMergeCommit(commit), // Detect merge commits using parents and message patterns
PRNumber: pr.Number,
}
if err := g.cache.SaveCommit(gitCommit, version); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save commit %s to cache: %v\n", commit.SHA, err)
}
}
}
}
// Create a proper new version entry for the database
newVersionEntry := &git.Version{
Name: version,
Date: versionDate, // Use most recent commit date instead of current time
CommitSHA: "", // Will be set when the release commit is made
PRNumbers: prNumbers, // Now we have the actual PR numbers
AISummary: content.String(),
}
if err := g.cache.SaveVersion(newVersionEntry); err != nil {
return fmt.Errorf("failed to save new version entry to database: %w", err)
}
}
for _, file := range files {
// Convert to relative path for git operations
relativeFile, err := filepath.Rel(g.cfg.RepoPath, file)
if err != nil {
relativeFile = file
}
// Use git remove to handle both filesystem and git index
if err := g.gitWalker.RemoveFile(relativeFile); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to remove %s from git index: %v\n", relativeFile, err)
// Fallback to filesystem-only removal
if err := os.Remove(file); err != nil {
fmt.Fprintf(os.Stderr, "Error: Failed to remove %s from the filesystem after failing to remove it from the git index.\n", relativeFile)
fmt.Fprintf(os.Stderr, "Filesystem error: %v\n", err)
fmt.Fprintf(os.Stderr, "Manual intervention required:\n")
fmt.Fprintf(os.Stderr, " 1. Remove the file %s manually (using the OS-specific command)\n", file)
fmt.Fprintf(os.Stderr, " 2. Remove from git index: git rm --cached %s\n", relativeFile)
fmt.Fprintf(os.Stderr, " 3. Or reset git index: git reset HEAD %s\n", relativeFile)
}
}
}
if err := g.stageChangesForRelease(); err != nil {
return fmt.Errorf("critical: failed to stage changes for release: %w", err)
}
fmt.Printf("Successfully processed %d incoming PR files for version %s\n", len(files), version)
return nil
}
// getDirectCommitsSinceLastRelease gets all direct commits (not part of PRs) since the last release
func (g *Generator) getDirectCommitsSinceLastRelease(processedPRs map[int]bool, processedCommitSHAs map[string]bool) (string, error) {
// Get the latest tag to determine what commits are unreleased
latestTag, err := g.gitWalker.GetLatestTag()
if err != nil {
return "", fmt.Errorf("failed to get latest tag: %w", err)
}
// Get all commits since the latest tag
unreleasedVersion, err := g.gitWalker.WalkCommitsSinceTag(latestTag)
if err != nil {
return "", fmt.Errorf("failed to walk commits since tag %s: %w", latestTag, err)
}
if unreleasedVersion == nil || len(unreleasedVersion.Commits) == 0 {
return "", nil // No unreleased commits
}
// Filter out commits that are part of PRs (we already have those from incoming files)
// and format the direct commits
var directCommits []*git.Commit
for _, commit := range unreleasedVersion.Commits {
// Skip version bump commits
if commit.IsVersion {
continue
}
// Skip commits that belong to PRs we've already processed from incoming files (by PR number)
if commit.PRNumber > 0 && processedPRs[commit.PRNumber] {
continue
}
// Skip commits whose SHA is already included in processed PRs (this catches commits
// that might not have been detected as part of a PR but are actually in the PR)
if processedCommitSHAs[commit.SHA] {
continue
}
// Only include commits that are NOT part of any PR (direct commits)
if commit.PRNumber == 0 {
directCommits = append(directCommits, commit)
}
}
if len(directCommits) == 0 {
return "", nil // No direct commits
}
// Format the direct commits similar to how it's done in generateRawVersionContent
var sb strings.Builder
sb.WriteString("### Direct commits\n\n")
// Sort direct commits by date (newest first) for consistent ordering
sort.Slice(directCommits, func(i, j int) bool {
return directCommits[i].Date.After(directCommits[j].Date)
})
for _, commit := range directCommits {
message := g.formatCommitMessage(strings.TrimSpace(commit.Message))
if message != "" && !g.isDuplicateMessage(message, directCommits) {
sb.WriteString(fmt.Sprintf("- %s\n", message))
}
}
return sb.String(), nil
}
// validatePRState validates that a PR is in the correct state for processing
func (g *Generator) validatePRState(prNumber int) error {
// Use lightweight validation call that doesn't fetch commits
details, err := g.ghClient.GetPRValidationDetails(prNumber)
if err != nil {
return fmt.Errorf("failed to fetch PR %d: %w", prNumber, err)
}
if details.State != "open" {
return fmt.Errorf("PR %d is not open (current state: %s)", prNumber, details.State)
}
if !details.Mergeable {
return fmt.Errorf("PR %d is not mergeable - please resolve conflicts first", prNumber)
}
return nil
}
// validateGitStatus ensures the working directory is clean
func (g *Generator) validateGitStatus() error {
isClean, err := g.gitWalker.IsWorkingDirectoryClean()
if err != nil {
return fmt.Errorf("failed to check git status: %w", err)
}
if !isClean {
// Get detailed status for better error message
statusDetails, statusErr := g.gitWalker.GetStatusDetails()
if statusErr == nil && statusDetails != "" {
return fmt.Errorf("working directory is not clean - please commit or stash changes before proceeding:\n%s", statusDetails)
}
return fmt.Errorf("working directory is not clean - please commit or stash changes before proceeding")
}
return nil
}
// ensureIncomingDir creates the incoming directory if it doesn't exist
func (g *Generator) ensureIncomingDir() error {
if err := os.MkdirAll(g.cfg.IncomingDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", g.cfg.IncomingDir, err)
}
return nil
}
// commitAndPushIncoming commits and optionally pushes the incoming changelog file
func (g *Generator) commitAndPushIncoming(prNumber int, filename string) error {
relativeFilename, err := filepath.Rel(g.cfg.RepoPath, filename)
if err != nil {
relativeFilename = filename
}
// Add file to git index
if err := g.gitWalker.AddFile(relativeFilename); err != nil {
return fmt.Errorf("failed to add file %s: %w", relativeFilename, err)
}
// Commit changes
commitMessage := fmt.Sprintf("chore: incoming %d changelog entry", prNumber)
_, err = g.gitWalker.CommitChanges(commitMessage)
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
// Push to remote if enabled
if g.cfg.Push {
if err := g.gitWalker.PushToRemote(); err != nil {
return fmt.Errorf("failed to push to remote: %w", err)
}
} else {
fmt.Println("Commit created successfully. Please review and push manually.")
}
return nil
}
// detectVersion detects the current version from version.nix or git tags
func (g *Generator) detectVersion() (string, error) {
versionNixPath := filepath.Join(g.cfg.RepoPath, "version.nix")
if _, err := os.Stat(versionNixPath); err == nil {
data, err := os.ReadFile(versionNixPath)
if err != nil {
return "", fmt.Errorf("failed to read version.nix: %w", err)
}
versionRegex := regexp.MustCompile(`"([^"]+)"`)
matches := versionRegex.FindStringSubmatch(string(data))
if len(matches) > 1 {
return matches[1], nil
}
}
latestTag, err := g.gitWalker.GetLatestTag()
if err != nil {
return "", fmt.Errorf("failed to get latest tag: %w", err)
}
if latestTag == "" {
return "v1.0.0", nil
}
return latestTag, nil
}
// insertVersionAtTop inserts a new version entry at the top of CHANGELOG.md
func (g *Generator) insertVersionAtTop(entry string) error {
changelogPath := filepath.Join(g.cfg.RepoPath, "CHANGELOG.md")
header := "# Changelog"
headerRegex := regexp.MustCompile(`(?m)^# Changelog\s*`)
existingContent, err := os.ReadFile(changelogPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to read existing CHANGELOG.md: %w", err)
}
// File doesn't exist, create it.
newContent := fmt.Sprintf("%s\n\n%s\n", header, entry)
return os.WriteFile(changelogPath, []byte(newContent), 0644)
}
contentStr := string(existingContent)
var newContent string
if loc := headerRegex.FindStringIndex(contentStr); loc != nil {
// Found the header, insert after it.
insertionPoint := loc[1]
// Skip any existing newlines after the header to avoid double spacing
for insertionPoint < len(contentStr) && (contentStr[insertionPoint] == '\n' || contentStr[insertionPoint] == '\r') {
insertionPoint++
}
// Insert with proper spacing: single newline after header, then entry, then newline before existing content
newContent = contentStr[:loc[1]] + entry + "\n" + contentStr[insertionPoint:]
} else {
// Header not found, prepend everything.
newContent = fmt.Sprintf("%s\n\n%s\n\n%s", header, entry, contentStr)
}
return os.WriteFile(changelogPath, []byte(newContent), 0644)
}
// stageChangesForRelease stages the modified files for the release commit
func (g *Generator) stageChangesForRelease() error {
changelogPath := filepath.Join(g.cfg.RepoPath, "CHANGELOG.md")
relativeChangelog, err := filepath.Rel(g.cfg.RepoPath, changelogPath)
if err != nil {
relativeChangelog = "CHANGELOG.md"
}
relativeCacheFile, err := filepath.Rel(g.cfg.RepoPath, g.cfg.CacheFile)
if err != nil {
relativeCacheFile = g.cfg.CacheFile
}
// Add CHANGELOG.md to git index
if err := g.gitWalker.AddFile(relativeChangelog); err != nil {
return fmt.Errorf("failed to add %s: %w", relativeChangelog, err)
}
// Add cache file to git index
if err := g.gitWalker.AddFile(relativeCacheFile); err != nil {
return fmt.Errorf("failed to add %s: %w", relativeCacheFile, err)
}
// Note: Individual incoming files are now removed during the main processing loop
// No need to remove the entire directory here
return nil
}

View File

@@ -0,0 +1,262 @@
package changelog
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/config"
)
func TestDetectVersion(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
versionNixContent string
expectedVersion string
shouldError bool
}{
{
name: "valid version.nix",
versionNixContent: `"1.2.3"`,
expectedVersion: "1.2.3",
shouldError: false,
},
{
name: "version with extra whitespace",
versionNixContent: `"1.2.3" `,
expectedVersion: "1.2.3",
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create version.nix file
versionNixPath := filepath.Join(tempDir, "version.nix")
if err := os.WriteFile(versionNixPath, []byte(tt.versionNixContent), 0644); err != nil {
t.Fatalf("Failed to create version.nix: %v", err)
}
cfg := &config.Config{
RepoPath: tempDir,
}
g := &Generator{cfg: cfg}
version, err := g.detectVersion()
if tt.shouldError && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.shouldError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if version != tt.expectedVersion {
t.Errorf("Expected version '%s', got '%s'", tt.expectedVersion, version)
}
// Clean up
os.Remove(versionNixPath)
})
}
}
func TestInsertVersionAtTop_ImprovedRobustness(t *testing.T) {
tempDir := t.TempDir()
changelogPath := filepath.Join(tempDir, "CHANGELOG.md")
cfg := &config.Config{
RepoPath: tempDir,
}
g := &Generator{cfg: cfg}
tests := []struct {
name string
existingContent string
entry string
expectedContent string
}{
{
name: "header with trailing spaces",
existingContent: "# Changelog \n\n## v1.0.0\n- Old content",
entry: "## v2.0.0\n- New content",
expectedContent: "# Changelog \n\n## v2.0.0\n- New content\n## v1.0.0\n- Old content",
},
{
name: "header with different line endings",
existingContent: "# Changelog\r\n\r\n## v1.0.0\r\n- Old content",
entry: "## v2.0.0\n- New content",
expectedContent: "# Changelog\r\n\r\n## v2.0.0\n- New content\n## v1.0.0\r\n- Old content",
},
{
name: "no existing header",
existingContent: "Some existing content without header",
entry: "## v1.0.0\n- New content",
expectedContent: "# Changelog\n\n## v1.0.0\n- New content\n\nSome existing content without header",
},
{
name: "new file creation",
existingContent: "",
entry: "## v1.0.0\n- Initial release",
expectedContent: "# Changelog\n\n## v1.0.0\n- Initial release\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Write existing content (or create empty file)
if tt.existingContent != "" {
if err := os.WriteFile(changelogPath, []byte(tt.existingContent), 0644); err != nil {
t.Fatalf("Failed to write existing content: %v", err)
}
} else {
// Remove file if it exists to test new file creation
os.Remove(changelogPath)
}
// Insert new version
if err := g.insertVersionAtTop(tt.entry); err != nil {
t.Fatalf("insertVersionAtTop failed: %v", err)
}
// Read result
result, err := os.ReadFile(changelogPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
if string(result) != tt.expectedContent {
t.Errorf("Expected:\n%q\nGot:\n%q", tt.expectedContent, string(result))
}
})
}
}
func TestProcessIncomingPRs_FileAggregation(t *testing.T) {
tempDir := t.TempDir()
incomingDir := filepath.Join(tempDir, "incoming")
// Create incoming directory and files
if err := os.MkdirAll(incomingDir, 0755); err != nil {
t.Fatalf("Failed to create incoming dir: %v", err)
}
// Create test incoming files
file1Content := "## PR #1\n- Feature A"
file2Content := "## PR #2\n- Feature B"
if err := os.WriteFile(filepath.Join(incomingDir, "1.txt"), []byte(file1Content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
if err := os.WriteFile(filepath.Join(incomingDir, "2.txt"), []byte(file2Content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test file aggregation logic by calling the internal functions
files, err := filepath.Glob(filepath.Join(incomingDir, "*.txt"))
if err != nil {
t.Fatalf("Failed to glob files: %v", err)
}
if len(files) != 2 {
t.Fatalf("Expected 2 files, got %d", len(files))
}
// Test content aggregation
var content strings.Builder
var processingErrors []string
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
processingErrors = append(processingErrors, err.Error())
continue
}
content.WriteString(string(data))
content.WriteString("\n")
}
if len(processingErrors) > 0 {
t.Fatalf("Unexpected processing errors: %v", processingErrors)
}
aggregatedContent := content.String()
if !strings.Contains(aggregatedContent, "Feature A") {
t.Errorf("Aggregated content should contain 'Feature A'")
}
if !strings.Contains(aggregatedContent, "Feature B") {
t.Errorf("Aggregated content should contain 'Feature B'")
}
}
func TestFileProcessing_ErrorHandling(t *testing.T) {
tempDir := t.TempDir()
incomingDir := filepath.Join(tempDir, "incoming")
// Create incoming directory with one good file and one unreadable file
if err := os.MkdirAll(incomingDir, 0755); err != nil {
t.Fatalf("Failed to create incoming dir: %v", err)
}
// Create a good file
if err := os.WriteFile(filepath.Join(incomingDir, "1.txt"), []byte("content"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Create an unreadable file (simulate permission error)
unreadableFile := filepath.Join(incomingDir, "2.txt")
if err := os.WriteFile(unreadableFile, []byte("content"), 0000); err != nil {
t.Fatalf("Failed to create unreadable file: %v", err)
}
defer os.Chmod(unreadableFile, 0644) // Clean up
// Test error aggregation logic
files, err := filepath.Glob(filepath.Join(incomingDir, "*.txt"))
if err != nil {
t.Fatalf("Failed to glob files: %v", err)
}
var content strings.Builder
var processingErrors []string
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
processingErrors = append(processingErrors, err.Error())
continue
}
content.WriteString(string(data))
content.WriteString("\n")
}
if len(processingErrors) == 0 {
t.Errorf("Expected processing errors due to unreadable file")
}
// Verify error message format
errorMsg := strings.Join(processingErrors, "; ")
if !strings.Contains(errorMsg, "2.txt") {
t.Errorf("Error message should mention the problematic file")
}
}
func TestEnsureIncomingDirCreation(t *testing.T) {
tempDir := t.TempDir()
incomingDir := filepath.Join(tempDir, "incoming")
cfg := &config.Config{
IncomingDir: incomingDir,
}
g := &Generator{cfg: cfg}
err := g.ensureIncomingDir()
if err != nil {
t.Fatalf("ensureIncomingDir failed: %v", err)
}
if _, err := os.Stat(incomingDir); os.IsNotExist(err) {
t.Errorf("Incoming directory was not created")
}
}

View File

@@ -1,15 +1,20 @@
package config
type Config struct {
RepoPath string
OutputFile string
Limit int
Version string
SaveData bool
CacheFile string
NoCache bool
RebuildCache bool
GitHubToken string
ForcePRSync bool
EnableAISummary bool
RepoPath string
OutputFile string
Limit int
Version string
SaveData bool
CacheFile string
NoCache bool
RebuildCache bool
GitHubToken string
ForcePRSync bool
EnableAISummary bool
IncomingPR int
ProcessPRsVersion string
IncomingDir string
Push bool
SyncDB bool
}

View File

@@ -2,6 +2,7 @@ package git
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
@@ -11,10 +12,19 @@ import (
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
var (
versionPattern = regexp.MustCompile(`Update version to (v\d+\.\d+\.\d+)`)
// The versionPattern matches version commit messages with or without the optional "chore(release): " prefix.
// Examples of matching commit messages:
// - "chore(release): Update version to v1.2.3"
// - "Update version to v1.2.3"
// Examples of non-matching commit messages:
// - "fix: Update version to v1.2.3" (missing "chore(release): " or "Update version to")
// - "chore(release): Update version to 1.2.3" (missing "v" prefix in version)
// - "Update version to v1.2" (incomplete version number)
versionPattern = regexp.MustCompile(`(?:chore\(release\): )?Update version to (v\d+\.\d+\.\d+)`)
prPattern = regexp.MustCompile(`Merge pull request #(\d+)`)
)
@@ -400,3 +410,165 @@ func dedupInts(ints []int) []int {
return result
}
// Worktree returns the git worktree for performing git operations
func (w *Walker) Worktree() (*git.Worktree, error) {
return w.repo.Worktree()
}
// Repository returns the underlying git repository
func (w *Walker) Repository() *git.Repository {
return w.repo
}
// IsWorkingDirectoryClean checks if the working directory has any uncommitted changes
func (w *Walker) IsWorkingDirectoryClean() (bool, error) {
worktree, err := w.repo.Worktree()
if err != nil {
return false, fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return false, fmt.Errorf("failed to get git status: %w", err)
}
return status.IsClean(), nil
}
// GetStatusDetails returns a detailed status of the working directory
func (w *Walker) GetStatusDetails() (string, error) {
worktree, err := w.repo.Worktree()
if err != nil {
return "", fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return "", fmt.Errorf("failed to get git status: %w", err)
}
if status.IsClean() {
return "", nil
}
var details strings.Builder
for file, fileStatus := range status {
details.WriteString(fmt.Sprintf(" %c%c %s\n", fileStatus.Staging, fileStatus.Worktree, file))
}
return details.String(), nil
}
// AddFile adds a file to the git index
func (w *Walker) AddFile(filename string) error {
worktree, err := w.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
_, err = worktree.Add(filename)
if err != nil {
return fmt.Errorf("failed to add file %s: %w", filename, err)
}
return nil
}
// CommitChanges creates a commit with the given message
func (w *Walker) CommitChanges(message string) (plumbing.Hash, error) {
worktree, err := w.repo.Worktree()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err)
}
// Get git config for author information
cfg, err := w.repo.Config()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get git config: %w", err)
}
var authorName, authorEmail string
if cfg.User.Name != "" {
authorName = cfg.User.Name
} else {
authorName = "Changelog Bot"
}
if cfg.User.Email != "" {
authorEmail = cfg.User.Email
} else {
authorEmail = "bot@changelog.local"
}
commit, err := worktree.Commit(message, &git.CommitOptions{
Author: &object.Signature{
Name: authorName,
Email: authorEmail,
When: time.Now(),
},
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to commit: %w", err)
}
return commit, nil
}
// PushToRemote pushes the current branch to the remote repository
// It automatically detects GitHub repositories and uses token authentication when available
func (w *Walker) PushToRemote() error {
pushOptions := &git.PushOptions{}
// Check if we have a GitHub token for authentication
if githubToken := os.Getenv("GITHUB_TOKEN"); githubToken != "" {
// Get remote URL to check if it's a GitHub repository
remotes, err := w.repo.Remotes()
if err == nil && len(remotes) > 0 {
// Get the origin remote (or first remote if origin doesn't exist)
var remote *git.Remote
for _, r := range remotes {
if r.Config().Name == "origin" {
remote = r
break
}
}
if remote == nil {
remote = remotes[0]
}
// Check if this is a GitHub repository
urls := remote.Config().URLs
if len(urls) > 0 {
url := urls[0]
if strings.Contains(url, "github.com") {
// Use token authentication for GitHub repositories
pushOptions.Auth = &http.BasicAuth{
Username: "token", // GitHub expects "token" as username
Password: githubToken,
}
}
}
}
}
err := w.repo.Push(pushOptions)
if err != nil {
return fmt.Errorf("failed to push: %w", err)
}
return nil
}
// RemoveFile removes a file from both the working directory and git index
func (w *Walker) RemoveFile(filename string) error {
worktree, err := w.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
_, err = worktree.Remove(filename)
if err != nil {
return fmt.Errorf("failed to remove file %s: %w", filename, err)
}
return nil
}

View File

@@ -100,35 +100,89 @@ func (c *Client) FetchPRs(prNumbers []int) ([]*PR, error) {
return prs, nil
}
func (c *Client) fetchSinglePR(ctx context.Context, prNumber int) (*PR, error) {
pr, _, err := c.client.PullRequests.Get(ctx, c.owner, c.repo, prNumber)
// GetPRValidationDetails fetches only the data needed for validation (lightweight).
func (c *Client) GetPRValidationDetails(prNumber int) (*PRDetails, error) {
ctx := context.Background()
ghPR, _, err := c.client.PullRequests.Get(ctx, c.owner, c.repo, prNumber)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get PR %d: %w", prNumber, err)
}
commits, _, err := c.client.PullRequests.ListCommits(ctx, c.owner, c.repo, prNumber, nil)
if err != nil {
return nil, fmt.Errorf("failed to fetch commits: %w", err)
// Only return validation data, no commits fetched
details := &PRDetails{
PR: nil, // Will be populated later if needed
State: getString(ghPR.State),
Mergeable: ghPR.Mergeable != nil && *ghPR.Mergeable,
}
return details, nil
}
// GetPRWithCommits fetches the full PR and its commits.
func (c *Client) GetPRWithCommits(prNumber int) (*PR, error) {
ctx := context.Background()
ghPR, _, err := c.client.PullRequests.Get(ctx, c.owner, c.repo, prNumber)
if err != nil {
return nil, fmt.Errorf("failed to get PR %d: %w", prNumber, err)
}
return c.buildPRWithCommits(ctx, ghPR)
}
// GetPRDetails fetches a comprehensive set of details for a single PR.
// Deprecated: Use GetPRValidationDetails + GetPRWithCommits for better performance
func (c *Client) GetPRDetails(prNumber int) (*PRDetails, error) {
ctx := context.Background()
ghPR, _, err := c.client.PullRequests.Get(ctx, c.owner, c.repo, prNumber)
if err != nil {
return nil, fmt.Errorf("failed to get PR %d: %w", prNumber, err)
}
// Reuse the existing logic to build the base PR object
pr, err := c.buildPRWithCommits(ctx, ghPR)
if err != nil {
return nil, fmt.Errorf("failed to build PR details for %d: %w", prNumber, err)
}
details := &PRDetails{
PR: pr,
State: getString(ghPR.State),
Mergeable: ghPR.Mergeable != nil && *ghPR.Mergeable,
}
return details, nil
}
// buildPRWithCommits fetches commits and constructs a PR object from a GitHub API response
func (c *Client) buildPRWithCommits(ctx context.Context, ghPR *github.PullRequest) (*PR, error) {
commits, _, err := c.client.PullRequests.ListCommits(ctx, c.owner, c.repo, *ghPR.Number, nil)
if err != nil {
return nil, fmt.Errorf("failed to fetch commits for PR %d: %w", *ghPR.Number, err)
}
return c.convertGitHubPR(ghPR, commits), nil
}
// convertGitHubPR transforms GitHub API data into our internal PR struct (pure function)
func (c *Client) convertGitHubPR(ghPR *github.PullRequest, commits []*github.RepositoryCommit) *PR {
result := &PR{
Number: prNumber,
Title: getString(pr.Title),
Body: getString(pr.Body),
URL: getString(pr.HTMLURL),
Number: *ghPR.Number,
Title: getString(ghPR.Title),
Body: getString(ghPR.Body),
URL: getString(ghPR.HTMLURL),
Commits: make([]PRCommit, 0, len(commits)),
}
if pr.MergedAt != nil {
result.MergedAt = pr.MergedAt.Time
if ghPR.MergedAt != nil {
result.MergedAt = ghPR.MergedAt.Time
}
if pr.User != nil {
result.Author = getString(pr.User.Login)
result.AuthorURL = getString(pr.User.HTMLURL)
userType := getString(pr.User.Type) // GitHub API returns "User", "Organization", or "Bot"
if ghPR.User != nil {
result.Author = getString(ghPR.User.Login)
result.AuthorURL = getString(ghPR.User.HTMLURL)
userType := getString(ghPR.User.Type)
// Convert GitHub API type to lowercase
switch userType {
case "User":
result.AuthorType = "user"
@@ -137,12 +191,12 @@ func (c *Client) fetchSinglePR(ctx context.Context, prNumber int) (*PR, error) {
case "Bot":
result.AuthorType = "bot"
default:
result.AuthorType = "user" // Default fallback
result.AuthorType = "user"
}
}
if pr.MergeCommitSHA != nil {
result.MergeCommit = *pr.MergeCommitSHA
if ghPR.MergeCommitSHA != nil {
result.MergeCommit = *ghPR.MergeCommitSHA
}
for _, commit := range commits {
@@ -153,12 +207,34 @@ func (c *Client) fetchSinglePR(ctx context.Context, prNumber int) (*PR, error) {
}
if commit.Commit.Author != nil {
prCommit.Author = getString(commit.Commit.Author.Name)
prCommit.Email = getString(commit.Commit.Author.Email) // Extract author email from GitHub API response
// Capture actual commit timestamp from GitHub API
if commit.Commit.Author.Date != nil {
prCommit.Date = commit.Commit.Author.Date.Time
}
}
// Capture parent commit SHAs for merge detection
if commit.Parents != nil {
for _, parent := range commit.Parents {
if parent.SHA != nil {
prCommit.Parents = append(prCommit.Parents, *parent.SHA)
}
}
}
result.Commits = append(result.Commits, prCommit)
}
}
return result, nil
return result
}
func (c *Client) fetchSinglePR(ctx context.Context, prNumber int) (*PR, error) {
ghPR, _, err := c.client.PullRequests.Get(ctx, c.owner, c.repo, prNumber)
if err != nil {
return nil, err
}
return c.buildPRWithCommits(ctx, ghPR)
}
func getString(s *string) string {
@@ -332,6 +408,7 @@ func (c *Client) FetchAllMergedPRsGraphQL(since time.Time) ([]*PR, error) {
SHA: commitNode.Commit.OID,
Message: strings.TrimSpace(commitNode.Commit.Message),
Author: commitNode.Commit.Author.Name,
Date: commitNode.Commit.AuthoredDate, // Use actual commit timestamp
}
pr.Commits = append(pr.Commits, commit)
}

View File

@@ -0,0 +1,59 @@
package github
import (
"testing"
"time"
)
func TestPRCommitEmailHandling(t *testing.T) {
tests := []struct {
name string
commit PRCommit
expected string
}{
{
name: "Valid email field",
commit: PRCommit{
SHA: "abc123",
Message: "Fix bug in authentication",
Author: "John Doe",
Email: "john.doe@example.com",
Date: time.Now(),
Parents: []string{"def456"},
},
expected: "john.doe@example.com",
},
{
name: "Empty email field",
commit: PRCommit{
SHA: "abc123",
Message: "Fix bug in authentication",
Author: "John Doe",
Email: "",
Date: time.Now(),
Parents: []string{"def456"},
},
expected: "",
},
{
name: "Email field with proper initialization",
commit: PRCommit{
SHA: "def789",
Message: "Add new feature",
Author: "Jane Smith",
Email: "jane.smith@company.org",
Date: time.Now(),
Parents: []string{"ghi012"},
},
expected: "jane.smith@company.org",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.commit.Email != tt.expected {
t.Errorf("Expected email %q, got %q", tt.expected, tt.commit.Email)
}
})
}
}

View File

@@ -15,10 +15,20 @@ type PR struct {
MergeCommit string
}
// PRDetails encapsulates all relevant information about a Pull Request.
type PRDetails struct {
*PR
State string
Mergeable bool
}
type PRCommit struct {
SHA string
Message string
Author string
Email string // Author email from GitHub API, empty if not public
Date time.Time // Timestamp field
Parents []string // Parent commits (for merge detection)
}
// GraphQL query structures for hasura client
@@ -43,9 +53,10 @@ type PullRequestsQuery struct {
Commits struct {
Nodes []struct {
Commit struct {
OID string `graphql:"oid"`
Message string
Author struct {
OID string `graphql:"oid"`
Message string
AuthoredDate time.Time `graphql:"authoredDate"`
Author struct {
Name string
}
}

View File

@@ -21,7 +21,8 @@ var rootCmd = &cobra.Command{
Long: `A high-performance changelog generator that walks git history,
collects version information and pull requests, and generates a
comprehensive changelog in markdown format.`,
RunE: run,
RunE: run,
SilenceUsage: true, // Don't show usage on runtime errors, only on flag errors
}
func init() {
@@ -36,9 +37,18 @@ func init() {
rootCmd.Flags().StringVar(&cfg.GitHubToken, "token", "", "GitHub API token (or set GITHUB_TOKEN env var)")
rootCmd.Flags().BoolVar(&cfg.ForcePRSync, "force-pr-sync", false, "Force a full PR sync from GitHub (ignores cache age)")
rootCmd.Flags().BoolVar(&cfg.EnableAISummary, "ai-summarize", false, "Generate AI-enhanced summaries using Fabric")
rootCmd.Flags().IntVar(&cfg.IncomingPR, "incoming-pr", 0, "Pre-process PR for changelog (provide PR number)")
rootCmd.Flags().StringVar(&cfg.ProcessPRsVersion, "process-prs", "", "Process all incoming PR files for release (provide version like v1.4.262)")
rootCmd.Flags().StringVar(&cfg.IncomingDir, "incoming-dir", "./cmd/generate_changelog/incoming", "Directory for incoming PR files")
rootCmd.Flags().BoolVar(&cfg.Push, "push", false, "Enable automatic git push after creating an incoming entry")
rootCmd.Flags().BoolVar(&cfg.SyncDB, "sync-db", false, "Synchronize and validate database integrity with git history and GitHub PRs")
}
func run(cmd *cobra.Command, args []string) error {
if cfg.IncomingPR > 0 && cfg.ProcessPRsVersion != "" {
return fmt.Errorf("--incoming-pr and --process-prs are mutually exclusive flags")
}
if cfg.GitHubToken == "" {
cfg.GitHubToken = os.Getenv("GITHUB_TOKEN")
}
@@ -48,6 +58,18 @@ func run(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create changelog generator: %w", err)
}
if cfg.IncomingPR > 0 {
return generator.ProcessIncomingPR(cfg.IncomingPR)
}
if cfg.ProcessPRsVersion != "" {
return generator.CreateNewChangelogEntry(cfg.ProcessPRsVersion)
}
if cfg.SyncDB {
return generator.SyncDatabase()
}
output, err := generator.Generate()
if err != nil {
return fmt.Errorf("failed to generate changelog: %w", err)
@@ -77,8 +99,5 @@ func main() {
}
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
rootCmd.Execute()
}

View File

@@ -14,16 +14,19 @@ _fabric_models() {
models=(${(f)"$(fabric --listmodels --shell-complete-list 2>/dev/null)"})
compadd -X "Models:" ${models}
}
_fabric_contexts() {
local -a contexts
contexts=(${(f)"$(fabric --listcontexts --shell-complete-list 2>/dev/null)"})
compadd -X "Contexts:" ${contexts}
}
_fabric_sessions() {
local -a sessions
sessions=(${(f)"$(fabric --listsessions --shell-complete-list 2>/dev/null)"})
compadd -X "Sessions:" ${sessions}
}
_fabric_strategies() {
local -a strategies
strategies=(${(f)"$(fabric --liststrategies --shell-complete-list 2>/dev/null)"})
@@ -34,14 +37,12 @@ _fabric_extensions() {
local -a extensions
extensions=(${(f)"$(fabric --listextensions --shell-complete-list 2>/dev/null)"})
compadd -X "Extensions:" ${extensions}
'(-L --listmodels)'{-L,--listmodels}'[List all available models]:list models:_fabric_models' \
'(-x --listcontexts)'{-x,--listcontexts}'[List all contexts]:list contexts:_fabric_contexts' \
'(-X --listsessions)'{-X,--listsessions}'[List all sessions]:list sessions:_fabric_sessions' \
'(--listextensions)--listextensions[List all registered extensions]' \
'(--liststrategies)--liststrategies[List all strategies]:list strategies:_fabric_strategies' \
'(--listvendors)--listvendors[List all vendors]' \
vendors=(${(f)"$(fabric --listvendors 2>/dev/null)"})
compadd -X "Vendors:" ${vendors}
}
_fabric_gemini_voices() {
local -a voices
voices=(${(f)"$(fabric --list-gemini-voices --shell-complete-list 2>/dev/null)"})
compadd -X "Gemini TTS Voices:" ${voices}
}
_fabric() {
@@ -109,6 +110,8 @@ _fabric() {
'(--strategy)--strategy[Choose a strategy from the available strategies]:strategy:_fabric_strategies' \
'(--liststrategies)--liststrategies[List all strategies]' \
'(--listvendors)--listvendors[List all vendors]' \
'(--voice)--voice[TTS voice name for supported models]:voice:_fabric_gemini_voices' \
'(--list-gemini-voices)--list-gemini-voices[List all available Gemini TTS voices]' \
'(--shell-complete-list)--shell-complete-list[Output raw list without headers/formatting (for shell completion)]' \
'(--suppress-think)--suppress-think[Suppress text enclosed in thinking tags]' \
'(--think-start-tag)--think-start-tag[Start tag for thinking sections (default: <think>)]:start tag:' \
@@ -119,4 +122,3 @@ _fabric() {
}
_fabric "$@"

View File

@@ -13,7 +13,7 @@ _fabric() {
_get_comp_words_by_ref -n : cur prev words cword
# Define all possible options/flags
local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --voice --list-gemini-voices --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
# Helper function for dynamic completions
_fabric_get_list() {
@@ -62,6 +62,10 @@ _fabric() {
COMPREPLY=($(compgen -W "$(_fabric_get_list --liststrategies)" -- "${cur}"))
return 0
;;
--voice)
COMPREPLY=($(compgen -W "$(_fabric_get_list --list-gemini-voices)" -- "${cur}"))
return 0
;;
# Options requiring file/directory paths
-a | --attachment | -o | --output | --config | --addextension | --image-file)
_filedir

View File

@@ -31,6 +31,10 @@ function __fabric_get_extensions
fabric --listextensions --shell-complete-list 2>/dev/null
end
function __fabric_get_gemini_voices
fabric --list-gemini-voices --shell-complete-list 2>/dev/null
end
# Main completion function
complete -c fabric -f
@@ -71,6 +75,7 @@ complete -c fabric -l rmextension -d "Remove a registered extension by name" -a
complete -c fabric -l strategy -d "Choose a strategy from the available strategies" -a "(__fabric_get_strategies)"
complete -c fabric -l think-start-tag -d "Start tag for thinking sections (default: <think>)"
complete -c fabric -l think-end-tag -d "End tag for thinking sections (default: </think>)"
complete -c fabric -l voice -d "TTS voice name for supported models (e.g., Kore, Charon, Puck)" -a "(__fabric_get_gemini_voices)"
# Boolean flags (no arguments)
complete -c fabric -s S -l setup -d "Run setup for all reconfigurable parts of fabric"
@@ -99,6 +104,7 @@ complete -c fabric -l version -d "Print current version"
complete -c fabric -l listextensions -d "List all registered extensions"
complete -c fabric -l liststrategies -d "List all strategies"
complete -c fabric -l listvendors -d "List all vendors"
complete -c fabric -l list-gemini-voices -d "List all available Gemini TTS voices"
complete -c fabric -l shell-complete-list -d "Output raw list without headers/formatting (for shell completion)"
complete -c fabric -l suppress-think -d "Suppress text enclosed in thinking tags"
complete -c fabric -l disable-responses-api -d "Disable OpenAI Responses API (default: false)"

373
docs/Automated-ChangeLog.md Normal file
View File

@@ -0,0 +1,373 @@
# Automated CHANGELOG Entry System for CI/CD
## Overview
This document outlines a comprehensive system for automatically generating and maintaining CHANGELOG.md entries during the CI/CD process. The system builds upon the existing `generate_changelog` tool and integrates seamlessly with GitHub's pull request workflow.
## Current State Analysis
### Existing Infrastructure
The `generate_changelog` tool already provides:
- **High-performance Git history walking** with one-pass algorithm
- **GitHub API integration** with GraphQL optimization and smart caching
- **SQLite-based caching** for instant incremental updates
- **AI-powered summaries** using Fabric integration
- **Concurrent processing** for optimal performance
- **Version detection** from git tags and commit patterns
### Key Components
- **Main entry point**: `cmd/generate_changelog/main.go`
- **Core generation logic**: `internal/changelog/generator.go`
- **AI summarization**: `internal/changelog/summarize.go`
- **Caching system**: `internal/cache/cache.go`
- **GitHub integration**: `internal/github/client.go`
- **Git operations**: `internal/git/walker.go`
## Proposed Automated System
### Developer Workflow
```mermaid
graph TD
A[Developer creates feature branch] --> B[Codes feature]
B --> C[Creates Pull Request]
C --> D[PR is open and ready]
D --> E[Developer runs: generate_changelog --incoming-pr XXXX]
E --> F[Tool validates PR is open/mergeable]
F --> G[Tool creates incoming/XXXX.txt with AI summary]
G --> H[Auto-commit and push to branch]
H --> I[PR includes pre-processed changelog entry]
I --> J[PR gets reviewed and merged]
```
### CI/CD Integration
```mermaid
graph TD
A[PR merged to main] --> B[Version bump workflow triggered]
B --> C[generate_changelog --process-prs]
C --> D[Scan incoming/ directory]
D --> E[Concatenate all incoming/*.txt files]
E --> F[Insert new version at top of CHANGELOG.md]
F --> G[Store entry in versions table]
G --> H[git rm incoming/*.txt files]
H --> I[git add CHANGELOG.md and changelog.db, done by the tool]
I --> J[Increment version number]
J --> K[Commit and tag release]
```
## Implementation Details
### Phase 1: Pre-Processing PRs
#### New Command: `--incoming-pr`
**Usage**: `generate_changelog --incoming-pr 1672`
**Functionality**:
1. **Validation**:
- Verify PR exists and is open
- Check PR is mergeable
- Ensure branch is up-to-date
- Verify that current git repo is clean (everything committed); do not continue otherwise.
2. **Content Generation**:
- Extract PR metadata (title, author, description)
- Collect all commit messages from the PR
- Use existing `SummarizeVersionContent` function for AI enhancement
- Format as standard changelog entry
3. **File Creation**:
- Generate `./cmd/generate_changelog/incoming/{PR#}.txt`
- Include PR header: `### PR [#1672](url) by [author](profile): Title` (as is done currently in the code)
- Consider extracting the existing header code for PRs into a helper function for re-use.
- Include the AI-summarized changes (generated when we ran all the commit messages through `SummarizeVersionContent`)
4. **Auto-commit**:
- Commit file with message: `chore: incoming 1672 changelog entry`
- Optionally push to current branch (use `--push` flag)
(The PR is now completely ready to be merged with integrated CHANGELOG entry updating)
#### File Format Example
```markdown
### PR [#1672](https://github.com/danielmiessler/Fabric/pull/1672) by [ksylvan](https://github.com/ksylvan): Changelog Generator Enhancement
- Added automated CI/CD integration for changelog generation
- Implemented pre-processing of PR entries during development
- Enhanced caching system for better performance
- Added validation for mergeable PR states
```
### Phase 2: Release Processing
#### New Command: `--process-prs`
**Usage**: `generate_changelog --process-prs`
**Integration Point**: `.github/workflows/update-version-and-create-tag.yml`
(we can do this AFTER the "Update gomod2nix.toml file" step in the workflow, where we
already have generated the next version in the "version.nix" file)
**Functionality**:
1. **Discovery**: Scan `./cmd/generate_changelog/incoming/` directory
2. **Aggregation**: Read and concatenate all `*.txt` files
3. **Version Creation**: Generate new version header with current date
4. **CHANGELOG Update**: Insert new version at top of existing CHANGELOG.md
5. **Database Update**: Store complete entry in `versions` table as `ai_summary`
6. **Cleanup**: Remove all processed incoming files
7. **Stage Changes**: Add modified files to git staging area
#### Example Output in CHANGELOG.md
```markdown
# Changelog
## v1.4.259 (2025-07-18)
### PR [#1672](https://github.com/danielmiessler/Fabric/pull/1672) by [ksylvan](https://github.com/ksylvan): Changelog Generator Enhancement
- Added automated CI/CD integration for changelog generation
- Implemented pre-processing of PR entries during development
- Enhanced caching system for better performance
### PR [#1671](https://github.com/danielmiessler/Fabric/pull/1671) by [contributor](https://github.com/contributor): Bug Fix
- Fixed memory leak in caching system
- Improved error handling for GitHub API failures
## v1.4.258 (2025-07-14)
[... rest of file ...]
```
## Technical Implementation
### Configuration Extensions
Add to `internal/config/config.go`:
```go
type Config struct {
// ... existing fields
IncomingPR int // PR number for --incoming-pr
ProcessPRsVersion string // Flag for --process-prs (new version string)
IncomingDir string // Directory for incoming files (default: ./cmd/generate_changelog/incoming/)
}
```
### New Command Line Flags
```go
rootCmd.Flags().IntVar(&cfg.IncomingPR, "incoming-pr", 0, "Pre-process PR for changelog (provide PR number)")
rootCmd.Flags().StringVar(&cfg.ProcessPRsVersion, "process-prs", "", "Process all incoming PR files for release (provide version like v1.4.262)")
rootCmd.Flags().StringVar(&cfg.IncomingDir, "incoming-dir", "./cmd/generate_changelog/incoming", "Directory for incoming PR files")
```
### Core Logic Extensions
#### PR Pre-processing
```go
func (g *Generator) ProcessIncomingPR(prNumber int) error {
// 1. Validate PR state via GitHub API
pr, err := g.ghClient.GetPR(prNumber)
if err != nil || pr.State != "open" || !pr.Mergeable {
return fmt.Errorf("PR %d is not in valid state for processing", prNumber)
}
// 2. Generate changelog content using existing logic
content := g.formatPR(pr)
// 3. Apply AI summarization if enabled
if g.cfg.EnableAISummary {
content, _ = SummarizeVersionContent(content)
}
// 4. Write to incoming file
filename := filepath.Join(g.cfg.IncomingDir, fmt.Sprintf("%d.txt", prNumber))
err = os.WriteFile(filename, []byte(content), 0644)
if err != nil {
return fmt.Errorf("failed to write incoming file: %w", err)
}
// 5. Auto-commit and push
return g.commitAndPushIncoming(prNumber, filename)
}
```
#### Release Processing
```go
func (g *Generator) ProcessIncomingPRs(version string) error {
// 1. Scan incoming directory
files, err := filepath.Glob(filepath.Join(g.cfg.IncomingDir, "*.txt"))
if err != nil || len(files) == 0 {
return fmt.Errorf("no incoming PR files found")
}
// 2. Read and concatenate all files
var content strings.Builder
for _, file := range files {
data, err := os.ReadFile(file)
if err == nil {
content.WriteString(string(data))
content.WriteString("\n")
}
}
// 3. Generate version entry
entry := fmt.Sprintf("\n## %s (%s)\n\n%s",
version, time.Now().Format("2006-01-02"), content.String())
// 4. Update CHANGELOG.md
err = g.insertVersionAtTop(entry)
if err != nil {
return fmt.Errorf("failed to update CHANGELOG.md: %w", err)
}
// 5. Update database
err = g.cache.SaveVersionEntry(version, content.String())
if err != nil {
return fmt.Errorf("failed to save to database: %w", err)
}
// 6. Cleanup incoming files
for _, file := range files {
os.Remove(file)
}
return nil
}
```
## Workflow Integration
### GitHub Actions Modification
Update `.github/workflows/update-version-and-create-tag.yml`.
```yaml
- name: Generate Changelog Entry
run: |
# Process all incoming PR entries
./cmd/generate_changelog/generate_changelog --process-prs
# The tool will make the needed changes in the CHANGELOG.md,
# and the changelog.db, and will remove the PR#.txt file(s)
# In effect, doing the following:
# 1. Generate the new CHANGELOG (and store the entry in the changelog.db)
# 2. git add CHANGELOG.md
# 3. git add ./cmd/generate_changelog/changelog.db
# 4. git rm -rf ./cmd/generate_changelog/incoming/
#
```
### Developer Instructions
1. **During Development**:
```bash
# After PR is ready for review (commit locally only)
generate_changelog --incoming-pr 1672 --ai-summarize
# Or to automatically push to remote
generate_changelog --incoming-pr 1672 --ai-summarize --push
```
2. **Validation**:
- Check that `incoming/1672.txt` was created
- Verify auto-commit occurred
- Confirm file is included in PR
- Scan the file and make any changes you need to the auto-generated summary
## Benefits
### For Developers
- **Automated changelog entries** - no manual CHANGELOG.md editing
- **AI-enhanced summaries** - professional, consistent formatting
- **Early visibility** - changelog content visible during PR review
- **Reduced merge conflicts** - no multiple PRs editing CHANGELOG.md
### For Project Maintainers
- **Consistent formatting** - all entries follow same structure
- **Complete coverage** - no missed changelog entries
- **Automated releases** - seamless integration with version bumps
- **Historical accuracy** - each PR's contribution properly documented
### For CI/CD
- **Deterministic process** - reliable, repeatable changelog generation
- **Performance optimized** - leverages existing caching and AI systems
- **Error resilience** - validates PR states before processing
- **Clean integration** - minimal changes to existing workflows
## Implementation Strategy
### Phase 1: Implement Developer Tooling
- [x] Add new command line flags and configuration
- [x] Implement `--incoming-pr` functionality
- [x] Add validation for PR states and git status
- [x] Create auto-commit logic
### Phase 2: Integration (CI/CD) Readiness
- [x] Implement `--process-prs` functionality
- [x] Add CHANGELOG.md insertion logic
- [x] Update database storage for version entries
### Phase 3: Deployment
- [x] Update GitHub Actions workflow
- [x] Create developer documentation in ./docs/ directory
- [x] Test full end-to-end workflow (the PR that includes these modifications can be its first production test)
### Phase 4: Adoption
- [ ] Train development team - Consider creating a full tutorial blog post/page to fully walk developers through the process.
- [ ] Monitor first few releases
- [ ] Gather feedback and iterate
- [ ] Document lessons learned
## Error Handling
### PR Validation Failures
- **Closed/Merged PR**: Error with suggestion to check PR status
- **Non-mergeable PR**: Error with instruction to resolve conflicts
- **Missing PR**: Error with verification of PR number
### File System Issues
- **Permission errors**: Clear error with directory permission requirements
- **Disk space**: Graceful handling with cleanup suggestions
- **Network failures**: Retry logic with exponential backoff
### Git Operations
- **Commit failures**: Check for dirty working directory
- **Push failures**: Handle authentication and remote issues
- **Merge conflicts**: Clear instructions for manual resolution
## Future Enhancements
### Advanced Features
- **Custom categorization** - group changes by type (feat/fix/docs)
- **Breaking change detection** - special handling for BREAKING CHANGE commits
- **Release notes generation** - enhanced formatting for GitHub releases (our release pages are pretty bare)
## Conclusion
This automated changelog system builds upon the robust foundation of the existing `generate_changelog` tool while providing a seamless developer experience and reliable CI/CD integration. By pre-processing PR entries during development and aggregating them during releases, we achieve both accuracy and automation without sacrificing quality or developer productivity.
The phased approach ensures smooth adoption while the extensive error handling and validation provide confidence in production deployment. The system's design leverages existing infrastructure and patterns, making it a natural evolution of the current changelog generation capabilities.

View File

@@ -0,0 +1,195 @@
# Automated Changelog System - Developer Guide
This guide explains how to use the new automated changelog system for the Fabric project.
## Overview
The automated changelog system allows developers to pre-process their PR changelog entries during development, which are then automatically aggregated during the release process. This eliminates manual CHANGELOG.md editing and reduces merge conflicts.
## Developer Workflow
### Step 1: Create Your Feature Branch and PR
Work on your feature as usual and create a pull request.
### Step 2: Generate Changelog Entry
Once your PR is ready for review, generate a changelog entry:
```bash
cd cmd/generate_changelog
go build -o generate_changelog .
./generate_changelog --incoming-pr YOUR_PR_NUMBER
```
For example, if your PR number is 1672:
```bash
./generate_changelog --incoming-pr 1672
```
### Step 3: Validation
The tool will validate:
- ✅ PR exists and is open
- ✅ PR is mergeable (no conflicts)
- ✅ Your working directory is clean
If any validation fails, fix the issues and try again.
### Step 4: Review Generated Entry
The tool will:
1. Create `./cmd/generate_changelog/incoming/1672.txt`
2. Generate an AI-enhanced summary (if `--ai-summarize` is enabled)
3. Auto-commit the file to your branch (use `--push` to also push to remote)
Review the generated file and edit if needed:
```bash
cat ./cmd/generate_changelog/incoming/1672.txt
```
### Step 5: Include in PR
The incoming changelog entry is now part of your PR and will be reviewed along with your code changes.
## Example Generated Entry
```markdown
### PR [#1672](https://github.com/danielmiessler/fabric/pull/1672) by [ksylvan](https://github.com/ksylvan): Changelog Generator Enhancement
- Added automated CI/CD integration for changelog generation
- Implemented pre-processing of PR entries during development
- Enhanced caching system for better performance
- Added validation for mergeable PR states
```
## Command Options
### `--incoming-pr`
Pre-process a specific PR for changelog generation.
**Usage**: `./generate_changelog --incoming-pr PR_NUMBER`
**Requirements**:
- PR must be open
- PR must be mergeable (no conflicts)
- Working directory must be clean (no uncommitted changes)
- GitHub token must be available (`GITHUB_TOKEN` env var or `--token` flag)
**Mutual Exclusivity**: Cannot be used with `--process-prs` flag
### `--incoming-dir`
Specify custom directory for incoming PR files (default: `./cmd/generate_changelog/incoming`).
**Usage**: `./generate_changelog --incoming-pr 1672 --incoming-dir ./custom/path`
### `--process-prs`
Process all incoming PR files for release aggregation. Used by CI/CD during release creation.
**Usage**: `./generate_changelog --process-prs {new_version_string}`
**Mutual Exclusivity**: Cannot be used with `--incoming-pr` flag
### `--ai-summarize`
Enable AI-enhanced summaries using Fabric integration.
**Usage**: `./generate_changelog --incoming-pr 1672 --ai-summarize`
### `--push`
Enable automatic git push after creating an incoming entry. By default, the commit is created locally but not pushed to the remote repository.
**Usage**: `./generate_changelog --incoming-pr 1672 --push`
**Note**: When using `--push`, ensure you have proper authentication configured (SSH keys or GITHUB_TOKEN environment variable).
## Troubleshooting
### "PR is not open"
Your PR has been closed or merged. Only open PRs can be processed.
### "PR is not mergeable"
Your PR has merge conflicts or other issues preventing it from being merged. Resolve conflicts and ensure the PR is in a mergeable state.
### "Working directory is not clean"
You have uncommitted changes. Commit or stash them before running the tool.
### "Failed to fetch PR"
Check your GitHub token and network connection. Ensure the PR number exists.
## CI/CD Integration
The system automatically processes all incoming PR files during the release workflow. No manual intervention is required.
When a release is created:
1. All `incoming/*.txt` files are aggregated using `--process-prs`
2. Version is detected from `version.nix` or latest git tag
3. A new version entry is created in CHANGELOG.md
4. Incoming files are cleaned up (removed)
5. Changes are staged for the release commit (CHANGELOG.md and cache file)
## Best Practices
1. **Run early**: Generate your changelog entry as soon as your PR is ready for review
2. **Review content**: Always review the generated entry and edit if necessary
3. **Keep it updated**: If you make significant changes to your PR, regenerate the entry
4. **Use AI summaries**: Enable `--ai-summarize` for more professional, consistent formatting
## Advanced Usage
### Custom GitHub Token
```bash
./generate_changelog --incoming-pr 1672 --token YOUR_GITHUB_TOKEN
```
### Custom Repository Path
```bash
./generate_changelog --incoming-pr 1672 --repo /path/to/repo
```
### Disable Caching
```bash
./generate_changelog --incoming-pr 1672 --no-cache
```
### Enable Auto-Push
```bash
./generate_changelog --incoming-pr 1672 --push
```
This creates the commit locally and pushes it to the remote repository. By default, commits are only created locally, allowing you to review changes before pushing manually.
**Authentication**: The tool automatically detects GitHub repositories and uses the GITHUB_TOKEN environment variable for authentication when pushing. For SSH repositories, ensure your SSH keys are properly configured.
## Integration with Existing Workflow
This system is fully backward compatible. The existing changelog generation continues to work unchanged. The new features are opt-in and only activated when using the new flags.
## Support
If you encounter issues:
1. Check this documentation
2. Verify your GitHub token has appropriate permissions
3. Ensure your PR meets the validation requirements
4. Check the tool's help: `./generate_changelog --help`
For bugs or feature requests, please create an issue in the repository.

155
docs/Gemini-TTS.md Normal file
View File

@@ -0,0 +1,155 @@
# Gemini Text-to-Speech (TTS) Guide
Fabric supports Google Gemini's text-to-speech (TTS) capabilities, allowing you to convert text into high-quality audio using various AI-generated voices.
## Overview
The Gemini TTS feature in Fabric allows you to:
- Convert text input into audio using Google's Gemini TTS models
- Choose from 30+ different AI voices with varying characteristics
- Generate high-quality WAV audio files
- Integrate TTS generation into your existing Fabric workflows
## Usage
### Basic TTS Generation
To generate audio from text using TTS:
```bash
# Basic TTS with default voice (Kore)
echo "Hello, this is a test of Gemini TTS" | fabric -m gemini-2.0-flash-tts -o output.wav
# Using a specific voice
echo "Hello, this is a test with the Charon voice" | fabric -m gemini-2.0-flash-tts --voice Charon -o output.wav
# Using TTS with a pattern
fabric -p summarize --voice Puck -m gemini-2.0-flash-tts -o summary.wav < document.txt
```
### Voice Selection
Use the `--voice` flag to specify which voice to use for TTS generation:
```bash
fabric -m gemini-2.0-flash-tts --voice Zephyr -o output.wav "Your text here"
```
If no voice is specified, the default voice "Kore" will be used.
## Available Voices
Gemini TTS supports 30+ different voices, each with unique characteristics:
### Popular Voices
- **Kore** - Firm and confident (default)
- **Charon** - Informative and clear
- **Puck** - Upbeat and energetic
- **Zephyr** - Bright and cheerful
- **Leda** - Youthful and energetic
- **Aoede** - Breezy and natural
### Complete Voice List
- Kore, Charon, Puck, Fenrir, Aoede, Leda, Orus, Zephyr
- Autonoe, Callirhoe, Despina, Erinome, Gacrux, Laomedeia
- Pulcherrima, Sulafat, Vindemiatrix, Achernar, Achird
- Algenib, Algieba, Alnilam, Enceladus, Iapetus, Rasalgethi
- Sadachbia, Zubenelgenubi, Vega, Capella, Lyra
### Listing Available Voices
To see all available voices with descriptions:
```bash
# List all voices with characteristics
fabric --list-gemini-voices
# List voice names only (for shell completion)
fabric --list-gemini-voices --shell-complete-list
```
## Rate Limits
Google Gemini TTS has usage quotas that vary by plan:
### Free Tier
- **15 requests per day** per project per TTS model
- Quota resets daily
- Applies to all TTS models (e.g., `gemini-2.5-flash-preview-tts`)
### Rate Limit Errors
If you exceed your quota, you'll see an error like:
```text
Error 429: You exceeded your current quota, please check your plan and billing details
```
**Solutions:**
- Wait for daily quota reset (typically at midnight UTC)
- Upgrade to a paid plan for higher limits
- Use TTS generation strategically for important content
For current rate limits and pricing, visit: <https://ai.google.dev/gemini-api/docs/rate-limits>
## Configuration
### Command Line Options
- `--voice <voice_name>` - Specify the TTS voice to use
- `-o <filename.wav>` - Output audio file (required for TTS models)
- `-m <tts_model>` - Specify a TTS-capable model (e.g., `gemini-2.0-flash-tts`)
### YAML Configuration
You can also set a default voice in your Fabric configuration file (`~/.config/fabric/config.yaml`):
```yaml
voice: "Charon" # Set your preferred default voice
```
## Requirements
- Valid Google Gemini API key configured in Fabric
- TTS-capable Gemini model (models containing "tts" in the name)
- Audio output must be specified with `-o filename.wav`
## Troubleshooting
### Common Issues
#### Error: "TTS model requires audio output"
- Solution: Always specify an output file with `-o filename.wav` when using TTS models
#### Error: "Invalid voice 'X'"
- Solution: Check that the voice name is spelled correctly and matches one of the supported voices listed above
#### Error: "TTS generation failed"
- Solution: Verify your Gemini API key is valid and you have sufficient quota
### Getting Help
For additional help with TTS features:
```bash
fabric --help
```
## Technical Details
- **Audio Format**: WAV files with 24kHz sample rate, 16-bit depth, mono channel
- **Language Support**: Automatic language detection for 24+ languages
- **Model Requirements**: Models must contain "tts", "preview-tts", or "text-to-speech" in the name
- **Voice Selection**: Uses Google's PrebuiltVoiceConfig system for consistent voice quality
---
For more information about Fabric, visit the [main documentation](../README.md).

36
docs/voices/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Voice Samples
This directory contains sample audio files demonstrating different Gemini TTS voices.
## Sample Files
Each voice sample says "The quick brown fox jumped over the lazy dog" to demonstrate the voice characteristics:
- **Kore.wav** - Firm and confident (default voice)
- **Charon.wav** - Informative and clear
- **Vega.wav** - Smooth and pleasant
- **Capella.wav** - Warm and welcoming
- **Achird.wav** - Friendly and approachable
- **Lyra.wav** - Melodic and expressive
## Generating Samples
To generate these samples, use the following commands:
```bash
# Generate each voice sample
echo "The quick brown fox jumped over the lazy dog" | fabric -m gemini-2.5-flash-preview-tts --voice Kore -o docs/voices/Kore.wav
echo "The quick brown fox jumped over the lazy dog" | fabric -m gemini-2.5-flash-preview-tts --voice Charon -o docs/voices/Charon.wav
echo "The quick brown fox jumped over the lazy dog" | fabric -m gemini-2.5-flash-preview-tts --voice Vega -o docs/voices/Vega.wav
echo "The quick brown fox jumped over the lazy dog" | fabric -m gemini-2.5-flash-preview-tts --voice Capella -o docs/voices/Capella.wav
echo "The quick brown fox jumped over the lazy dog" | fabric -m gemini-2.5-flash-preview-tts --voice Achird -o docs/voices/Achird.wav
echo "The quick brown fox jumped over the lazy dog" | fabric -m gemini-2.5-flash-preview-tts --voice Lyra -o docs/voices/Lyra.wav
```
## Audio Format
- **Format**: WAV (uncompressed)
- **Sample Rate**: 24kHz
- **Bit Depth**: 16-bit
- **Channels**: Mono
- **Approximate Size**: ~500KB per sample

11
go.mod
View File

@@ -15,7 +15,6 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/go-git/go-git/v5 v5.16.2
github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612
github.com/google/generative-ai-go v0.20.1
github.com/google/go-github/v66 v66.0.0
github.com/hasura/go-graphql-client v0.14.4
github.com/jessevdk/go-flags v1.6.1
@@ -35,13 +34,16 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
)
require (
cloud.google.com/go v0.121.2 // indirect
cloud.google.com/go/ai v0.12.1 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/longrunning v0.6.7 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
@@ -109,7 +111,6 @@ require (
github.com/ugorji/go/codec v1.2.14 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
@@ -120,7 +121,7 @@ require (
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genai v1.17.0
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect

14
go.sum
View File

@@ -1,15 +1,11 @@
cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg=
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
cloud.google.com/go/ai v0.12.1 h1:m1n/VjUuHS+pEO/2R4/VbuuEIkgk0w67fDQvFaMngM0=
cloud.google.com/go/ai v0.12.1/go.mod h1:5vIPNe1ZQsVZqCliXIPL4QnhObQQY4d9hAGHdVc4iw4=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -126,8 +122,6 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ=
github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -145,6 +139,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hasura/go-graphql-client v0.14.4 h1:bYU7/+V50T2YBGdNQXt6l4f2cMZPECPUd8cyCR+ixtw=
github.com/hasura/go-graphql-client v0.14.4/go.mod h1:jfSZtBER3or+88Q9vFhWHiFMPppfYILRyl+0zsgPIIw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -249,8 +245,6 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
@@ -345,8 +339,6 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -357,6 +349,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.236.0 h1:CAiEiDVtO4D/Qja2IA9VzlFrgPnK3XVMmRoJZlSWbc0=
google.golang.org/api v0.236.0/go.mod h1:X1WF9CU2oTc+Jml1tiIxGmWFK/UZezdqEu09gcxZAj4=
google.golang.org/genai v1.17.0 h1:lXYSnWShPYjxTouxRj0zF8RsNmSF+SKo7SQ7dM35NlI=
google.golang.org/genai v1.17.0/go.mod h1:QPj5NGJw+3wEOHg+PrsWwJKvG6UC84ex5FR7qAYsN/M=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=

View File

@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/danielmiessler/fabric/internal/core"
@@ -35,6 +36,40 @@ func handleChatProcessing(currentFlags *Flags, registry *core.PluginRegistry, me
if chatOptions, err = currentFlags.BuildChatOptions(); err != nil {
return
}
// Check if user is requesting audio output or using a TTS model
isAudioOutput := currentFlags.Output != "" && IsAudioFormat(currentFlags.Output)
isTTSModel := isTTSModel(currentFlags.Model)
if isTTSModel && !isAudioOutput {
err = fmt.Errorf("TTS model '%s' requires audio output. Please specify an audio output file with -o flag (e.g., -o output.wav)", currentFlags.Model)
return
}
if isAudioOutput && !isTTSModel {
err = fmt.Errorf("audio output file '%s' specified but model '%s' is not a TTS model. Please use a TTS model like gemini-2.5-flash-preview-tts", currentFlags.Output, currentFlags.Model)
return
}
// For TTS models, check if output file already exists BEFORE processing
if isTTSModel && isAudioOutput {
outputFile := currentFlags.Output
// Add .wav extension if not provided
if filepath.Ext(outputFile) == "" {
outputFile += ".wav"
}
if _, err = os.Stat(outputFile); err == nil {
err = fmt.Errorf("file %s already exists. Please choose a different filename or remove the existing file", outputFile)
return
}
}
// Set audio options in chat config
chatOptions.AudioOutput = isAudioOutput
if isAudioOutput {
chatOptions.AudioFormat = "wav" // Default to WAV format
}
if session, err = chatter.Send(chatReq, chatOptions); err != nil {
return
}
@@ -42,8 +77,13 @@ func handleChatProcessing(currentFlags *Flags, registry *core.PluginRegistry, me
result := session.GetLastMessage().Content
if !currentFlags.Stream || currentFlags.SuppressThink {
// print the result if it was not streamed already or suppress-think disabled streaming output
fmt.Println(result)
// For TTS models with audio output, show a user-friendly message instead of raw data
if isTTSModel && isAudioOutput && strings.HasPrefix(result, "FABRIC_AUDIO_DATA:") {
fmt.Printf("TTS audio generated successfully and saved to: %s\n", currentFlags.Output)
} else {
// print the result if it was not streamed already or suppress-think disabled streaming output
fmt.Println(result)
}
}
// if the copy flag is set, copy the message to the clipboard
@@ -59,8 +99,29 @@ func handleChatProcessing(currentFlags *Flags, registry *core.PluginRegistry, me
sessionAsString := session.String()
err = CreateOutputFile(sessionAsString, currentFlags.Output)
} else {
err = CreateOutputFile(result, currentFlags.Output)
// For TTS models, we need to handle audio output differently
if isTTSModel && isAudioOutput {
// Check if result contains actual audio data
if strings.HasPrefix(result, "FABRIC_AUDIO_DATA:") {
// Extract the binary audio data
audioData := result[len("FABRIC_AUDIO_DATA:"):]
err = CreateAudioOutputFile([]byte(audioData), currentFlags.Output)
} else {
// Fallback for any error messages or unexpected responses
err = CreateOutputFile(result, currentFlags.Output)
}
} else {
err = CreateOutputFile(result, currentFlags.Output)
}
}
}
return
}
// isTTSModel checks if the model is a text-to-speech model
func isTTSModel(modelName string) bool {
lowerModel := strings.ToLower(modelName)
return strings.Contains(lowerModel, "tts") ||
strings.Contains(lowerModel, "preview-tts") ||
strings.Contains(lowerModel, "text-to-speech")
}

View File

@@ -19,6 +19,12 @@ func Cli(version string) (err error) {
return
}
if currentFlags.Setup {
if err = ensureEnvFile(); err != nil {
return
}
}
if currentFlags.Version {
fmt.Println(version)
return

View File

@@ -87,6 +87,8 @@ type Flags struct {
ThinkStartTag string `long:"think-start-tag" yaml:"thinkStartTag" description:"Start tag for thinking sections" default:"<think>"`
ThinkEndTag string `long:"think-end-tag" yaml:"thinkEndTag" description:"End tag for thinking sections" default:"</think>"`
DisableResponsesAPI bool `long:"disable-responses-api" yaml:"disableResponsesAPI" description:"Disable OpenAI Responses API (default: false)"`
Voice string `long:"voice" yaml:"voice" description:"TTS voice name for supported models (e.g., Kore, Charon, Puck)" default:"Kore"`
ListGeminiVoices bool `long:"list-gemini-voices" description:"List all available Gemini TTS voices"`
}
var debug = false
@@ -441,6 +443,7 @@ func (o *Flags) BuildChatOptions() (ret *domain.ChatOptions, err error) {
SuppressThink: o.SuppressThink,
ThinkStartTag: startTag,
ThinkEndTag: endTag,
Voice: o.Voice,
}
return
}

View File

@@ -1,6 +1,7 @@
package cli
import (
"fmt"
"os"
"path/filepath"
@@ -8,6 +9,9 @@ import (
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
)
const ConfigDirPerms os.FileMode = 0755
const EnvFilePerms os.FileMode = 0644
// initializeFabric initializes the fabric database and plugin registry
func initializeFabric() (registry *core.PluginRegistry, err error) {
var homedir string
@@ -26,3 +30,27 @@ func initializeFabric() (registry *core.PluginRegistry, err error) {
return
}
// ensureEnvFile checks for the default ~/.config/fabric/.env file and creates it
// along with the parent directory if it does not exist.
func ensureEnvFile() (err error) {
var homedir string
if homedir, err = os.UserHomeDir(); err != nil {
return fmt.Errorf("could not determine user home directory: %w", err)
}
configDir := filepath.Join(homedir, ".config", "fabric")
envPath := filepath.Join(configDir, ".env")
if _, statErr := os.Stat(envPath); statErr != nil {
if !os.IsNotExist(statErr) {
return fmt.Errorf("could not stat .env file: %w", statErr)
}
if err = os.MkdirAll(configDir, ConfigDirPerms); err != nil {
return fmt.Errorf("could not create config directory: %w", err)
}
if err = os.WriteFile(envPath, []byte{}, EnvFilePerms); err != nil {
return fmt.Errorf("could not create .env file: %w", err)
}
}
return
}

View File

@@ -1,11 +1,13 @@
package cli
import (
"fmt"
"os"
"strconv"
"github.com/danielmiessler/fabric/internal/core"
"github.com/danielmiessler/fabric/internal/plugins/ai"
"github.com/danielmiessler/fabric/internal/plugins/ai/gemini"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
)
@@ -58,5 +60,11 @@ func handleListingCommands(currentFlags *Flags, fabricDb *fsdb.Db, registry *cor
return true, err
}
if currentFlags.ListGeminiVoices {
voicesList := gemini.ListGeminiVoices(currentFlags.ShellCompleteOutput)
fmt.Print(voicesList)
return true, nil
}
return false, nil
}

View File

@@ -3,6 +3,8 @@ package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/atotto/clipboard"
)
@@ -28,3 +30,37 @@ func CreateOutputFile(message string, fileName string) (err error) {
}
return
}
// CreateAudioOutputFile creates a binary file for audio data
func CreateAudioOutputFile(audioData []byte, fileName string) (err error) {
// If no extension is provided, default to .wav
if filepath.Ext(fileName) == "" {
fileName += ".wav"
}
// File existence check is now done in the CLI layer before TTS generation
var file *os.File
if file, err = os.Create(fileName); err != nil {
err = fmt.Errorf("error creating audio file: %v", err)
return
}
defer file.Close()
if _, err = file.Write(audioData); err != nil {
err = fmt.Errorf("error writing audio data to file: %v", err)
}
// No redundant output message here - the CLI layer handles success messaging
return
}
// IsAudioFormat checks if the filename suggests an audio format
func IsAudioFormat(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
audioExts := []string{".wav", ".mp3", ".m4a", ".aac", ".ogg", ".flac"}
for _, audioExt := range audioExts {
if ext == audioExt {
return true
}
}
return false
}

View File

@@ -37,12 +37,16 @@ import (
"github.com/danielmiessler/fabric/internal/util"
)
// hasAWSCredentials checks if any AWS credentials are present either in the
// environment variables or in the default/shared credentials file. It doesn't
// attempt to verify the validity of the credentials, but simply ensures that a
// potential authentication source exists so we can safely initialize the
// Bedrock client without causing the AWS SDK to search for credentials.
// hasAWSCredentials checks if Bedrock is properly configured by ensuring both
// AWS credentials and BEDROCK_AWS_REGION are present. This prevents the Bedrock
// client from being initialized when AWS credentials exist for other purposes.
func hasAWSCredentials() bool {
// First check if BEDROCK_AWS_REGION is set - this is required for Bedrock
if os.Getenv("BEDROCK_AWS_REGION") == "" {
return false
}
// Then check if AWS credentials are available
if os.Getenv("AWS_PROFILE") != "" ||
os.Getenv("AWS_ROLE_SESSION_NAME") != "" ||
(os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "") {

View File

@@ -36,6 +36,9 @@ type ChatOptions struct {
SuppressThink bool
ThinkStartTag string
ThinkEndTag string
AudioOutput bool
AudioFormat string
Voice string
}
// NormalizeMessages remove empty messages and ensure messages order user-assist-user

View File

@@ -13,6 +13,7 @@ func NewClient() (ret *Client) {
ret = &Client{}
ret.Client = openai.NewClientCompatibleNoSetupQuestions("Exolab", ret.configure)
ret.ApiKey = ret.AddSetupQuestion("API Key", false)
ret.ApiBaseURL = ret.AddSetupQuestion("API Base URL", true)
ret.ApiBaseURL.Value = "http://localhost:52415"

View File

@@ -1,8 +1,9 @@
package gemini
import (
"bytes"
"context"
"errors"
"encoding/binary"
"fmt"
"strings"
@@ -10,12 +11,20 @@ import (
"github.com/danielmiessler/fabric/internal/plugins"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
"google.golang.org/genai"
)
const modelsNamePrefix = "models/"
// WAV audio constants
const (
DefaultChannels = 1
DefaultSampleRate = 24000
DefaultBitsPerSample = 16
WAVHeaderSize = 44
RIFFHeaderSize = 36
MaxAudioDataSize = 100 * 1024 * 1024 // 100MB limit for security
MinAudioDataSize = 44 // Minimum viable audio data
AudioDataPrefix = "FABRIC_AUDIO_DATA:"
)
func NewClient() (ret *Client) {
vendorName := "Gemini"
@@ -39,107 +48,104 @@ type Client struct {
func (o *Client) ListModels() (ret []string, err error) {
ctx := context.Background()
var client *genai.Client
if client, err = genai.NewClient(ctx, option.WithAPIKey(o.ApiKey.Value)); err != nil {
if client, err = genai.NewClient(ctx, &genai.ClientConfig{
APIKey: o.ApiKey.Value,
Backend: genai.BackendGeminiAPI,
}); err != nil {
return
}
defer client.Close()
iter := client.ListModels(ctx)
for {
var resp *genai.ModelInfo
if resp, err = iter.Next(); err != nil {
if errors.Is(err, iterator.Done) {
err = nil
}
break
}
// List available models using the correct API
resp, err := client.Models.List(ctx, &genai.ListModelsConfig{})
if err != nil {
return nil, err
}
name := o.buildModelNameSimple(resp.Name)
ret = append(ret, name)
for _, model := range resp.Items {
// Strip the "models/" prefix for user convenience
modelName := strings.TrimPrefix(model.Name, "models/")
ret = append(ret, modelName)
}
return
}
func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (ret string, err error) {
systemInstruction, messages := toMessages(msgs)
// Check if this is a TTS model request
if o.isTTSModel(opts.Model) {
if !opts.AudioOutput {
err = fmt.Errorf("TTS model '%s' requires audio output. Please specify an audio output file with -o flag ending in .wav", opts.Model)
return
}
// Handle TTS generation
return o.generateTTSAudio(ctx, msgs, opts)
}
// Regular text generation
var client *genai.Client
if client, err = genai.NewClient(ctx, option.WithAPIKey(o.ApiKey.Value)); err != nil {
return
}
defer client.Close()
model := client.GenerativeModel(o.buildModelNameFull(opts.Model))
model.SetTemperature(float32(opts.Temperature))
model.SetTopP(float32(opts.TopP))
model.SystemInstruction = systemInstruction
var response *genai.GenerateContentResponse
if response, err = model.GenerateContent(ctx, messages...); err != nil {
if client, err = genai.NewClient(ctx, &genai.ClientConfig{
APIKey: o.ApiKey.Value,
Backend: genai.BackendGeminiAPI,
}); err != nil {
return
}
ret = o.extractText(response)
// Convert messages to new SDK format
contents := o.convertMessages(msgs)
// Generate content
temperature := float32(opts.Temperature)
topP := float32(opts.TopP)
response, err := client.Models.GenerateContent(ctx, o.buildModelNameFull(opts.Model), contents, &genai.GenerateContentConfig{
Temperature: &temperature,
TopP: &topP,
MaxOutputTokens: int32(opts.ModelContextLength),
})
if err != nil {
return "", err
}
// Extract text from response
ret = o.extractTextFromResponse(response)
return
}
func (o *Client) buildModelNameSimple(fullModelName string) string {
return strings.TrimPrefix(fullModelName, modelsNamePrefix)
}
func (o *Client) buildModelNameFull(modelName string) string {
return fmt.Sprintf("%v%v", modelsNamePrefix, modelName)
}
func (o *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan string) (err error) {
ctx := context.Background()
var client *genai.Client
if client, err = genai.NewClient(ctx, option.WithAPIKey(o.ApiKey.Value)); err != nil {
if client, err = genai.NewClient(ctx, &genai.ClientConfig{
APIKey: o.ApiKey.Value,
Backend: genai.BackendGeminiAPI,
}); err != nil {
return
}
defer client.Close()
systemInstruction, messages := toMessages(msgs)
// Convert messages to new SDK format
contents := o.convertMessages(msgs)
model := client.GenerativeModel(o.buildModelNameFull(opts.Model))
model.SetTemperature(float32(opts.Temperature))
model.SetTopP(float32(opts.TopP))
model.SystemInstruction = systemInstruction
// Generate streaming content
temperature := float32(opts.Temperature)
topP := float32(opts.TopP)
stream := client.Models.GenerateContentStream(ctx, o.buildModelNameFull(opts.Model), contents, &genai.GenerateContentConfig{
Temperature: &temperature,
TopP: &topP,
MaxOutputTokens: int32(opts.ModelContextLength),
})
iter := model.GenerateContentStream(ctx, messages...)
for {
if resp, iterErr := iter.Next(); iterErr == nil {
for _, candidate := range resp.Candidates {
if candidate.Content != nil {
for _, part := range candidate.Content.Parts {
if text, ok := part.(genai.Text); ok {
channel <- string(text)
}
}
}
}
} else {
if !errors.Is(iterErr, iterator.Done) {
channel <- fmt.Sprintf("%v\n", iterErr)
}
for response, err := range stream {
if err != nil {
channel <- fmt.Sprintf("Error: %v\n", err)
close(channel)
break
}
}
return
}
func (o *Client) extractText(response *genai.GenerateContentResponse) (ret string) {
for _, candidate := range response.Candidates {
if candidate.Content == nil {
break
}
for _, part := range candidate.Content.Parts {
if text, ok := part.(genai.Text); ok {
ret += string(text)
}
text := o.extractTextFromResponse(response)
if text != "" {
channel <- text
}
}
close(channel)
return
}
@@ -147,18 +153,223 @@ func (o *Client) NeedsRawMode(modelName string) bool {
return false
}
func toMessages(msgs []*chat.ChatCompletionMessage) (systemInstruction *genai.Content, messages []genai.Part) {
if len(msgs) >= 2 {
systemInstruction = &genai.Content{
Parts: []genai.Part{
genai.Text(msgs[0].Content),
},
}
for _, msg := range msgs[1:] {
messages = append(messages, genai.Text(msg.Content))
}
} else {
messages = append(messages, genai.Text(msgs[0].Content))
// buildModelNameFull adds the "models/" prefix for API calls
func (o *Client) buildModelNameFull(modelName string) string {
if strings.HasPrefix(modelName, "models/") {
return modelName
}
return
return "models/" + modelName
}
// isTTSModel checks if the model is a text-to-speech model
func (o *Client) isTTSModel(modelName string) bool {
lowerModel := strings.ToLower(modelName)
return strings.Contains(lowerModel, "tts") ||
strings.Contains(lowerModel, "preview-tts") ||
strings.Contains(lowerModel, "text-to-speech")
}
// extractTextForTTS extracts text content from chat messages for TTS generation
func (o *Client) extractTextForTTS(msgs []*chat.ChatCompletionMessage) (string, error) {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == chat.ChatMessageRoleUser && msgs[i].Content != "" {
return msgs[i].Content, nil
}
}
return "", fmt.Errorf("no text content found for TTS generation")
}
// createGenaiClient creates a new GenAI client for TTS operations
func (o *Client) createGenaiClient(ctx context.Context) (*genai.Client, error) {
return genai.NewClient(ctx, &genai.ClientConfig{
APIKey: o.ApiKey.Value,
Backend: genai.BackendGeminiAPI,
})
}
// generateTTSAudio handles TTS audio generation using the new SDK
func (o *Client) generateTTSAudio(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (ret string, err error) {
textToSpeak, err := o.extractTextForTTS(msgs)
if err != nil {
return "", err
}
// Validate voice name before making API call
if opts.Voice != "" && !IsValidGeminiVoice(opts.Voice) {
validVoices := GetGeminiVoiceNames()
return "", fmt.Errorf("invalid voice '%s'. Valid voices are: %v", opts.Voice, validVoices)
}
client, err := o.createGenaiClient(ctx)
if err != nil {
return "", err
}
return o.performTTSGeneration(ctx, client, textToSpeak, opts)
}
// performTTSGeneration performs the actual TTS generation and audio processing
func (o *Client) performTTSGeneration(ctx context.Context, client *genai.Client, textToSpeak string, opts *domain.ChatOptions) (string, error) {
// Create content for TTS
contents := []*genai.Content{{
Parts: []*genai.Part{{Text: textToSpeak}},
}}
// Configure for TTS generation
voiceName := opts.Voice
if voiceName == "" {
voiceName = "Kore" // Default voice if none specified
}
config := &genai.GenerateContentConfig{
ResponseModalities: []string{"AUDIO"},
SpeechConfig: &genai.SpeechConfig{
VoiceConfig: &genai.VoiceConfig{
PrebuiltVoiceConfig: &genai.PrebuiltVoiceConfig{
VoiceName: voiceName,
},
},
},
}
// Generate TTS content
response, err := client.Models.GenerateContent(ctx, o.buildModelNameFull(opts.Model), contents, config)
if err != nil {
return "", fmt.Errorf("TTS generation failed: %w", err)
}
// Extract and process audio data
if len(response.Candidates) > 0 && response.Candidates[0].Content != nil && len(response.Candidates[0].Content.Parts) > 0 {
part := response.Candidates[0].Content.Parts[0]
if part.InlineData != nil && len(part.InlineData.Data) > 0 {
// Validate audio data format and size
if part.InlineData.MIMEType != "" && !strings.HasPrefix(part.InlineData.MIMEType, "audio/") {
return "", fmt.Errorf("unexpected data type: %s, expected audio data", part.InlineData.MIMEType)
}
pcmData := part.InlineData.Data
if len(pcmData) < MinAudioDataSize {
return "", fmt.Errorf("audio data too small: %d bytes, minimum required: %d", len(pcmData), MinAudioDataSize)
}
// Generate WAV file with proper headers and return the binary data
wavData, err := o.generateWAVFile(pcmData)
if err != nil {
return "", fmt.Errorf("failed to generate WAV file: %w", err)
}
// Validate generated WAV data
if len(wavData) < WAVHeaderSize {
return "", fmt.Errorf("generated WAV data is invalid: %d bytes, minimum required: %d", len(wavData), WAVHeaderSize)
}
// Store the binary audio data in a special format that the CLI can detect
// Use more efficient string concatenation
return AudioDataPrefix + string(wavData), nil
}
}
return "", fmt.Errorf("no audio data received from TTS model")
}
// generateWAVFile creates WAV data from PCM data with proper headers
func (o *Client) generateWAVFile(pcmData []byte) ([]byte, error) {
// Validate input size to prevent potential security issues
if len(pcmData) == 0 {
return nil, fmt.Errorf("empty PCM data provided")
}
if len(pcmData) > MaxAudioDataSize {
return nil, fmt.Errorf("PCM data too large: %d bytes, maximum allowed: %d", len(pcmData), MaxAudioDataSize)
}
// WAV file parameters (Gemini TTS default specs)
channels := DefaultChannels
sampleRate := DefaultSampleRate
bitsPerSample := DefaultBitsPerSample
// Calculate required values
byteRate := sampleRate * channels * bitsPerSample / 8
blockAlign := channels * bitsPerSample / 8
dataLen := uint32(len(pcmData))
riffSize := RIFFHeaderSize + dataLen
// Pre-allocate buffer with known size for better performance
totalSize := int(riffSize + 8) // +8 for RIFF header
buf := bytes.NewBuffer(make([]byte, 0, totalSize))
// RIFF header
buf.WriteString("RIFF")
binary.Write(buf, binary.LittleEndian, riffSize)
buf.WriteString("WAVE")
// fmt chunk
buf.WriteString("fmt ")
binary.Write(buf, binary.LittleEndian, uint32(16)) // subchunk1Size
binary.Write(buf, binary.LittleEndian, uint16(1)) // audioFormat = PCM
binary.Write(buf, binary.LittleEndian, uint16(channels)) // numChannels
binary.Write(buf, binary.LittleEndian, uint32(sampleRate)) // sampleRate
binary.Write(buf, binary.LittleEndian, uint32(byteRate)) // byteRate
binary.Write(buf, binary.LittleEndian, uint16(blockAlign)) // blockAlign
binary.Write(buf, binary.LittleEndian, uint16(bitsPerSample)) // bitsPerSample
// data chunk
buf.WriteString("data")
binary.Write(buf, binary.LittleEndian, dataLen)
// Write PCM data to buffer
buf.Write(pcmData)
// Validate generated WAV data
result := buf.Bytes()
if len(result) < WAVHeaderSize {
return nil, fmt.Errorf("generated WAV data is invalid: %d bytes, minimum required: %d", len(result), WAVHeaderSize)
}
return result, nil
}
// convertMessages converts fabric chat messages to genai Content format
func (o *Client) convertMessages(msgs []*chat.ChatCompletionMessage) []*genai.Content {
var contents []*genai.Content
for _, msg := range msgs {
content := &genai.Content{Parts: []*genai.Part{}}
if msg.Content != "" {
content.Parts = append(content.Parts, &genai.Part{Text: msg.Content})
}
// Handle multi-content messages (images, etc.)
for _, part := range msg.MultiContent {
switch part.Type {
case chat.ChatMessagePartTypeText:
content.Parts = append(content.Parts, &genai.Part{Text: part.Text})
case chat.ChatMessagePartTypeImageURL:
// TODO: Handle image URLs if needed
// This would require downloading and converting to inline data
}
}
contents = append(contents, content)
}
return contents
}
// extractTextFromResponse extracts text content from the response
func (o *Client) extractTextFromResponse(response *genai.GenerateContentResponse) string {
var result strings.Builder
for _, candidate := range response.Candidates {
if candidate.Content != nil {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
result.WriteString(part.Text)
}
}
}
}
return result.String()
}

View File

@@ -3,32 +3,40 @@ package gemini
import (
"testing"
"github.com/google/generative-ai-go/genai"
"google.golang.org/genai"
)
// Test generated using Keploy
func TestBuildModelNameSimple(t *testing.T) {
// Test buildModelNameFull method
func TestBuildModelNameFull(t *testing.T) {
client := &Client{}
fullModelName := "models/chat-bison-001"
expected := "chat-bison-001"
result := client.buildModelNameSimple(fullModelName)
tests := []struct {
input string
expected string
}{
{"chat-bison-001", "models/chat-bison-001"},
{"models/chat-bison-001", "models/chat-bison-001"},
{"gemini-2.5-flash-preview-tts", "models/gemini-2.5-flash-preview-tts"},
}
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
for _, test := range tests {
result := client.buildModelNameFull(test.input)
if result != test.expected {
t.Errorf("For input %v, expected %v, got %v", test.input, test.expected, result)
}
}
}
// Test generated using Keploy
func TestExtractText(t *testing.T) {
// Test extractTextFromResponse method
func TestExtractTextFromResponse(t *testing.T) {
client := &Client{}
response := &genai.GenerateContentResponse{
Candidates: []*genai.Candidate{
{
Content: &genai.Content{
Parts: []genai.Part{
genai.Text("Hello, "),
genai.Text("world!"),
Parts: []*genai.Part{
{Text: "Hello, "},
{Text: "world!"},
},
},
},
@@ -36,9 +44,56 @@ func TestExtractText(t *testing.T) {
}
expected := "Hello, world!"
result := client.extractText(response)
result := client.extractTextFromResponse(response)
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
}
// Test isTTSModel method
func TestIsTTSModel(t *testing.T) {
client := &Client{}
tests := []struct {
modelName string
expected bool
}{
{"gemini-2.5-flash-preview-tts", true},
{"text-to-speech-model", true},
{"TTS-MODEL", true},
{"gemini-pro", false},
{"chat-bison", false},
{"", false},
}
for _, test := range tests {
result := client.isTTSModel(test.modelName)
if result != test.expected {
t.Errorf("For model %v, expected %v, got %v", test.modelName, test.expected, result)
}
}
}
// Test generateWAVFile method (basic test)
func TestGenerateWAVFile(t *testing.T) {
client := &Client{}
// Test with minimal PCM data
pcmData := []byte{0x00, 0x01, 0x02, 0x03}
result, err := client.generateWAVFile(pcmData)
if err != nil {
t.Errorf("generateWAVFile failed: %v", err)
}
// Check that we got some data back
if len(result) == 0 {
t.Error("generateWAVFile returned empty data")
}
// Check that it starts with RIFF header
if len(result) >= 4 && string(result[0:4]) != "RIFF" {
t.Error("Generated WAV data doesn't start with RIFF header")
}
}

View File

@@ -0,0 +1,218 @@
package gemini
import (
"fmt"
"sort"
)
// GeminiVoice represents a Gemini TTS voice with its characteristics
type GeminiVoice struct {
Name string
Description string
Characteristics []string
}
// GetGeminiVoices returns the current list of supported Gemini TTS voices
// This list is maintained based on official Google Gemini documentation
// https://ai.google.dev/gemini-api/docs/speech-generation
func GetGeminiVoices() []GeminiVoice {
return []GeminiVoice{
// Firm voices
{Name: "Kore", Description: "Firm and confident", Characteristics: []string{"firm", "confident", "default"}},
{Name: "Orus", Description: "Firm and decisive", Characteristics: []string{"firm", "decisive"}},
{Name: "Alnilam", Description: "Firm and strong", Characteristics: []string{"firm", "strong"}},
// Upbeat voices
{Name: "Puck", Description: "Upbeat and energetic", Characteristics: []string{"upbeat", "energetic"}},
{Name: "Laomedeia", Description: "Upbeat and lively", Characteristics: []string{"upbeat", "lively"}},
// Bright voices
{Name: "Zephyr", Description: "Bright and cheerful", Characteristics: []string{"bright", "cheerful"}},
{Name: "Autonoe", Description: "Bright and optimistic", Characteristics: []string{"bright", "optimistic"}},
// Informative voices
{Name: "Charon", Description: "Informative and clear", Characteristics: []string{"informative", "clear"}},
{Name: "Rasalgethi", Description: "Informative and professional", Characteristics: []string{"informative", "professional"}},
// Natural voices
{Name: "Aoede", Description: "Breezy and natural", Characteristics: []string{"breezy", "natural"}},
{Name: "Leda", Description: "Youthful and energetic", Characteristics: []string{"youthful", "energetic"}},
// Gentle voices
{Name: "Vindemiatrix", Description: "Gentle and kind", Characteristics: []string{"gentle", "kind"}},
{Name: "Achernar", Description: "Soft and gentle", Characteristics: []string{"soft", "gentle"}},
{Name: "Enceladus", Description: "Breathy and soft", Characteristics: []string{"breathy", "soft"}},
// Warm voices
{Name: "Sulafat", Description: "Warm and welcoming", Characteristics: []string{"warm", "welcoming"}},
{Name: "Capella", Description: "Warm and approachable", Characteristics: []string{"warm", "approachable"}},
// Clear voices
{Name: "Iapetus", Description: "Clear and articulate", Characteristics: []string{"clear", "articulate"}},
{Name: "Erinome", Description: "Clear and precise", Characteristics: []string{"clear", "precise"}},
// Pleasant voices
{Name: "Algieba", Description: "Smooth and pleasant", Characteristics: []string{"smooth", "pleasant"}},
{Name: "Vega", Description: "Smooth and flowing", Characteristics: []string{"smooth", "flowing"}},
// Textured voices
{Name: "Algenib", Description: "Gravelly texture", Characteristics: []string{"gravelly", "textured"}},
// Relaxed voices
{Name: "Callirrhoe", Description: "Easy-going and relaxed", Characteristics: []string{"relaxed", "easy-going"}},
{Name: "Despina", Description: "Calm and serene", Characteristics: []string{"calm", "serene"}},
// Mature voices
{Name: "Gacrux", Description: "Mature and experienced", Characteristics: []string{"mature", "experienced"}},
// Expressive voices
{Name: "Pulcherrima", Description: "Forward and expressive", Characteristics: []string{"forward", "expressive"}},
{Name: "Lyra", Description: "Melodic and expressive", Characteristics: []string{"melodic", "expressive"}},
// Dynamic voices
{Name: "Fenrir", Description: "Excitable and dynamic", Characteristics: []string{"excitable", "dynamic"}},
{Name: "Sadachbia", Description: "Lively and animated", Characteristics: []string{"lively", "animated"}},
// Friendly voices
{Name: "Achird", Description: "Friendly and approachable", Characteristics: []string{"friendly", "approachable"}},
// Casual voices
{Name: "Zubenelgenubi", Description: "Casual and conversational", Characteristics: []string{"casual", "conversational"}},
// Additional voices from latest API
{Name: "Sadaltager", Description: "Experimental voice with a calm and neutral tone", Characteristics: []string{"experimental", "calm", "neutral"}},
{Name: "Schedar", Description: "Experimental voice with a warm and engaging tone", Characteristics: []string{"experimental", "warm", "engaging"}},
{Name: "Umbriel", Description: "Experimental voice with a deep and resonant tone", Characteristics: []string{"experimental", "deep", "resonant"}},
}
}
// GetGeminiVoiceNames returns just the voice names in alphabetical order
func GetGeminiVoiceNames() []string {
voices := GetGeminiVoices()
names := make([]string, len(voices))
for i, voice := range voices {
names[i] = voice.Name
}
sort.Strings(names)
return names
}
// IsValidGeminiVoice checks if a voice name is valid
func IsValidGeminiVoice(voiceName string) bool {
if voiceName == "" {
return true // Empty voice is valid (will use default)
}
for _, voice := range GetGeminiVoices() {
if voice.Name == voiceName {
return true
}
}
return false
}
// GetGeminiVoiceByName returns a specific voice by name
func GetGeminiVoiceByName(name string) (*GeminiVoice, error) {
for _, voice := range GetGeminiVoices() {
if voice.Name == name {
return &voice, nil
}
}
return nil, fmt.Errorf("voice '%s' not found", name)
}
// ListGeminiVoices formats the voice list for display
func ListGeminiVoices(shellCompleteMode bool) string {
if shellCompleteMode {
// For shell completion, just return voice names
names := GetGeminiVoiceNames()
result := ""
for _, name := range names {
result += name + "\n"
}
return result
}
// For human-readable output
voices := GetGeminiVoices()
result := "Available Gemini Text-to-Speech voices:\n\n"
// Group by characteristics for better readability
groups := map[string][]GeminiVoice{
"Firm & Confident": {},
"Bright & Cheerful": {},
"Warm & Welcoming": {},
"Clear & Professional": {},
"Natural & Expressive": {},
"Other Voices": {},
}
for _, voice := range voices {
placed := false
for _, char := range voice.Characteristics {
switch char {
case "firm", "confident", "decisive", "strong":
if !placed {
groups["Firm & Confident"] = append(groups["Firm & Confident"], voice)
placed = true
}
case "bright", "cheerful", "upbeat", "energetic", "lively":
if !placed {
groups["Bright & Cheerful"] = append(groups["Bright & Cheerful"], voice)
placed = true
}
case "warm", "welcoming", "friendly", "approachable":
if !placed {
groups["Warm & Welcoming"] = append(groups["Warm & Welcoming"], voice)
placed = true
}
case "clear", "informative", "professional", "articulate":
if !placed {
groups["Clear & Professional"] = append(groups["Clear & Professional"], voice)
placed = true
}
case "natural", "expressive", "melodic", "breezy":
if !placed {
groups["Natural & Expressive"] = append(groups["Natural & Expressive"], voice)
placed = true
}
}
}
if !placed {
groups["Other Voices"] = append(groups["Other Voices"], voice)
}
}
// Output grouped voices
for groupName, groupVoices := range groups {
if len(groupVoices) > 0 {
result += fmt.Sprintf("%s:\n", groupName)
for _, voice := range groupVoices {
defaultStr := ""
if voice.Name == "Kore" {
defaultStr = " (default)"
}
result += fmt.Sprintf(" %-15s - %s%s\n", voice.Name, voice.Description, defaultStr)
}
result += "\n"
}
}
result += "Use --voice <voice_name> to select a specific voice.\n"
result += "Example: fabric --voice Charon -m gemini-2.0-flash-tts -o output.wav \"Hello world\"\n"
return result
}
// NOTE: This implementation maintains a curated list based on official Google documentation.
// In the future, if Google provides a dynamic voice discovery API, this can be updated
// to make API calls for real-time voice discovery.
//
// The current approach ensures:
// 1. Fast response times (no API calls needed)
// 2. Reliable voice information with descriptions
// 3. Easy maintenance when new voices are added
// 4. Offline functionality
//
// To update voices: Monitor Google's Gemini TTS documentation at:
// https://ai.google.dev/gemini-api/docs/speech-generation

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -61,6 +62,11 @@ func (t *transport_sec) RoundTrip(req *http.Request) (*http.Response, error) {
return t.underlyingTransport.RoundTrip(req)
}
// IsConfigured returns true only if OLLAMA_API_URL environment variable is explicitly set
func (o *Client) IsConfigured() bool {
return os.Getenv("OLLAMA_API_URL") != ""
}
func (o *Client) configure() (err error) {
if o.apiUrl, err = url.Parse(o.ApiUrl.Value); err != nil {
fmt.Printf("cannot parse URL: %s: %v\n", o.ApiUrl.Value, err)
@@ -160,6 +166,7 @@ func (o *Client) NeedsRawMode(modelName string) bool {
ollamaPrefixes := []string{
"llama3",
"llama2",
"mistral",
}
for _, prefix := range ollamaPrefixes {
if strings.HasPrefix(modelName, prefix) {

View File

@@ -0,0 +1,61 @@
package youtube
import (
"testing"
)
func TestParseTimestampToSeconds(t *testing.T) {
tests := []struct {
timestamp string
expected int
shouldErr bool
}{
{"00:30", 30, false},
{"01:30", 90, false},
{"01:05:30", 3930, false}, // 1 hour 5 minutes 30 seconds
{"10:00", 600, false},
{"invalid", 0, true},
{"1:2:3:4", 0, true}, // too many parts
}
for _, test := range tests {
result, err := parseTimestampToSeconds(test.timestamp)
if test.shouldErr {
if err == nil {
t.Errorf("Expected error for timestamp %s, but got none", test.timestamp)
}
} else {
if err != nil {
t.Errorf("Unexpected error for timestamp %s: %v", test.timestamp, err)
}
if result != test.expected {
t.Errorf("For timestamp %s, expected %d seconds, got %d", test.timestamp, test.expected, result)
}
}
}
}
func TestShouldIncludeRepeat(t *testing.T) {
tests := []struct {
lastTimestamp string
currentTimestamp string
expected bool
description string
}{
{"00:30", "01:30", true, "60 second gap should allow repeat"},
{"00:30", "00:45", true, "15 second gap should allow repeat"},
{"01:00", "01:10", true, "10 second gap should allow repeat (boundary case)"},
{"01:00", "01:09", false, "9 second gap should not allow repeat"},
{"00:30", "00:35", false, "5 second gap should not allow repeat"},
{"invalid", "01:30", true, "invalid timestamp should err on side of inclusion"},
{"01:30", "invalid", true, "invalid timestamp should err on side of inclusion"},
}
for _, test := range tests {
result := shouldIncludeRepeat(test.lastTimestamp, test.currentTimestamp)
if result != test.expected {
t.Errorf("%s: expected %v, got %v", test.description, test.expected, result)
}
}
}

View File

@@ -29,6 +29,15 @@ import (
"google.golang.org/api/youtube/v3"
)
var timestampRegex *regexp.Regexp
const TimeGapForRepeats = 10 // seconds
func init() {
// Match timestamps like "00:00:01.234" or just numbers or sequence numbers
timestampRegex = regexp.MustCompile(`^\d+$|^\d{1,2}:\d{2}(:\d{2})?(\.\d{3})?$`)
}
func NewYouTube() (ret *YouTube) {
label := "YouTube"
@@ -180,6 +189,7 @@ func (o *YouTube) readAndCleanVTTFile(filename string) (ret string, err error) {
// Convert VTT to plain text
lines := strings.Split(string(content), "\n")
var textBuilder strings.Builder
seenSegments := make(map[string]struct{})
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -193,8 +203,11 @@ func (o *YouTube) readAndCleanVTTFile(filename string) (ret string, err error) {
// Remove VTT formatting tags
line = removeVTTTags(line)
if line != "" {
textBuilder.WriteString(line)
textBuilder.WriteString(" ")
if _, exists := seenSegments[line]; !exists {
textBuilder.WriteString(line)
textBuilder.WriteString(" ")
seenSegments[line] = struct{}{}
}
}
}
@@ -215,6 +228,10 @@ func (o *YouTube) readAndFormatVTTWithTimestamps(filename string) (ret string, e
lines := strings.Split(string(content), "\n")
var textBuilder strings.Builder
var currentTimestamp string
// Track content with timestamps to allow repeats after significant time gaps
// This preserves legitimate repeated content (choruses, recurring phrases, etc.)
// while still filtering out immediate duplicates from VTT formatting issues
seenSegments := make(map[string]string) // text -> last timestamp seen
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -246,7 +263,20 @@ func (o *YouTube) readAndFormatVTTWithTimestamps(filename string) (ret string, e
// Remove VTT formatting tags
cleanText := removeVTTTags(line)
if cleanText != "" && currentTimestamp != "" {
textBuilder.WriteString(fmt.Sprintf("[%s] %s\n", currentTimestamp, cleanText))
// Check if we should include this segment
shouldInclude := true
if lastTimestamp, exists := seenSegments[cleanText]; exists {
// Calculate time difference to determine if this is a legitimate repeat
if !shouldIncludeRepeat(lastTimestamp, currentTimestamp) {
shouldInclude = false
}
}
if shouldInclude {
timestampedLine := fmt.Sprintf("[%s] %s", currentTimestamp, cleanText)
textBuilder.WriteString(timestampedLine + "\n")
seenSegments[cleanText] = currentTimestamp
}
}
}
}
@@ -268,8 +298,6 @@ func formatVTTTimestamp(vttTime string) string {
}
func isTimeStamp(s string) bool {
// Match timestamps like "00:00:01.234" or just numbers
timestampRegex := regexp.MustCompile(`^\d+$|^\d{2}:\d{2}:\d{2}`)
return timestampRegex.MatchString(s)
}
@@ -279,6 +307,76 @@ func removeVTTTags(s string) string {
return tagRegex.ReplaceAllString(s, "")
}
// shouldIncludeRepeat determines if repeated content should be included based on time gap
func shouldIncludeRepeat(lastTimestamp, currentTimestamp string) bool {
// Parse timestamps to calculate time difference
lastSeconds, err1 := parseTimestampToSeconds(lastTimestamp)
currentSeconds, err2 := parseTimestampToSeconds(currentTimestamp)
if err1 != nil || err2 != nil {
// If we can't parse timestamps, err on the side of inclusion
return true
}
// Allow repeats if there's at least a TimeGapForRepeats gap
// This threshold can be adjusted based on use case:
// - 10 seconds works well for most content
// - Could be made configurable in the future
timeDiffSeconds := currentSeconds - lastSeconds
return timeDiffSeconds >= TimeGapForRepeats
}
// parseTimestampToSeconds converts timestamp string (HH:MM:SS or MM:SS) to total seconds
func parseTimestampToSeconds(timestamp string) (int, error) {
parts := strings.Split(timestamp, ":")
if len(parts) < 2 || len(parts) > 3 {
return 0, fmt.Errorf("invalid timestamp format: %s", timestamp)
}
var hours, minutes, seconds int
var err error
if len(parts) == 3 {
// HH:MM:SS format
if hours, err = strconv.Atoi(parts[0]); err != nil {
return 0, err
}
if minutes, err = strconv.Atoi(parts[1]); err != nil {
return 0, err
}
if seconds, err = parseSeconds(parts[2]); err != nil {
return 0, err
}
} else {
// MM:SS format
if minutes, err = strconv.Atoi(parts[0]); err != nil {
return 0, err
}
if seconds, err = parseSeconds(parts[1]); err != nil {
return 0, err
}
}
return hours*3600 + minutes*60 + seconds, nil
}
func parseSeconds(seconds_str string) (int, error) {
var seconds int
var err error
if strings.Contains(seconds_str, ".") {
// Handle fractional seconds
second_parts := strings.Split(seconds_str, ".")
if seconds, err = strconv.Atoi(second_parts[0]); err != nil {
return 0, err
}
} else {
if seconds, err = strconv.Atoi(seconds_str); err != nil {
return 0, err
}
}
return seconds, nil
}
func (o *YouTube) GrabComments(videoId string) (ret []string, err error) {
if err = o.initService(); err != nil {
return

View File

@@ -4,9 +4,6 @@ schema = 3
[mod."cloud.google.com/go"]
version = "v0.121.2"
hash = "sha256-BCgGHxKti8slH98UDDurtgzX3lgcYEklsmj4ImPpwlc="
[mod."cloud.google.com/go/ai"]
version = "v0.12.1"
hash = "sha256-wg3oLMS68E/v7EdNzywbjwEmpk+u6U8LTnIc1pq8edo="
[mod."cloud.google.com/go/auth"]
version = "v0.16.2"
hash = "sha256-BAU9WGFKe0pd5Eu3l/Mbts+QeCOjS+lChr5hrPBCzdA="
@@ -16,9 +13,6 @@ schema = 3
[mod."cloud.google.com/go/compute/metadata"]
version = "v0.7.0"
hash = "sha256-jJZDW+hibqjMiY8OiJhgJALbGwEq+djLOxfYR7upQyE="
[mod."cloud.google.com/go/longrunning"]
version = "v0.6.7"
hash = "sha256-9I0Nc2KWAEVoxDngNkqFUdASmZIAySfMEELlPh3Q3xA="
[mod."dario.cat/mergo"]
version = "v1.0.2"
hash = "sha256-p6jdiHlLEfZES8vJnDywG4aVzIe16p0CU6iglglIweA="
@@ -163,9 +157,9 @@ schema = 3
[mod."github.com/golang/groupcache"]
version = "v0.0.0-20241129210726-2c02b8208cf8"
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
[mod."github.com/google/generative-ai-go"]
version = "v0.20.1"
hash = "sha256-9bSpEs4kByhgyTKiHdOY5muYjGBTluA1LvEjw2gSoLI="
[mod."github.com/google/go-cmp"]
version = "v0.7.0"
hash = "sha256-JbxZFBFGCh/Rj5XZ1vG94V2x7c18L8XKB0N9ZD5F2rM="
[mod."github.com/google/go-github/v66"]
version = "v66.0.0"
hash = "sha256-o4usfbApXwTuwIFMECagJwK2H4UMJbCpdyGdWZ5VUpI="
@@ -184,6 +178,9 @@ schema = 3
[mod."github.com/googleapis/gax-go/v2"]
version = "v2.14.2"
hash = "sha256-QyY7wuCkrOJCJIf9Q884KD/BC3vk/QtQLXeLeNPt750="
[mod."github.com/gorilla/websocket"]
version = "v1.5.3"
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
[mod."github.com/hasura/go-graphql-client"]
version = "v0.14.4"
hash = "sha256-TBNYIfC2CI0cVu7aZcHSWc6ZkgdkWSSfoCXqoAJT8jw="
@@ -292,9 +289,6 @@ schema = 3
[mod."go.opentelemetry.io/auto/sdk"]
version = "v1.1.0"
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
[mod."go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"]
version = "v0.61.0"
hash = "sha256-o5w9k3VbqP3gaXI3Aelw93LLHH53U4PnkYVwc3MaY3Y="
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
version = "v0.61.0"
hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM="
@@ -331,12 +325,12 @@ schema = 3
[mod."golang.org/x/text"]
version = "v0.27.0"
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
[mod."golang.org/x/time"]
version = "v0.12.0"
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
[mod."google.golang.org/api"]
version = "v0.236.0"
hash = "sha256-tP1RSUSnQ4a0axgZQwEZgKF1E13nL02FSP1NPSZr0Rc="
[mod."google.golang.org/genai"]
version = "v1.17.0"
hash = "sha256-Iw09DYpWuGR8E++dsFCBs702oKJPZLBEEGv0g4a4AhA="
[mod."google.golang.org/genproto/googleapis/api"]
version = "v0.0.0-20250603155806-513f23925822"
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="

View File

@@ -1 +1 @@
"1.4.257"
"1.4.268"

View File

@@ -0,0 +1,116 @@
# Docker Test Environment for API Configuration Fix
This directory contains a Docker-based testing setup for fixing the issue where Fabric calls Ollama and Bedrock APIs even when not configured. This addresses the problem where unconfigured services show error messages during model listing.
## Quick Start
```bash
# Run all tests
./scripts/docker-test/test-runner.sh
# Interactive mode - pick which test to run
./scripts/docker-test/test-runner.sh -i
# Run specific test case
./scripts/docker-test/test-runner.sh gemini-only
# Shell into test environment
./scripts/docker-test/test-runner.sh -s gemini-only
# Build image only (for development)
./scripts/docker-test/test-runner.sh -b
# Show help
./scripts/docker-test/test-runner.sh -h
```
## Test Cases
1. **no-config**: No APIs configured
2. **gemini-only**: Only Gemini configured (reproduces original issue #1195)
3. **openai-only**: Only OpenAI configured
4. **ollama-only**: Only Ollama configured
5. **bedrock-only**: Only Bedrock configured
6. **mixed**: Multiple APIs configured (Gemini + OpenAI + Ollama)
## Environment Files
Each test case has a corresponding environment file in `scripts/docker-test/env/`:
- `env.no-config` - Empty configuration
- `env.gemini-only` - Only Gemini API key
- `env.openai-only` - Only OpenAI API key
- `env.ollama-only` - Only Ollama URL
- `env.bedrock-only` - Only Bedrock configuration
- `env.mixed` - Multiple API configurations
These files are volume-mounted into the Docker container and persist changes made with `fabric -S`.
## Interactive Mode & Shell Access
The interactive mode (`-i`) provides several options:
```text
Available test cases:
1) No APIs configured (no-config)
2) Only Gemini configured (gemini-only)
3) Only OpenAI configured (openai-only)
4) Only Ollama configured (ollama-only)
5) Only Bedrock configured (bedrock-only)
6) Mixed configuration (mixed)
7) Run all tests
0) Exit
Add '!' after number to shell into test environment (e.g., '1!' to shell into no-config)
```
### Shell Mode
- Use `1!`, `2!`, etc. to shell into any test environment
- Run `fabric -S` to configure APIs interactively
- Run `fabric --listmodels` or `fabric -L` to test model listing
- Changes persist in the environment files
- Type `exit` to return to test runner
## Expected Results
**Before Fix:**
- `no-config` and `gemini-only` tests show Ollama connection errors
- Tests show Bedrock authentication errors when BEDROCK_AWS_REGION not set
- Error: `Ollama Get "http://localhost:11434/api/tags": dial tcp...`
- Error: `Bedrock failed to list foundation models...`
**After Fix:**
- Clean output with no error messages for unconfigured services
- Only configured services appear in model listings
- Ollama only initialized when `OLLAMA_API_URL` is set
- Bedrock only initialized when `BEDROCK_AWS_REGION` is set
## Implementation Details
- **Volume-mounted configs**: Environment files are mounted to `/home/testuser/.config/fabric/.env`
- **Persistent state**: Configuration changes survive between test runs
- **Single Docker image**: Built once from `scripts/docker-test/base/Dockerfile`, reused for all tests
- **Isolated environments**: Each test uses its own environment file
- **Cross-platform**: Works on macOS, Linux, and Windows with Docker
## Development Workflow
1. Make code changes to fix API initialization logic
2. Run `./scripts/docker-test/test-runner.sh no-config` to test the main issue
3. Use `./scripts/docker-test/test-runner.sh -i` for interactive testing
4. Shell into environments (`1!`, `2!`, etc.) to debug specific configurations
5. Run all tests before submitting PR: `./scripts/docker-test/test-runner.sh`
## Architecture
The fix involves:
1. **Ollama**: Override `IsConfigured()` method to check for `OLLAMA_API_URL` env var
2. **Bedrock**: Modify `hasAWSCredentials()` to require `BEDROCK_AWS_REGION`
3. **Plugin Registry**: Only initialize providers when properly configured
This prevents unnecessary API calls and eliminates confusing error messages for users.

View File

@@ -0,0 +1,30 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY ./cmd/fabric ./cmd/fabric
COPY ./internal ./internal
RUN go build -o fabric ./cmd/fabric
FROM alpine:latest
RUN apk --no-cache add ca-certificates
# Create a test user
RUN adduser -D -s /bin/sh testuser
# Switch to test user
USER testuser
WORKDIR /home/testuser
# Set environment variables for the test user
ENV HOME=/home/testuser
ENV USER=testuser
COPY --from=builder /app/fabric .
# Create fabric config directory and empty .env file
RUN mkdir -p .config/fabric && touch .config/fabric/.env
ENTRYPOINT ["./fabric"]

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bash
set -e
# Get the directory where this script is located
top_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
base_name="$(basename "$top_dir")"
cd "$top_dir"/../.. || exit 1
# Check if bash version supports associative arrays
if [[ ${BASH_VERSION%%.*} -lt 4 ]]; then
echo "This script requires bash 4.0 or later for associative arrays."
echo "Current version: $BASH_VERSION"
exit 1
fi
IMAGE_NAME="fabric-test-setup"
ENV_DIR="scripts/${base_name}/env"
# Test case descriptions
declare -A test_descriptions=(
["no-config"]="No APIs configured"
["gemini-only"]="Only Gemini configured (reproduces original issue)"
["openai-only"]="Only OpenAI configured"
["ollama-only"]="Only Ollama configured"
["bedrock-only"]="Only Bedrock configured"
["mixed"]="Mixed configuration (Gemini + OpenAI + Ollama)"
)
# Test case order for consistent display
test_order=("no-config" "gemini-only" "openai-only" "ollama-only" "bedrock-only" "mixed")
build_image() {
echo "=== Building Docker image ==="
docker build -f "${top_dir}/base/Dockerfile" -t "$IMAGE_NAME" .
echo
}
check_env_file() {
local test_name="$1"
local env_file="$ENV_DIR/env.$test_name"
if [[ ! -f "$env_file" ]]; then
echo "Error: Environment file not found: $env_file"
exit 1
fi
}
run_test() {
local test_name="$1"
local description="${test_descriptions[$test_name]}"
local env_file="$ENV_DIR/env.$test_name"
check_env_file "$test_name"
echo "===================="
echo "Test: $description"
echo "Config: $test_name"
echo "Env file: $env_file"
echo "===================="
echo "Running test..."
if docker run --rm \
-e HOME=/home/testuser \
-e USER=testuser \
-v "$(pwd)/$env_file:/home/testuser/.config/fabric/.env:ro" \
"$IMAGE_NAME" --listmodels 2>&1; then
echo "✅ Test completed"
else
echo "❌ Test failed"
fi
echo
}
shell_into_env() {
local test_name="$1"
local description="${test_descriptions[$test_name]}"
local env_file="$ENV_DIR/env.$test_name"
check_env_file "$test_name"
echo "===================="
echo "Shelling into: $description"
echo "Config: $test_name"
echo "Env file: $env_file"
echo "===================="
echo "You can now run 'fabric -S' to configure, or 'fabric --listmodels' or 'fabric -L' to test."
echo "Changes to .env will persist in $env_file"
echo "Type 'exit' to return to the test runner."
echo
docker run -it --rm \
-e HOME=/home/testuser \
-e USER=testuser \
-v "$(pwd)/$env_file:/home/testuser/.config/fabric/.env" \
--entrypoint=/bin/sh \
"$IMAGE_NAME"
}
interactive_mode() {
echo "=== Interactive Mode ==="
echo "Available test cases:"
echo
local i=1
local cases=()
for test_name in "${test_order[@]}"; do
echo "$i) ${test_descriptions[$test_name]} ($test_name)"
cases[i]="$test_name"
((i++))
done
echo "$i) Run all tests"
echo "0) Exit"
echo
echo "Add '!' after number to shell into test environment (e.g., '1!' to shell into no-config)"
echo
while true; do
read -r -p "Select test case (0-$i) [or 1!, etc. to shell into test environment]: " choice
# Check for shell mode (! suffix)
local shell_mode=false
if [[ "$choice" == *"!" ]]; then
shell_mode=true
choice="${choice%!}" # Remove the ! suffix
fi
if [[ "$choice" == "0" ]]; then
if [[ "$shell_mode" == true ]]; then
echo "Cannot shell into exit option."
continue
fi
echo "Exiting..."
exit 0
elif [[ "$choice" == "$i" ]]; then
if [[ "$shell_mode" == true ]]; then
echo "Cannot shell into 'run all tests' option."
continue
fi
echo "Running all tests..."
run_all_tests
break
elif [[ "$choice" -ge 1 && "$choice" -lt "$i" ]]; then
local selected_test="${cases[$choice]}"
if [[ "$shell_mode" == true ]]; then
echo "Shelling into: ${test_descriptions[$selected_test]}"
shell_into_env "$selected_test"
else
echo "Running: ${test_descriptions[$selected_test]}"
run_test "$selected_test"
fi
read -r -p "Continue testing? (y/n): " again
if [[ "$again" != "y" && "$again" != "Y" ]]; then
break
fi
echo
else
echo "Invalid choice. Please select 0-$i (optionally with '!' for shell mode)."
fi
done
}
run_all_tests() {
echo "=== Testing PR #1645: Conditional API initialization ==="
echo
for test_name in "${test_order[@]}"; do
run_test "$test_name"
done
echo "=== Test run complete ==="
echo "Review the output above to check:"
echo "1. No Ollama connection errors when OLLAMA_URL not set"
echo "2. No Bedrock authentication errors when BEDROCK_AWS_REGION not set"
echo "3. Only configured services appear in model listings"
}
show_help() {
echo "Usage: $0 [OPTIONS] [TEST_CASE]"
echo
echo "Test PR #1645 conditional API initialization"
echo
echo "Options:"
echo " -h, --help Show this help message"
echo " -i, --interactive Run in interactive mode"
echo " -b, --build-only Build image only, don't run tests"
echo " -s, --shell TEST Shell into test environment"
echo
echo "Test cases:"
for test_name in "${test_order[@]}"; do
echo " $test_name: ${test_descriptions[$test_name]}"
done
echo
echo "Examples:"
echo " $0 # Run all tests"
echo " $0 -i # Interactive mode"
echo " $0 gemini-only # Run specific test"
echo " $0 -s gemini-only # Shell into gemini-only environment"
echo " $0 -b # Build image only"
echo
echo "Environment files are located in $ENV_DIR/ and can be edited directly."
}
# Parse command line arguments
if [[ $# -eq 0 ]]; then
build_image
run_all_tests
elif [[ "$1" == "-h" || "$1" == "--help" ]]; then
show_help
elif [[ "$1" == "-i" || "$1" == "--interactive" ]]; then
build_image
interactive_mode
elif [[ "$1" == "-b" || "$1" == "--build-only" ]]; then
build_image
elif [[ "$1" == "-s" || "$1" == "--shell" ]]; then
if [[ -z "$2" ]]; then
echo "Error: -s/--shell requires a test case name"
echo "Use -h for help."
exit 1
fi
if [[ -z "${test_descriptions[$2]}" ]]; then
echo "Error: Unknown test case: $2"
echo "Use -h for help."
exit 1
fi
build_image
shell_into_env "$2"
elif [[ -n "${test_descriptions[$1]}" ]]; then
build_image
run_test "$1"
else
echo "Unknown test case or option: $1"
echo "Use -h for help."
exit 1
fi

View File

@@ -44,7 +44,7 @@ export default defineConfig({
'/api': {
target: FABRIC_BASE_URL,
changeOrigin: true,
timeout: 30000,
timeout: 900000,
rewrite: (path) => path.replace(/^\/api/, ''),
configure: (proxy, _options) => {
proxy.on('error', (err, req, res) => {
@@ -59,7 +59,7 @@ export default defineConfig({
'^/(patterns|models|sessions)/names': {
target: FABRIC_BASE_URL,
changeOrigin: true,
timeout: 30000,
timeout: 900000,
configure: (proxy, _options) => {
proxy.on('error', (err, req, res) => {
console.log('proxy error', err);