
Fabric
- 4 installs
- 53 repo stars
- Updated November 20, 2025
- danielmiessler/paiplugin
Automatically selects the right Fabric CLI pattern from 242+ prompts for tasks like threat modeling, summarization, extraction, and content creation.
About
Verifies the Fabric repo is available, then picks the best-matched pattern from 242+ specialized prompts based on user intent. Developers use it to process, analyze, summarize, or transform content via the Fabric CLI.
- Selects from 242+ Fabric patterns by intent
- Clones the Fabric repo automatically if missing
Fabric by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,780 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/danielmiessler/paiplugin --skill fabricAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 53 |
| Last updated | November 20, 2025 |
| Repository | danielmiessler/paiplugin ↗ |
What it does
Automatically selects the right Fabric CLI pattern from 242+ prompts for tasks like threat modeling, summarization, extraction, and content creation.
Files
Fabric Skill
Setup Check - Fabric Repository
IMPORTANT: Before using this skill, verify the Fabric repository is available:
# Check if Fabric repo exists
if [ ! -d "$HOME/.claude/skills/fabric/fabric-repo" ]; then
echo "Fabric repository not found. Cloning..."
cd "$HOME/.claude/skills/fabric"
git clone https://github.com/danielmiessler/fabric.git fabric-repo
echo "Fabric repository cloned successfully."
else
echo "Fabric repository found at $HOME/.claude/skills/fabric/fabric-repo"
fiIf the repo doesn't exist, clone it immediately before proceeding with any pattern selection.
When to Activate This Skill
Primary Use Cases:
- "Create a threat model for..."
- "Summarize this article/video/paper..."
- "Extract wisdom/insights from..."
- "Analyze this [code/malware/claims/debate]..."
- "Improve my writing/code/prompt..."
- "Create a [visualization/summary/report]..."
- "Rate/review/judge this content..."
The Goal: Select the RIGHT pattern from 242+ available patterns based on what you're trying to accomplish.
🎯 Pattern Selection Strategy
When a user requests Fabric processing, follow this decision tree:
1. Identify Intent Category
Threat Modeling & Security:
- Threat model →
create_threat_modelorcreate_stride_threat_model - Threat scenarios →
create_threat_scenarios - Security update →
create_security_update - Security rules →
create_sigma_rules,write_nuclei_template_rule,write_semgrep_rule - Threat analysis →
analyze_threat_report,analyze_threat_report_trends
Summarization:
- General summary →
summarize - 5-sentence summary →
create_5_sentence_summary - Micro summary →
create_micro_summaryorsummarize_micro - Meeting →
summarize_meeting - Paper/research →
summarize_paper - Video/YouTube →
youtube_summary - Newsletter →
summarize_newsletter - Code changes →
summarize_git_changesorsummarize_git_diff
Wisdom Extraction:
- General wisdom →
extract_wisdom - Article wisdom →
extract_article_wisdom - Book ideas →
extract_book_ideas - Insights →
extract_insightsorextract_insights_dm - Main idea →
extract_main_idea - Recommendations →
extract_recommendations - Controversial ideas →
extract_controversial_ideas
Analysis:
- Malware →
analyze_malware - Code →
analyze_codeorreview_code - Claims →
analyze_claims - Debate →
analyze_debate - Logs →
analyze_logs - Paper →
analyze_paper - Threat report →
analyze_threat_report - Product feedback →
analyze_product_feedback - Sales call →
analyze_sales_call
Content Creation:
- PRD →
create_prd - Design document →
create_design_document - User story →
create_user_story - Visualization →
create_visualization,create_mermaid_visualization,create_markmap_visualization - Essay →
write_essay - Report finding →
create_report_finding - Newsletter entry →
create_newsletter_entry
Improvement:
- Writing →
improve_writing - Academic writing →
improve_academic_writing - Prompt →
improve_prompt - Report finding →
improve_report_finding - Code →
review_code
Rating/Evaluation:
- AI response →
rate_ai_response - Content quality →
rate_content - Value assessment →
rate_value - General judgment →
judge_output
2. Execute Pattern
# Basic format
fabric [input] -p [selected_pattern]
# From URL
fabric -u "URL" -p [pattern]
# From YouTube
fabric -y "YOUTUBE_URL" -p [pattern]
# From file
cat file.txt | fabric -p [pattern]
# Direct text
fabric "your text here" -p [pattern]📚 Pattern Categories (242 Total)
Threat Modeling & Security (15 patterns)
create_threat_model- General threat modelingcreate_stride_threat_model- STRIDE methodologycreate_threat_scenarios- Threat scenario generationcreate_security_update- Security update documentationcreate_sigma_rules- SIGMA detection ruleswrite_nuclei_template_rule- Nuclei scanner templateswrite_semgrep_rule- Semgrep static analysis rulesanalyze_threat_report- Threat report analysisanalyze_threat_report_cmds- Extract commands from threat reportsanalyze_threat_report_trends- Identify threat trendst_threat_model_plans- Threat model for plansask_secure_by_design_questions- Secure by design questionscreate_network_threat_landscape- Network threat landscapeanalyze_incident- Incident analysisanalyze_risk- Risk analysis
Summarization (20 patterns)
summarize- General summarizationcreate_5_sentence_summary- Ultra-concise 5-line summarycreate_micro_summary- Micro summarycreate_summary- Detailed summarysummarize_micro- Micro summarizationsummarize_meeting- Meeting notes summarysummarize_paper- Academic paper summarysummarize_lecture- Lecture summarysummarize_newsletter- Newsletter summarysummarize_debate- Debate summarysummarize_legislation- Legislation summarysummarize_rpg_session- RPG session summarysummarize_board_meeting- Board meeting summarysummarize_git_changes- Git changes summarysummarize_git_diff- Git diff summarysummarize_pull-requests- PR summarysummarize_prompt- Prompt summaryyoutube_summary- YouTube video summarycreate_ul_summary- Unsupervised Learning summarycreate_cyber_summary- Cybersecurity summary
Extraction (30+ patterns)
extract_wisdom- General wisdom extractionextract_article_wisdom- Article-specific wisdomextract_book_ideas- Book ideasextract_insights- General insightsextract_insights_dm- Daniel Miessler style insightsextract_main_idea- Core messageextract_recommendations- Recommendationsextract_ideas- Ideas from contentextract_questions- Questions raisedextract_predictions- Predictions madeextract_controversial_ideas- Controversial pointsextract_business_ideas- Business opportunitiesextract_skills- Skills mentionedextract_patterns- Patterns identifiedextract_sponsors- Sponsor mentionsextract_references- References citedextract_instructions- Instructions from contentextract_jokes- Humor extractionextract_primary_problem- Main problemextract_primary_solution- Main solutionextract_product_features- Product featuresextract_core_message- Core messageextract_algorithm_update_recommendations- Algorithm recommendationsextract_extraordinary_claims- Extraordinary claimsextract_most_redeeming_thing- Most valuable aspect
Analysis (35+ patterns)
analyze_claims- Claim analysisanalyze_malware- Malware analysisanalyze_code- Code analysisanalyze_paper- Paper analysisanalyze_logs- Log analysisanalyze_debate- Debate analysisanalyze_incident- Incident analysisanalyze_comments- Comment analysisanalyze_answers- Answer analysisanalyze_email_headers- Email header analysisanalyze_military_strategy- Military strategyanalyze_mistakes- Mistake analysisanalyze_personality- Personality analysisanalyze_presentation- Presentation analysisanalyze_product_feedback- Product feedbackanalyze_proposition- Proposition analysisanalyze_prose- Prose analysisanalyze_risk- Risk analysisanalyze_sales_call- Sales call analysisanalyze_spiritual_text- Spiritual text analysisanalyze_tech_impact- Tech impact analysisanalyze_threat_report- Threat report analysisanalyze_bill- Legislation analysisanalyze_candidates- Candidate analysisanalyze_cfp_submission- CFP submission analysisanalyze_terraform_plan- Terraform plan analysisanalyze_interviewer_techniques- Interviewer technique analysis
Creation (50+ patterns)
create_prd- Product Requirements Documentcreate_design_document- Design documentationcreate_user_story- User storiescreate_coding_project- Coding projectcreate_coding_feature- Code featurescreate_mermaid_visualization- Mermaid diagramscreate_markmap_visualization- Markmap mindmapscreate_visualization- General visualizationscreate_threat_model- Threat modelscreate_stride_threat_model- STRIDE threat modelscreate_threat_scenarios- Threat scenarioscreate_report_finding- Report findingscreate_newsletter_entry- Newsletter contentcreate_keynote- Keynote presentationscreate_academic_paper- Academic paperscreate_flash_cards- Study flashcardscreate_quiz- Quizzescreate_graph_from_input- Graphscreate_tags- Content tagscreate_art_prompt- Art generation promptscreate_command- CLI commandscreate_pattern- Fabric patternscreate_logo- Logo designscreate_podcast_image- Podcast imagerycreate_sigma_rules- SIGMA rulescreate_video_chapters- Video chapterscreate_upgrade_pack- Upgrade documentation
Improvement (10 patterns)
improve_writing- General writing improvementimprove_academic_writing- Academic writingimprove_prompt- Prompt engineeringimprove_report_finding- Report findingsreview_code- Code reviewreview_design- Design reviewrefine_design_document- Design refinementhumanize- Humanize AI textenrich_blog_post- Blog enhancementclean_text- Text cleanup
Rating/Judgment (8 patterns)
rate_ai_response- Rate AI outputsrate_ai_result- Rate AI resultsrate_content- Rate content qualityrate_value- Rate value propositionjudge_output- General judgmentlabel_and_rate- Label and ratecheck_agreement- Agreement checkingarbiter-evaluate-quality- Quality evaluation
🔄 Updating Patterns
The Fabric repository is included in this skill at ${PAI_DIR}/skills/fabric/fabric-repo/.
To update patterns:
cd ${PAI_DIR}/skills/fabric/fabric-repo
git pull origin mainTo see all available patterns:
ls ${PAI_DIR}/skills/fabric/fabric-repo/data/patterns/
# OR from your local Fabric install:
ls ~/.config/fabric/patterns/💡 Usage Examples
Threat Modeling:
# User: "Create a threat model for our new API"
fabric "API that handles user authentication and payment processing" -p create_threat_modelSummarization:
# User: "Summarize this blog post"
fabric -u "https://example.com/blog-post" -p summarize
# User: "Give me a 5-sentence summary"
fabric -u "https://example.com/article" -p create_5_sentence_summaryWisdom Extraction:
# User: "Extract wisdom from this video"
fabric -y "https://youtube.com/watch?v=..." -p extract_wisdom
# User: "What are the main ideas?"
fabric -u "URL" -p extract_main_ideaAnalysis:
# User: "Analyze this code for issues"
fabric "$(cat code.py)" -p analyze_code
# User: "Analyze these security claims"
fabric "security claims text" -p analyze_claims🎯 Pattern Selection Decision Matrix
| User Request Contains | Likely Intent | Recommended Patterns |
|---|---|---|
| "threat model" | Security modeling | create_threat_model, create_stride_threat_model |
| "summarize", "summary" | Summarization | summarize, create_5_sentence_summary |
| "extract wisdom", "insights" | Wisdom extraction | extract_wisdom, extract_insights |
| "analyze [X]" | Analysis | analyze_[X] (match X to pattern) |
| "improve", "enhance" | Improvement | improve_writing, improve_prompt |
| "create [visualization]" | Visualization | create_mermaid_visualization, create_markmap_visualization |
| "rate", "judge", "evaluate" | Rating | rate_content, judge_output |
| "main idea", "core message" | Core extraction | extract_main_idea, extract_core_message |
🚀 Advanced Usage
Pipe content through Fabric:
cat article.txt | fabric -p extract_wisdom
pbpaste | fabric -p summarize
curl -s "https://..." | fabric -p analyze_claimsProcess YouTube videos:
# Fabric handles download + transcription + processing
fabric -y "https://youtube.com/watch?v=..." -p youtube_summaryChain patterns (manual):
# Extract then summarize
fabric -u "URL" -p extract_wisdom > wisdom.txt
cat wisdom.txt | fabric -p create_5_sentence_summary📖 Supplementary Resources
Full Pattern List: ls ${PAI_DIR}/skills/fabric/fabric-repo/data/patterns/ Fabric Repo: ${PAI_DIR}/skills/fabric/fabric-repo/ Fabric Documentation: https://github.com/danielmiessler/fabric Pattern Templates: See ${PAI_DIR}/skills/fabric/fabric-repo/data/patterns/official_pattern_template/
🔑 Key Insight
The skill's value is in selecting the RIGHT pattern for the task.
When user says "Create a threat model using Fabric", your job is to: 1. Recognize "threat model" intent 2. Know available options: create_threat_model, create_stride_threat_model, create_threat_scenarios 3. Select the best match (usually create_threat_model unless STRIDE specified) 4. Execute: fabric "[content]" -p create_threat_model
Not: "Here are the patterns, pick one" Instead: "I'll use create_threat_model for this" → execute immediately
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"features": {
}
}
name: Bug Report
description: File a bug report.
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please provide as much detail as possible to help us understand and reproduce the issue.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "Please provide all the steps to reproduce the bug. I was doing THIS, when THAT happened. I was expecting THAT_OTHER_THING to happen instead."
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS - Silicon (arm64)
- macOS - Intel (amd64)
- Linux - amd64
- Linux - arm64
- Windows
validations:
required: true
- type: textarea
id: os-version
attributes:
label: OS Version
description: Please provide details about your OS version by running one of the following commands.
placeholder: |
macOS: `sw_vers`
Linux: `uname -a` or `cat /etc/os-release`
Windows: `ver`
render: shell
- type: dropdown
id: installation
attributes:
label: How did you install Fabric?
description: "Please select the method you used to install Fabric. You can find this information in the [Installation section of the README](https://github.com/ksylvan/fabric/blob/main/README.md#installation)."
options:
- Release Binary - Windows
- Release Binary - macOS (arm64)
- Release Binary - macOS (amd64)
- Release Binary - Linux (amd64)
- Release Binary - Linux (arm64)
- Package Manager - Homebrew (macOS)
- Package Manager - AUR (Arch Linux)
- From Source
- Other
validations:
required: true
- type: textarea
id: version
attributes:
label: Version
description: Please copy and paste the output of `fabric --version` (or `fabric-ai --version` if you installed it via brew) here.
render: text
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: textarea
id: screens
attributes:
label: Relevant screenshots (optional)
description: Please upload any screenshots that may help us reproduce and/or understand the issue.
name: Feature Request
description: Suggest features for this project.
title: "[Feature request]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you need?
description: Tell us what functionality you would like added/modified?
value: "I want the CLI to do my homework for me."
validations:
required: true
name: Question
description: Ask us questions about this project.
title: "[Question]: "
labels: ["question"]
body:
- type: textarea
id: description
attributes:
label: What is your question?
value: "After reading the documentation, I am still not clear how to get X working. I tried this, this, and that."
validations:
required: true
What this Pull Request (PR) does
Please briefly describe what this PR does.
Related issues
Please reference any open issues this PR relates to in here. If it closes an issue, type closes #[ISSUE_NUMBER].
Screenshots
Provide any screenshots you may find relevant to facilitate us understanding your PR.
name: Go Build
on:
push:
branches: ["main"]
paths-ignore:
- "data/patterns/**"
- "**/*.md"
pull_request:
branches: ["main"]
paths-ignore:
- "data/patterns/**"
- "**/*.md"
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Run tests
run: go test -v ./...
- name: Check Formatting
run: nix flake check
name: Patterns Artifact
on:
push:
paths:
- "data/patterns/**" # Trigger only on changes to files in the patterns folder
jobs:
zip-and-upload:
name: Zip and Upload Patterns Folder
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Verify Changes in Patterns Folder
id: check-changes
run: |
git fetch origin
if git diff --quiet HEAD~1 -- data/patterns; then
echo "No changes detected in patterns folder."
echo "changes=false" >> $GITHUB_OUTPUT
else
echo "Changes detected in patterns folder."
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Zip the Patterns Folder
if: steps.check-changes.outputs.changes == 'true'
run: zip -r patterns.zip data/patterns/
- name: Upload Patterns Artifact
if: steps.check-changes.outputs.changes == 'true'
uses: actions/upload-artifact@v4
with:
name: patterns
path: patterns.zip
name: Go Release
on:
repository_dispatch:
types: [tag_created]
push:
tags:
- "v*"
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Run tests
run: go test -v ./...
build:
# only run in main upstream repo
if: ${{ github.repository_owner == 'danielmiessler' }}
name: Build & Release with Goreleaser
needs: [test]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update Release Description
run: go run ./cmd/generate_changelog --release ${{ github.event.client_payload.tag || github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Update Version File and Create Tag
on:
push:
branches:
- main # Monitor the main branch
paths-ignore:
- "data/patterns/**"
- "data/strategies/**"
- "cmd/generate_changelog/*.db"
- "cmd/generate_changelog/incoming/*.txt"
- "scripts/pattern_descriptions/*.json"
- "web/static/data/pattern_descriptions.json"
- "**/*.md"
- .vscode/**
permissions:
contents: write # Ensure the workflow has write permissions
concurrency:
group: version-update
cancel-in-progress: false
jobs:
update-version:
if: >
${{ github.repository_owner == 'danielmiessler' }} &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Set up Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Pull latest main and tags
run: |
git pull --rebase origin main
git fetch --tags
- name: Get the latest tag
id: get_latest_tag
run: |
latest_tag=$(git tag --sort=-creatordate | head -n 1)
echo "Latest tag is: $latest_tag"
echo "tag=$latest_tag" >> $GITHUB_OUTPUT
echo "tag=$latest_tag" >> $GITHUB_ENV # Save the latest tag to environment file
- name: Increment patch version
id: increment_version
run: |
latest_tag=${{ steps.get_latest_tag.outputs.tag }}
major=$(echo "$latest_tag" | cut -d. -f1 | sed 's/v//')
minor=$(echo "$latest_tag" | cut -d. -f2)
patch=$(echo "$latest_tag" | cut -d. -f3)
new_patch=$((patch + 1))
new_version="${major}.${minor}.${new_patch}"
new_tag="v${new_version}"
echo "New version is: $new_version"
echo "new_version=$new_version" >> $GITHUB_OUTPUT
echo "new_version=$new_version" >> $GITHUB_ENV # Save the new version to environment file
echo "New tag is: $new_tag"
echo "new_tag=$new_tag" >> $GITHUB_OUTPUT
echo "new_tag=$new_tag" >> $GITHUB_ENV # Save the new tag to environment file
- name: Update version.go file
run: |
echo "package main" > cmd/fabric/version.go
echo "" >> cmd/fabric/version.go
echo "var version = \"${{ steps.increment_version.outputs.new_tag }}\"" >> cmd/fabric/version.go
- name: Update version.nix file
run: |
echo "\"${{ steps.increment_version.outputs.new_version }}\"" > nix/pkgs/fabric/version.nix
- name: Format source code
run: |
nix fmt
- name: Update gomod2nix.toml file
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 ${{ steps.increment_version.outputs.new_tag }}
go run ./cmd/generate_changelog --sync-db
git add ./cmd/generate_changelog/changelog.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
# 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 "chore(release): Update version to ${{ steps.increment_version.outputs.new_tag }}"
else
echo "No changes to commit."
fi
- name: Push changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN to authenticate the push
run: |
git push origin main # Push changes to the main branch
- name: Create a new tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git tag ${{ steps.increment_version.outputs.new_tag }}
git push origin ${{ steps.increment_version.outputs.new_tag }} # Push the new tag
- name: Dispatch event to trigger release workflow
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN to authenticate the dispatch
run: |
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/dispatches \
-d '{"event_type": "tag_created", "client_payload": {"tag": "${{ steps.increment_version.outputs.new_tag }}"}}'
# Nix
.direnv
result
# macOS local stores
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
.idea
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
coverage.out
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
venv/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
patterns/dialog_with_socrates/Apology by Plato.txt
patterns/dialog_with_socrates/Phaedrus by Plato.txt
patterns/dialog_with_socrates/Symposium by Plato.txt
patterns/dialog_with_socrates/The Economist by Xenophon.txt
patterns/dialog_with_socrates/The Memorabilia by Xenophon.txt
patterns/dialog_with_socrates/The Memorable Thoughts of Socrates by Xenophon.txt
patterns/dialog_with_socrates/The Republic by Plato.txt
patterns/dialog_with_socrates/The Symposium by Xenophon.txt
web/node_modules
# Output
web/.output
web/.vercel
web/.svelte-kit
web/build
# OS
web/.DS_Store
web/Thumbs.db
# Env
web/.env
web/.env.*
web/!.env.example
web/!.env.test
# Vite
web/vite.config.js.timestamp-*
web/vite.config.ts.timestamp-*
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
web/logs
web/*.log
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/lerna-debug.log*
web/.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
web/report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
web/pids
web/*.pid
web/*.seed
web/*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
web/lib-cov
# Coverage directory used by tools like istanbul
web/coverage
web/*.lcov
# nyc test coverage
web/.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
web/.grunt
# Bower dependency directory (https://bower.io/)
web/bower_components
# node-waf configuration
web/.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
web/node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web/web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
web/.npm
# Optional eslint cache
web/.eslintcache
# Optional stylelint cache
web/.stylelintcache
# Microbundle cache
web/.rpt2_cache/
web/.rts2_cache_cjs/
web/.rts2_cache_es/
web/.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
web/.env
web/.env.development.local
web/.env.test.local
web/.env.production.local
web/.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
web/.next
web/out
# Nuxt.js build / generate output
web/.nuxt
web/dist
# Gatsby files
web/.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
web/.vuepress/dist
# vuepress v2.x temp and cache directory
web/.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
web/.vscode-test
# yarn v2
web/.yarn/cache
web/.yarn/unplugged
web/.yarn/build-state.yml
web/.yarn/install-state.gz
web/.pnp.*
### Node Patch ###
# Serverless Webpack directories
web/.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
web/.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node
web/myfiles/Obsidian_perso_not_share/
ENV
web/package-lock.json
.gitignore_backup
web/static/*.png
# Local tmp directory
.tmp/
tmp/
# Ignore .claude/
.claude/
# Read the documentation at https://goreleaser.com
# For a full reference of the configuration file.
version: 2
project_name: fabric
before:
hooks:
- go mod tidy
builds:
- id: default
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
main: ./cmd/fabric
binary: fabric
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
- -X main.builtBy=goreleaser
- -X main.tag={{.Tag}}
- id: windows-build
env:
- CGO_ENABLED=0
goos:
- windows
main: ./cmd/fabric
binary: fabric
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
- -X main.builtBy=goreleaser
- -X main.tag={{.Tag}}
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// FileItem represents a file in the project
type FileItem struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content,omitempty"`
Contents []FileItem `json:"contents,omitempty"`
}
// ProjectData represents the entire project structure with instructions
type ProjectData struct {
Files []FileItem `json:"files"`
Instructions struct {
Type string `json:"type"`
Name string `json:"name"`
Details string `json:"details"`
} `json:"instructions"`
Report struct {
Type string `json:"type"`
Directories int `json:"directories"`
Files int `json:"files"`
} `json:"report"`
}
// ScanDirectory scans a directory and returns a JSON representation of its structure
func ScanDirectory(rootDir string, maxDepth int, instructions string, ignoreList []string) ([]byte, error) {
// Count totals for report
dirCount := 1
fileCount := 0
// Create root directory item
rootItem := FileItem{
Type: "directory",
Name: rootDir,
Contents: []FileItem{},
}
// Walk through the directory
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip .git directory
if strings.Contains(path, ".git") {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// Check if path matches any ignore pattern
relPath, err := filepath.Rel(rootDir, path)
if err != nil {
return err
}
for _, pattern := range ignoreList {
if strings.Contains(relPath, pattern) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
if relPath == "." {
return nil
}
depth := len(strings.Split(relPath, string(filepath.Separator)))
if depth > maxDepth {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// Create directory structure
if info.IsDir() {
dirCount++
} else {
fileCount++
// Read file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading file %s: %v", path, err)
}
// Add file to appropriate parent directory
addFileToDirectory(&rootItem, relPath, string(content), rootDir)
}
return nil
})
if err != nil {
return nil, err
}
// Create final data structure
var data []interface{}
data = append(data, rootItem)
// Add report
reportItem := map[string]interface{}{
"type": "report",
"directories": dirCount,
"files": fileCount,
}
data = append(data, reportItem)
// Add instructions
instructionsItem := map[string]interface{}{
"type": "instructions",
"name": "code_change_instructions",
"details": instructions,
}
data = append(data, instructionsItem)
return json.MarshalIndent(data, "", " ")
}
// addFileToDirectory adds a file to the correct directory in the structure
func addFileToDirectory(root *FileItem, path, content, rootDir string) {
parts := strings.Split(path, string(filepath.Separator))
// If this is a file at the root level
if len(parts) == 1 {
root.Contents = append(root.Contents, FileItem{
Type: "file",
Name: parts[0],
Content: content,
})
return
}
// Otherwise, find or create the directory path
current := root
for i := 0; i < len(parts)-1; i++ {
dirName := parts[i]
found := false
// Look for existing directory
for j, item := range current.Contents {
if item.Type == "directory" && item.Name == dirName {
current = ¤t.Contents[j]
found = true
break
}
}
// Create directory if not found
if !found {
newDir := FileItem{
Type: "directory",
Name: dirName,
Contents: []FileItem{},
}
current.Contents = append(current.Contents, newDir)
current = ¤t.Contents[len(current.Contents)-1]
}
}
// Add the file to the current directory
current.Contents = append(current.Contents, FileItem{
Type: "file",
Name: parts[len(parts)-1],
Content: content,
})
}
package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
// Command line flags
maxDepth := flag.Int("depth", 3, "Maximum directory depth to scan")
ignorePatterns := flag.String("ignore", ".git,node_modules,vendor", "Comma-separated patterns to ignore")
outputFile := flag.String("out", "", "Output file (default: stdout)")
flag.Usage = printUsage
flag.Parse()
// Require exactly two positional arguments: directory and instructions
if flag.NArg() != 2 {
printUsage()
os.Exit(1)
}
directory := flag.Arg(0)
instructions := flag.Arg(1)
// Validate directory
if info, err := os.Stat(directory); err != nil || !info.IsDir() {
fmt.Fprintf(os.Stderr, "Error: Directory '%s' does not exist or is not a directory\n", directory)
os.Exit(1)
}
// Parse ignore patterns and scan directory
jsonData, err := ScanDirectory(directory, *maxDepth, instructions, strings.Split(*ignorePatterns, ","))
if err != nil {
fmt.Fprintf(os.Stderr, "Error scanning directory: %v\n", err)
os.Exit(1)
}
// Output result
if *outputFile != "" {
if err := os.WriteFile(*outputFile, jsonData, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
} else {
fmt.Print(string(jsonData))
}
}
func printUsage() {
fmt.Fprintf(os.Stderr, `code_helper - Code project scanner for use with Fabric AI
Usage:
code_helper [options] <directory> <instructions>
Examples:
code_helper . "Add input validation to all user inputs"
code_helper -depth 4 ./my-project "Implement error handling"
code_helper -out project.json ./src "Fix security issues"
Options:
`)
flag.PrintDefaults()
}
package main
import (
"fmt"
"os"
"github.com/jessevdk/go-flags"
"github.com/danielmiessler/fabric/internal/cli"
)
func main() {
err := cli.Cli(version)
if err != nil && !flags.WroteHelp(err) {
fmt.Printf("%s\n", err)
os.Exit(1)
}
}
package main
var version = "v1.4.319"
### PR [#1780](https://github.com/danielmiessler/Fabric/pull/1780) by [marcas756](https://github.com/marcas756): feat: add extract_characters pattern
- Define character extraction goals and steps with canonical naming and deduplication rules
- Outline interaction mapping and narrative importance analysis
- Provide comprehensive output schema with proper formatting guidelines
- Include positive and negative examples for pattern clarity
- Enforce restrictions on speculative motivations and non-actor inclusion
### PR [#1794](https://github.com/danielmiessler/Fabric/pull/1794) by [starfish456](https://github.com/starfish456): Enhance web app docs
- Remove duplicate content from the main readme and link to the web app readme
- Update table of contents with proper nesting and fix minor formatting issues
package cache
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/git"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/github"
_ "github.com/mattn/go-sqlite3"
)
type Cache struct {
db *sql.DB
}
func New(dbPath string) (*Cache, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
cache := &Cache{db: db}
if err := cache.createTables(); err != nil {
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return cache, nil
}
func (c *Cache) Close() error {
return c.db.Close()
}
func (c *Cache) createTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS versions (
name TEXT PRIMARY KEY,
date DATETIME,
commit_sha TEXT,
pr_numbers TEXT,
ai_summary TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS commits (
sha TEXT PRIMARY KEY,
version TEXT NOT NULL,
message TEXT,
author TEXT,
email TEXT,
date DATETIME,
is_merge BOOLEAN,
pr_number INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (version) REFERENCES versions(name)
)`,
`CREATE TABLE IF NOT EXISTS pull_requests (
number INTEGER PRIMARY KEY,
title TEXT,
body TEXT,
author TEXT,
author_url TEXT,
author_type TEXT DEFAULT 'user',
url TEXT,
merged_at DATETIME,
merge_commit TEXT,
commits TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_commits_version ON commits(version)`,
`CREATE INDEX IF NOT EXISTS idx_commits_pr_number ON commits(pr_number)`,
`CREATE TABLE IF NOT EXISTS commit_pr_mapping (
commit_sha TEXT PRIMARY KEY,
pr_number INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pr_number) REFERENCES pull_requests(number)
)`,
`CREATE INDEX IF NOT EXISTS idx_commit_pr_mapping_sha ON commit_pr_mapping(commit_sha)`,
}
for _, query := range queries {
if _, err := c.db.Exec(query); err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
}
return nil
}
func (c *Cache) GetLastProcessedTag() (string, error) {
var tag string
err := c.db.QueryRow("SELECT value FROM metadata WHERE key = 'last_processed_tag'").Scan(&tag)
if err == sql.ErrNoRows {
return "", nil
}
return tag, err
}
func (c *Cache) SetLastProcessedTag(tag string) error {
_, err := c.db.Exec(`
INSERT OR REPLACE INTO metadata (key, value, updated_at)
VALUES ('last_processed_tag', ?, CURRENT_TIMESTAMP)
`, tag)
return err
}
func (c *Cache) SaveVersion(v *git.Version) error {
prNumbers, _ := json.Marshal(v.PRNumbers)
_, err := c.db.Exec(`
INSERT OR REPLACE INTO versions (name, date, commit_sha, pr_numbers, ai_summary)
VALUES (?, ?, ?, ?, ?)
`, v.Name, v.Date, v.CommitSHA, string(prNumbers), v.AISummary)
return err
}
// UpdateVersionAISummary updates only the AI summary for a specific version
func (c *Cache) UpdateVersionAISummary(versionName, aiSummary string) error {
_, err := c.db.Exec(`
UPDATE versions SET ai_summary = ? WHERE name = ?
`, aiSummary, versionName)
return err
}
func (c *Cache) SaveCommit(commit *git.Commit, version string) error {
_, err := c.db.Exec(`
INSERT OR REPLACE INTO commits
(sha, version, message, author, email, date, is_merge, pr_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, commit.SHA, version, commit.Message, commit.Author, commit.Email,
commit.Date, commit.IsMerge, commit.PRNumber)
return err
}
func (c *Cache) SavePR(pr *github.PR) error {
commits, _ := json.Marshal(pr.Commits)
_, err := c.db.Exec(`
INSERT OR REPLACE INTO pull_requests
(number, title, body, author, author_url, author_type, url, merged_at, merge_commit, commits)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, pr.Number, pr.Title, pr.Body, pr.Author, pr.AuthorURL, pr.AuthorType,
pr.URL, pr.MergedAt, pr.MergeCommit, string(commits))
return err
}
func (c *Cache) GetPR(number int) (*github.PR, error) {
var pr github.PR
var commitsJSON string
err := c.db.QueryRow(`
SELECT number, title, body, author, author_url, COALESCE(author_type, 'user'), url, merged_at, merge_commit, commits
FROM pull_requests WHERE number = ?
`, number).Scan(
&pr.Number, &pr.Title, &pr.Body, &pr.Author, &pr.AuthorURL, &pr.AuthorType,
&pr.URL, &pr.MergedAt, &pr.MergeCommit, &commitsJSON,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(commitsJSON), &pr.Commits); err != nil {
return nil, fmt.Errorf("failed to unmarshal commits: %w", err)
}
return &pr, nil
}
func (c *Cache) GetVersions() (map[string]*git.Version, error) {
rows, err := c.db.Query(`
SELECT name, date, commit_sha, pr_numbers, ai_summary FROM versions
`)
if err != nil {
return nil, err
}
defer rows.Close()
versions := make(map[string]*git.Version)
for rows.Next() {
var v git.Version
var dateStr sql.NullString
var prNumbersJSON string
var aiSummary sql.NullString
if err := rows.Scan(&v.Name, &dateStr, &v.CommitSHA, &prNumbersJSON, &aiSummary); err != nil {
return nil, err
}
if dateStr.Valid {
// 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 != "" {
json.Unmarshal([]byte(prNumbersJSON), &v.PRNumbers)
}
if aiSummary.Valid {
v.AISummary = aiSummary.String
}
v.Commits, err = c.getCommitsForVersion(v.Name)
if err != nil {
return nil, err
}
versions[v.Name] = &v
}
return versions, rows.Err()
}
func (c *Cache) getCommitsForVersion(version string) ([]*git.Commit, error) {
rows, err := c.db.Query(`
SELECT sha, message, author, email, date, is_merge, pr_number
FROM commits WHERE version = ?
ORDER BY date DESC
`, version)
if err != nil {
return nil, err
}
defer rows.Close()
var commits []*git.Commit
for rows.Next() {
var commit git.Commit
if err := rows.Scan(
&commit.SHA, &commit.Message, &commit.Author, &commit.Email,
&commit.Date, &commit.IsMerge, &commit.PRNumber,
); err != nil {
return nil, err
}
commits = append(commits, &commit)
}
return commits, rows.Err()
}
func (c *Cache) Clear() error {
tables := []string{"metadata", "versions", "commits", "pull_requests"}
for _, table := range tables {
if _, err := c.db.Exec("DELETE FROM " + table); err != nil {
return err
}
}
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
err := c.db.QueryRow("SELECT value FROM metadata WHERE key = 'last_pr_sync'").Scan(×tamp)
if err == sql.ErrNoRows {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, err
}
return time.Parse(time.RFC3339, timestamp)
}
// SetLastPRSync updates the timestamp of the last PR sync
func (c *Cache) SetLastPRSync(timestamp time.Time) error {
_, err := c.db.Exec(`
INSERT OR REPLACE INTO metadata (key, value, updated_at)
VALUES ('last_pr_sync', ?, CURRENT_TIMESTAMP)
`, timestamp.Format(time.RFC3339))
return err
}
// SavePRBatch saves multiple PRs in a single transaction for better performance
func (c *Cache) SavePRBatch(prs []*github.PR) error {
tx, err := c.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT OR REPLACE INTO pull_requests
(number, title, body, author, author_url, author_type, url, merged_at, merge_commit, commits)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, pr := range prs {
commits, _ := json.Marshal(pr.Commits)
_, err := stmt.Exec(
pr.Number, pr.Title, pr.Body, pr.Author, pr.AuthorURL, pr.AuthorType,
pr.URL, pr.MergedAt, pr.MergeCommit, string(commits),
)
if err != nil {
return fmt.Errorf("failed to save PR #%d: %w", pr.Number, err)
}
}
return tx.Commit()
}
// GetAllPRs returns all cached PRs
func (c *Cache) GetAllPRs() (map[int]*github.PR, error) {
rows, err := c.db.Query(`
SELECT number, title, body, author, author_url, COALESCE(author_type, 'user'), url, merged_at, merge_commit, commits
FROM pull_requests
`)
if err != nil {
return nil, err
}
defer rows.Close()
prs := make(map[int]*github.PR)
for rows.Next() {
var pr github.PR
var commitsJSON string
if err := rows.Scan(
&pr.Number, &pr.Title, &pr.Body, &pr.Author, &pr.AuthorURL, &pr.AuthorType,
&pr.URL, &pr.MergedAt, &pr.MergeCommit, &commitsJSON,
); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(commitsJSON), &pr.Commits); err != nil {
return nil, fmt.Errorf("failed to unmarshal commits for PR #%d: %w", pr.Number, err)
}
prs[pr.Number] = &pr
}
return prs, rows.Err()
}
// MarkPRAsNonExistent marks a PR number as non-existent to avoid future fetches
func (c *Cache) MarkPRAsNonExistent(prNumber int) error {
_, err := c.db.Exec(`
INSERT OR REPLACE INTO metadata (key, value, updated_at)
VALUES (?, 'non_existent', CURRENT_TIMESTAMP)
`, fmt.Sprintf("pr_non_existent_%d", prNumber))
return err
}
// IsPRMarkedAsNonExistent checks if a PR is marked as non-existent
func (c *Cache) IsPRMarkedAsNonExistent(prNumber int) bool {
var value string
err := c.db.QueryRow("SELECT value FROM metadata WHERE key = ?",
fmt.Sprintf("pr_non_existent_%d", prNumber)).Scan(&value)
return err == nil && value == "non_existent"
}
// SaveCommitPRMappings saves SHA→PR mappings for all commits in PRs
func (c *Cache) SaveCommitPRMappings(prs []*github.PR) error {
tx, err := c.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT OR REPLACE INTO commit_pr_mapping (commit_sha, pr_number)
VALUES (?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, pr := range prs {
for _, commit := range pr.Commits {
_, err := stmt.Exec(commit.SHA, pr.Number)
if err != nil {
return fmt.Errorf("failed to save commit mapping %s→%d: %w", commit.SHA, pr.Number, err)
}
}
}
return tx.Commit()
}
// GetPRNumberBySHA returns the PR number for a given commit SHA
func (c *Cache) GetPRNumberBySHA(sha string) (int, bool) {
var prNumber int
err := c.db.QueryRow("SELECT pr_number FROM commit_pr_mapping WHERE commit_sha = ?", sha).Scan(&prNumber)
if err == sql.ErrNoRows {
return 0, false
}
if err != nil {
return 0, false
}
return prNumber, true
}
// GetCommitSHAsForPR returns all commit SHAs for a given PR number
func (c *Cache) GetCommitSHAsForPR(prNumber int) ([]string, error) {
rows, err := c.db.Query("SELECT commit_sha FROM commit_pr_mapping WHERE pr_number = ?", prNumber)
if err != nil {
return nil, err
}
defer rows.Close()
var shas []string
for rows.Next() {
var sha string
if err := rows.Scan(&sha); err != nil {
return nil, err
}
shas = append(shas, sha)
}
return shas, rows.Err()
}
// GetUnreleasedContentHash returns the cached content hash for Unreleased
func (c *Cache) GetUnreleasedContentHash() (string, error) {
var hash string
err := c.db.QueryRow("SELECT value FROM metadata WHERE key = 'unreleased_content_hash'").Scan(&hash)
if err == sql.ErrNoRows {
return "", fmt.Errorf("no content hash found")
}
return hash, err
}
// SetUnreleasedContentHash stores the content hash for Unreleased
func (c *Cache) SetUnreleasedContentHash(hash string) error {
_, err := c.db.Exec(`
INSERT OR REPLACE INTO metadata (key, value, updated_at)
VALUES ('unreleased_content_hash', ?, CURRENT_TIMESTAMP)
`, hash)
return err
}
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))
}
})
}
package changelog
import (
"crypto/sha256"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/cache"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/config"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/git"
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/github"
)
type Generator struct {
cfg *config.Config
gitWalker *git.Walker
ghClient *github.Client
cache *cache.Cache
versions map[string]*git.Version
prs map[int]*github.PR
}
func New(cfg *config.Config) (*Generator, error) {
gitWalker, err := git.NewWalker(cfg.RepoPath)
if err != nil {
return nil, fmt.Errorf("failed to create git walker: %w", err)
}
owner, repo, err := gitWalker.GetRepoInfo()
if err != nil {
return nil, fmt.Errorf("failed to get repo info: %w", err)
}
ghClient := github.NewClient(cfg.GitHubToken, owner, repo)
var c *cache.Cache
if !cfg.NoCache {
c, err = cache.New(cfg.CacheFile)
if err != nil {
return nil, fmt.Errorf("failed to create cache: %w", err)
}
if cfg.RebuildCache {
if err := c.Clear(); err != nil {
return nil, fmt.Errorf("failed to clear cache: %w", err)
}
}
}
return &Generator{
cfg: cfg,
gitWalker: gitWalker,
ghClient: ghClient,
cache: c,
prs: make(map[int]*github.PR),
}, nil
}
func (g *Generator) Generate() (string, error) {
if err := g.collectData(); err != nil {
return "", fmt.Errorf("failed to collect data: %w", err)
}
if err := g.fetchPRs(g.cfg.ForcePRSync); err != nil {
return "", fmt.Errorf("failed to fetch PRs: %w", err)
}
return g.formatChangelog(), nil
}
func (g *Generator) collectData() error {
if g.cache != nil && !g.cfg.RebuildCache {
cachedTag, err := g.cache.GetLastProcessedTag()
if err != nil {
return fmt.Errorf("failed to get last processed tag: %w", err)
}
if cachedTag != "" {
// Get the current latest tag from git
currentTag, err := g.gitWalker.GetLatestTag()
if err == nil {
// Load cached data - we can use it even if there are new tags
cachedVersions, err := g.cache.GetVersions()
if err == nil && len(cachedVersions) > 0 {
g.versions = cachedVersions
// Load cached PRs
for _, version := range g.versions {
for _, prNum := range version.PRNumbers {
if pr, err := g.cache.GetPR(prNum); err == nil && pr != nil {
g.prs[prNum] = pr
}
}
}
// If we have new tags since cache, process the new versions only
if currentTag != cachedTag {
fmt.Fprintf(os.Stderr, "Processing new versions since %s...\n", cachedTag)
newVersions, err := g.gitWalker.WalkHistorySinceTag(cachedTag)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to walk history since tag %s: %v\n", cachedTag, err)
} else {
// Merge new versions into cached versions (only add if not already cached)
for name, version := range newVersions {
if name != "Unreleased" { // Handle Unreleased separately
if existingVersion, exists := g.versions[name]; !exists {
g.versions[name] = version
} else {
// Update existing version with new PR numbers if they're missing
if len(existingVersion.PRNumbers) == 0 && len(version.PRNumbers) > 0 {
existingVersion.PRNumbers = version.PRNumbers
}
}
}
}
}
}
// Always update Unreleased section with latest commits
unreleasedVersion, err := g.gitWalker.WalkCommitsSinceTag(currentTag)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to walk commits since tag %s: %v\n", currentTag, err)
} else if unreleasedVersion != nil {
// Preserve existing AI summary if available
if existingUnreleased, exists := g.versions["Unreleased"]; exists {
unreleasedVersion.AISummary = existingUnreleased.AISummary
}
// Replace or add the unreleased version
g.versions["Unreleased"] = unreleasedVersion
}
// Save any new versions to cache (after potential AI processing)
if currentTag != cachedTag {
for _, version := range g.versions {
// Skip versions that were already cached and Unreleased
if version.Name != "Unreleased" {
if err := g.cache.SaveVersion(version); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save version to cache: %v\n", err)
}
for _, commit := range version.Commits {
if err := g.cache.SaveCommit(commit, version.Name); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save commit to cache: %v\n", err)
}
}
}
}
// Update the last processed tag
if err := g.cache.SetLastProcessedTag(currentTag); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to update last processed tag: %v\n", err)
}
}
return nil
}
}
}
}
versions, err := g.gitWalker.WalkHistory()
if err != nil {
return fmt.Errorf("failed to walk history: %w", err)
}
g.versions = versions
if g.cache != nil {
for _, version := range versions {
if err := g.cache.SaveVersion(version); err != nil {
return fmt.Errorf("failed to save version to cache: %w", err)
}
for _, commit := range version.Commits {
if err := g.cache.SaveCommit(commit, version.Name); err != nil {
return fmt.Errorf("failed to save commit to cache: %w", err)
}
}
}
// Save the latest tag as our cache anchor point
if latestTag, err := g.gitWalker.GetLatestTag(); err == nil && latestTag != "" {
if err := g.cache.SetLastProcessedTag(latestTag); err != nil {
return fmt.Errorf("failed to save last processed tag: %w", err)
}
}
}
return nil
}
func (g *Generator) fetchPRs(forcePRSync bool) error {
// First, load all cached PRs
if g.cache != nil {
cachedPRs, err := g.cache.GetAllPRs()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to load cached PRs: %v\n", err)
} else {
g.prs = cachedPRs
}
}
// Check if we need to fetch new PRs
var lastSync time.Time
if g.cache != nil {
lastSync, _ = g.cache.GetLastPRSync()
}
// Check if we need to sync for missing PRs
missingPRs := false
for _, version := range g.versions {
for _, prNum := range version.PRNumbers {
if _, exists := g.prs[prNum]; !exists {
missingPRs = true
break
}
}
if missingPRs {
break
}
}
if missingPRs {
fmt.Fprintf(os.Stderr, "Full sync triggered due to missing PRs in cache.\n")
}
// 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 || forcePRSync || missingPRs
if !needsSync {
fmt.Fprintf(os.Stderr, "Using cached PR data (last sync: %s)\n", lastSync.Format("2006-01-02 15:04:05"))
return nil
}
fmt.Fprintf(os.Stderr, "Fetching merged PRs from GitHub using GraphQL...\n")
// Use GraphQL for ultimate performance - gets everything in ~5-10 calls
prs, err := g.ghClient.FetchAllMergedPRsGraphQL(lastSync)
if err != nil {
fmt.Fprintf(os.Stderr, "GraphQL fetch failed, falling back to REST API: %v\n", err)
// Fall back to REST API
prs, err = g.ghClient.FetchAllMergedPRs(lastSync)
if err != nil {
return fmt.Errorf("both GraphQL and REST API failed: %w", err)
}
}
// Update our PR map with new data
for _, pr := range prs {
g.prs[pr.Number] = pr
}
// Save all PRs to cache in a batch transaction
if g.cache != nil && len(prs) > 0 {
// Save PRs
if err := g.cache.SavePRBatch(prs); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to cache PRs: %v\n", err)
}
// Save SHA→PR mappings for lightning-fast git operations
if err := g.cache.SaveCommitPRMappings(prs); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to cache commit mappings: %v\n", err)
}
// Update last sync timestamp
if err := g.cache.SetLastPRSync(time.Now()); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to update last sync timestamp: %v\n", err)
}
}
if len(prs) > 0 {
fmt.Fprintf(os.Stderr, "Fetched %d PRs with commits (total cached: %d)\n", len(prs), len(g.prs))
}
return nil
}
func (g *Generator) formatChangelog() string {
var sb strings.Builder
sb.WriteString("# Changelog\n")
versionList := g.getSortedVersions()
for _, version := range versionList {
if g.cfg.Version != "" && version.Name != g.cfg.Version {
continue
}
versionText := g.formatVersion(version)
if versionText != "" {
sb.WriteString("\n")
sb.WriteString(versionText)
}
}
return sb.String()
}
func (g *Generator) getSortedVersions() []*git.Version {
var versions []*git.Version
var releasedVersions []*git.Version
// Collect all released versions (non-"Unreleased")
for name, version := range g.versions {
if name != "Unreleased" {
releasedVersions = append(releasedVersions, version)
}
}
// Sort released versions by date (newest first)
sort.Slice(releasedVersions, func(i, j int) bool {
return releasedVersions[i].Date.After(releasedVersions[j].Date)
})
// Add "Unreleased" first if it exists and has commits
if unreleased, exists := g.versions["Unreleased"]; exists && len(unreleased.Commits) > 0 {
versions = append(versions, unreleased)
}
// Add sorted released versions
versions = append(versions, releasedVersions...)
if g.cfg.Limit > 0 && len(versions) > g.cfg.Limit {
versions = versions[:g.cfg.Limit]
}
return versions
}
func (g *Generator) formatVersion(version *git.Version) string {
var sb strings.Builder
// Generate raw content
rawContent := g.generateRawVersionContent(version)
if rawContent == "" {
return ""
}
header := g.formatVersionHeader(version)
sb.WriteString(("\n"))
sb.WriteString(header)
// If AI summarization is enabled, enhance with AI
if g.cfg.EnableAISummary {
// For "Unreleased", check if content has changed since last AI summary
if version.Name == "Unreleased" && version.AISummary != "" && g.cache != nil {
// Get cached content hash
cachedHash, err := g.cache.GetUnreleasedContentHash()
if err == nil {
// Calculate current content hash
currentHash := hashContent(rawContent)
if cachedHash == currentHash {
// Content unchanged, use cached summary
fmt.Fprintf(os.Stderr, "✅ %s content unchanged (skipping AI)\n", version.Name)
sb.WriteString(version.AISummary)
return fixMarkdown(sb.String())
}
}
}
// For released versions, if we have cached AI summary, use it!
if version.Name != "Unreleased" && version.AISummary != "" {
fmt.Fprintf(os.Stderr, "✅ %s already summarized (skipping)\n", version.Name)
sb.WriteString(version.AISummary)
return fixMarkdown(sb.String())
}
fmt.Fprintf(os.Stderr, "🤖 AI summarizing %s...", version.Name)
aiSummary, err := SummarizeVersionContent(rawContent)
if err != nil {
fmt.Fprintf(os.Stderr, " Failed: %v\n", err)
sb.WriteString((rawContent))
return fixMarkdown(sb.String())
}
if checkForAIError(aiSummary) {
fmt.Fprintf(os.Stderr, " AI error detected, using raw content instead\n")
sb.WriteString(rawContent)
fmt.Fprintf(os.Stderr, "Raw Content was: (%d bytes) %s \n", len(rawContent), rawContent)
fmt.Fprintf(os.Stderr, "AI Summary was: (%d bytes) %s\n", len(aiSummary), aiSummary)
return fixMarkdown(sb.String())
}
fmt.Fprintf(os.Stderr, " Done!\n")
aiSummary = strings.TrimSpace(aiSummary)
// Cache the AI summary and content hash
version.AISummary = aiSummary
if g.cache != nil {
if err := g.cache.UpdateVersionAISummary(version.Name, aiSummary); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to cache AI summary: %v\n", err)
}
// Cache content hash for "Unreleased" to detect changes
if version.Name == "Unreleased" {
if err := g.cache.SetUnreleasedContentHash(hashContent(rawContent)); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to cache content hash: %v\n", err)
}
}
}
sb.WriteString(aiSummary)
return fixMarkdown(sb.String())
}
sb.WriteString(rawContent)
return fixMarkdown(sb.String())
}
func checkForAIError(summary string) bool {
// Check for common AI error patterns
errorPatterns := []string{
"I don't see any", "please provide",
"content you've provided appears to be incomplete",
}
for _, pattern := range errorPatterns {
if strings.Contains(summary, pattern) {
return true
}
}
return false
}
// formatVersionHeader formats just the version header (## ...)
func (g *Generator) formatVersionHeader(version *git.Version) string {
if version.Name == "Unreleased" {
return "## Unreleased\n\n"
}
return fmt.Sprintf("\n## %s (%s)\n\n", version.Name, version.Date.Format("2006-01-02"))
}
// generateRawVersionContent generates the raw content (PRs + commits) for a version
func (g *Generator) generateRawVersionContent(version *git.Version) string {
var sb strings.Builder
// Build a set of commit SHAs that are part of fetched PRs
prCommitSHAs := make(map[string]bool)
for _, prNum := range version.PRNumbers {
if pr, exists := g.prs[prNum]; exists {
for _, prCommit := range pr.Commits {
prCommitSHAs[prCommit.SHA] = true
}
}
}
prCommits := make(map[int][]*git.Commit)
directCommits := []*git.Commit{}
for _, commit := range version.Commits {
// Skip version bump commits from output
if commit.IsVersion {
continue
}
// If this commit is part of a fetched PR, don't include it in direct commits
if prCommitSHAs[commit.SHA] {
continue
}
if commit.PRNumber > 0 {
prCommits[commit.PRNumber] = append(prCommits[commit.PRNumber], commit)
} else {
directCommits = append(directCommits, commit)
}
}
// There are occasionally no PRs or direct commits other than version bumps, so we handle that gracefully
if len(prCommits) == 0 && len(directCommits) == 0 {
return ""
}
prependNewline := ""
for _, prNum := range version.PRNumbers {
if pr, exists := g.prs[prNum]; exists {
sb.WriteString(prependNewline)
sb.WriteString(g.formatPR(pr))
prependNewline = "\n"
}
}
if len(directCommits) > 0 {
// 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)
})
sb.WriteString(prependNewline + "### Direct commits\n\n")
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 fixMarkdown(
strings.ReplaceAll(sb.String(), "\n-\n", "\n"), // Remove empty list items
)
}
func fixMarkdown(content string) string {
// Fix MD032/blank-around-lists: Lists should be surrounded by blank lines
lines := strings.Split(content, "\n")
inList := false
preListNewline := false
for i := range lines {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
if !inList {
inList = true
// Ensure there's a blank line before the list starts
if !preListNewline && i > 0 && lines[i-1] != "" {
line = "\n" + line
preListNewline = true
}
}
} else {
if inList {
inList = false
preListNewline = false
}
}
lines[i] = strings.TrimRight(line, " \t")
}
fixedContent := strings.TrimSpace(strings.Join(lines, "\n"))
return fixedContent + "\n"
}
func (g *Generator) formatPR(pr *github.PR) string {
var sb strings.Builder
pr.Title = strings.TrimRight(strings.TrimSpace(pr.Title), ".")
// Add type indicator for non-users
authorName := pr.Author
switch pr.AuthorType {
case "bot":
authorName += "[bot]"
case "organization":
authorName += "[org]"
}
sb.WriteString(fmt.Sprintf("### PR [#%d](%s) by [%s](%s): %s\n\n",
pr.Number, pr.URL, authorName, pr.AuthorURL, strings.TrimSpace(pr.Title)))
changes := g.extractChanges(pr)
for _, change := range changes {
if change != "" {
sb.WriteString(fmt.Sprintf("- %s\n", change))
}
}
return sb.String()
}
func (g *Generator) extractChanges(pr *github.PR) []string {
var changes []string
seen := make(map[string]bool)
for _, commit := range pr.Commits {
message := g.formatCommitMessage(commit.Message)
if message != "" && !seen[message] {
seen[message] = true
changes = append(changes, message)
}
}
if len(changes) == 0 && pr.Body != "" {
lines := strings.Split(pr.Body, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
change := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ")
if change != "" {
changes = append(changes, change)
}
}
}
}
return changes
}
func normalizeLineEndings(content string) string {
return strings.ReplaceAll(content, "\r\n", "\n")
}
func (g *Generator) formatCommitMessage(message string) string {
strings_to_remove := []string{
"### CHANGES\n", "## CHANGES\n", "# CHANGES\n",
"...\n", "---\n", "## Changes\n", "## Change",
"Update version to v..1 and commit\n",
"# What this Pull Request (PR) does\n",
"# Conflicts:",
}
message = normalizeLineEndings(message)
// No hard tabs
message = strings.ReplaceAll(message, "\t", " ")
if len(message) > 0 {
message = strings.ToUpper(message[:1]) + message[1:]
}
for _, str := range strings_to_remove {
if strings.Contains(message, str) {
message = strings.ReplaceAll(message, str, "")
}
}
message = fixFormatting(message)
return message
}
func fixFormatting(message string) string {
// Turn "*"" lists into "-" lists"
message = strings.ReplaceAll(message, "* ", "- ")
// Remove extra spaces around dashes
message = strings.ReplaceAll(message, "- ", "- ")
message = strings.ReplaceAll(message, "- ", "- ")
// turn bare URL into <URL>
if strings.Contains(message, "http://") || strings.Contains(message, "https://") {
// Use regex to wrap bare URLs with angle brackets
urlRegex := regexp.MustCompile(`\b(https?://[^\s<>]+)`)
message = urlRegex.ReplaceAllString(message, "<$1>")
}
// Replace "## LINKS\n" with "- "
message = strings.ReplaceAll(message, "## LINKS\n", "- ")
// Dependabot messages: "- [Commits]" should become "\n- [Commits]"
message = strings.TrimSpace(message)
// Turn multiple newlines into a single newline
message = strings.TrimSpace(strings.ReplaceAll(message, "\n\n", "\n"))
// Fix inline trailing spaces
message = strings.ReplaceAll(message, " \n", "\n")
// Fix weird indent before list,
message = strings.ReplaceAll(message, "\n - ", "\n- ")
// blanks-around-lists MD032 fix
// Use regex to ensure blank line before list items that don't already have one
listRegex := regexp.MustCompile(`(?m)([^\n-].*[^:\n])\n([-*] .*)`)
message = listRegex.ReplaceAllString(message, "$1\n\n$2")
// Change random first-level "#" to 4th level "####"
// This is a hack to fix spurious first-level headings that are not actual headings
// but rather just comments or notes in the commit message.
message = strings.ReplaceAll(message, "# ", "\n#### ")
message = strings.ReplaceAll(message, "\n\n\n", "\n\n")
// Wrap any non-wrapped Emails with angle brackets
emailRegex := regexp.MustCompile(`([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})`)
message = emailRegex.ReplaceAllString(message, "<$1>")
// Wrap any non-wrapped URLs with angle brackets
urlRegex := regexp.MustCompile(`(https?://[^\s<]+)`)
message = urlRegex.ReplaceAllString(message, "<$1>")
message = strings.ReplaceAll(message, "<<", "<")
message = strings.ReplaceAll(message, ">>", ">")
// Fix some spurious Issue/PR links at the beginning of a commit message line
prOrIssueLinkRegex := regexp.MustCompile("\n" + `(#\d+)`)
message = prOrIssueLinkRegex.ReplaceAllString(message, " $1")
// Remove leading/trailing whitespace
message = strings.TrimSpace(message)
return message
}
func (g *Generator) isDuplicateMessage(message string, commits []*git.Commit) bool {
if message == "." || strings.ToLower(message) == "fix" {
count := 0
for _, commit := range commits {
formatted := g.formatCommitMessage(commit.Message)
if formatted == message {
count++
if count > 1 {
return true
}
}
}
}
return false
}
// hashContent generates a SHA256 hash of the content for change detection
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
}
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)
}
})
}
}
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")
}
}
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 i, 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))
// Add an extra newline between PR sections for proper spacing
if i < len(files)-1 {
content.WriteString("\n")
}
}
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)
}
if directCommitsContent != "" {
// Add spacing before direct commits section if we have PR content
if content.Len() > 0 {
content.WriteString("\n")
}
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
}
package changelog
import (
"fmt"
"os"
"os/exec"
"strings"
)
const DefaultSummarizeModel = "claude-sonnet-4-20250514"
const MinContentLength = 256 // Minimum content length to consider for summarization
const prompt = `# ROLE
You are an expert Technical Writer specializing in creating clear, concise,
and professional release notes from raw Git commit logs.
# TASK
Your goal is to transform a provided block of Git commit logs into a clean,
human-readable changelog summary. You will identify the most important changes,
format them as a bulleted list, and preserve the associated Pull Request (PR)
information.
# INSTRUCTIONS:
Follow these steps in order:
1. Deeply analyze the input. You will be given a block of text containing PR
information and commit log messages. Carefully read through the logs
to identify individual commits and their descriptions.
2. Identify Key Changes: Focus on commits that represent significant changes,
such as new features ("feat"), bug fixes ("fix"), performance improvements ("perf"),
or breaking changes ("BREAKING CHANGE").
3. Select the Top 5: From the identified key changes, select a maximum of the five
(5) most impactful ones to include in the summary.
If there are five or fewer total changes, include all of them.
4. Format the Output:
- Where you see a PR header, include the PR header verbatim. NO CHANGES.
**This is a critical rule: Do not modify the PR header, as it contains
important links.** What follow the PR header are the related changes.
- Do not add any additional text or preamble. Begin directly with the output.
- Use bullet points for each key change. Starting each point with a hyphen ("-").
- Ensure that the summary is concise and focused on the main changes.
- The summary should be in American English (en-US), using proper grammar and punctuation.
5. If the content is too brief or you do not see any PR headers, return the content as is.
`
// getSummarizeModel returns the model to use for AI summarization
func getSummarizeModel() string {
if model := os.Getenv("FABRIC_CHANGELOG_SUMMARIZE_MODEL"); model != "" {
return model
}
return DefaultSummarizeModel
}
// SummarizeVersionContent takes raw version content and returns AI-enhanced summary
func SummarizeVersionContent(content string) (string, error) {
if strings.TrimSpace(content) == "" {
return "", fmt.Errorf("no content to summarize")
}
if len(content) < MinContentLength {
// If content is too brief, return it as is
return content, nil
}
model := getSummarizeModel()
cmd := exec.Command("fabric", "-m", model, prompt)
cmd.Stdin = strings.NewReader(content)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("fabric command failed: %w", err)
}
summary := strings.TrimSpace(string(output))
if summary == "" {
return "", fmt.Errorf("fabric returned empty summary")
}
return summary, nil
}
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
IncomingPR int
ProcessPRsVersion string
IncomingDir string
Push bool
SyncDB bool
Release string
}
package git
import (
"time"
)
type Commit struct {
SHA string
Message string
Author string
Email string
Date time.Time
IsMerge bool
PRNumber int
IsVersion bool
Version string
}
type Version struct {
Name string
Date time.Time
CommitSHA string
Commits []*Commit
PRNumbers []int
AISummary string
}
package git
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/util"
"github.com/go-git/go-git/v5"
"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 (
// 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+)`)
)
type Walker struct {
repo *git.Repository
}
func NewWalker(repoPath string) (*Walker, error) {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
return &Walker{repo: repo}, nil
}
// GetLatestTag returns the name of the most recent tag by committer date
func (w *Walker) GetLatestTag() (string, error) {
tagRefs, err := w.repo.Tags()
if err != nil {
return "", err
}
var latestTagCommit *object.Commit
var latestTagName string
err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error {
revision := plumbing.Revision(tagRef.Name().String())
tagCommitHash, err := w.repo.ResolveRevision(revision)
if err != nil {
return err
}
commit, err := w.repo.CommitObject(*tagCommitHash)
if err != nil {
return err
}
if latestTagCommit == nil {
latestTagCommit = commit
latestTagName = tagRef.Name().Short() // Get short name like "v1.4.245"
}
if commit.Committer.When.After(latestTagCommit.Committer.When) {
latestTagCommit = commit
latestTagName = tagRef.Name().Short()
}
return nil
})
if err != nil {
return "", err
}
return latestTagName, nil
}
// WalkCommitsSinceTag walks commits from the specified tag to HEAD and returns only "Unreleased" version
func (w *Walker) WalkCommitsSinceTag(tagName string) (*Version, error) {
// Get the tag reference
tagRef, err := w.repo.Tag(tagName)
if err != nil {
return nil, fmt.Errorf("failed to find tag %s: %w", tagName, err)
}
// Get the commit that the tag points to
tagCommit, err := w.repo.CommitObject(tagRef.Hash())
if err != nil {
return nil, fmt.Errorf("failed to get tag commit: %w", err)
}
// Get HEAD
headRef, err := w.repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD: %w", err)
}
// Walk from HEAD back to the tag commit (exclusive)
commitIter, err := w.repo.Log(&git.LogOptions{
From: headRef.Hash(),
Order: git.LogOrderCommitterTime,
})
if err != nil {
return nil, fmt.Errorf("failed to get commit log: %w", err)
}
version := &Version{
Name: "Unreleased",
Commits: []*Commit{},
}
prNumbers := []int{}
err = commitIter.ForEach(func(c *object.Commit) error {
// Stop when we reach the tag commit (don't include it)
if c.Hash == tagCommit.Hash {
return fmt.Errorf("reached tag commit") // Use error to break out of iteration
}
commit := &Commit{
SHA: c.Hash.String(),
Message: strings.TrimSpace(c.Message),
Date: c.Committer.When,
}
// Check for version patterns
if versionMatch := versionPattern.FindStringSubmatch(commit.Message); versionMatch != nil {
commit.IsVersion = true
}
// Check for PR merge patterns
if prMatch := prPattern.FindStringSubmatch(commit.Message); prMatch != nil {
if prNumber, err := strconv.Atoi(prMatch[1]); err == nil {
commit.PRNumber = prNumber
prNumbers = append(prNumbers, prNumber)
}
}
version.Commits = append(version.Commits, commit)
return nil
})
// Ignore the "reached tag commit" error - it's expected
if err != nil && !strings.Contains(err.Error(), "reached tag commit") {
return nil, fmt.Errorf("failed to walk commits: %w", err)
}
// Remove duplicates from prNumbers and set them
prNumbersMap := make(map[int]bool)
for _, prNum := range prNumbers {
prNumbersMap[prNum] = true
}
version.PRNumbers = make([]int, 0, len(prNumbersMap))
for prNum := range prNumbersMap {
version.PRNumbers = append(version.PRNumbers, prNum)
}
return version, nil
}
func (w *Walker) WalkHistory() (map[string]*Version, error) {
ref, err := w.repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD: %w", err)
}
commitIter, err := w.repo.Log(&git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
})
if err != nil {
return nil, fmt.Errorf("failed to get commit log: %w", err)
}
versions := make(map[string]*Version)
currentVersion := "Unreleased"
versions[currentVersion] = &Version{
Name: currentVersion,
Commits: []*Commit{},
}
prNumbers := make(map[string][]int)
err = commitIter.ForEach(func(c *object.Commit) error {
// c.Message = Summarize(c.Message)
commit := &Commit{
SHA: c.Hash.String(),
Message: strings.TrimSpace(c.Message),
Author: c.Author.Name,
Email: c.Author.Email,
Date: c.Author.When,
IsMerge: len(c.ParentHashes) > 1,
}
if matches := versionPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
commit.IsVersion = true
commit.Version = matches[1]
currentVersion = commit.Version
if _, exists := versions[currentVersion]; !exists {
versions[currentVersion] = &Version{
Name: currentVersion,
Date: commit.Date,
CommitSHA: commit.SHA,
Commits: []*Commit{},
}
}
return nil
}
if matches := prPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
prNumber := 0
fmt.Sscanf(matches[1], "%d", &prNumber)
commit.PRNumber = prNumber
prNumbers[currentVersion] = append(prNumbers[currentVersion], prNumber)
}
versions[currentVersion].Commits = append(versions[currentVersion].Commits, commit)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk commits: %w", err)
}
for version, prs := range prNumbers {
versions[version].PRNumbers = dedupInts(prs)
}
return versions, nil
}
func (w *Walker) GetRepoInfo() (owner string, name string, err error) {
remotes, err := w.repo.Remotes()
if err != nil {
return "", "", fmt.Errorf("failed to get remotes: %w", err)
}
// First try upstream (preferred for forks)
for _, remote := range remotes {
if remote.Config().Name == "upstream" {
urls := remote.Config().URLs
if len(urls) > 0 {
owner, name = parseGitHubURL(urls[0])
if owner != "" && name != "" {
return owner, name, nil
}
}
}
}
// Then try origin
for _, remote := range remotes {
if remote.Config().Name == "origin" {
urls := remote.Config().URLs
if len(urls) > 0 {
owner, name = parseGitHubURL(urls[0])
if owner != "" && name != "" {
return owner, name, nil
}
}
}
}
return "danielmiessler", "fabric", nil
}
func parseGitHubURL(url string) (owner, repo string) {
patterns := []string{
`github\.com[:/]([^/]+)/([^/.]+)`,
`github\.com[:/]([^/]+)/([^/]+)\.git$`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(url)
if len(matches) > 2 {
return matches[1], matches[2]
}
}
return "", ""
}
// WalkHistorySinceTag walks git history from HEAD down to (but not including) the specified tag
// and returns any version commits found along the way
func (w *Walker) WalkHistorySinceTag(sinceTag string) (map[string]*Version, error) {
// Get the commit SHA for the sinceTag
tagRef, err := w.repo.Tag(sinceTag)
if err != nil {
return nil, fmt.Errorf("failed to get tag %s: %w", sinceTag, err)
}
tagCommit, err := w.repo.CommitObject(tagRef.Hash())
if err != nil {
return nil, fmt.Errorf("failed to get commit for tag %s: %w", sinceTag, err)
}
// Get HEAD reference
ref, err := w.repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD: %w", err)
}
// Walk from HEAD down to the tag commit (excluding it)
commitIter, err := w.repo.Log(&git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
})
if err != nil {
return nil, fmt.Errorf("failed to create commit iterator: %w", err)
}
defer commitIter.Close()
versions := make(map[string]*Version)
currentVersion := "Unreleased"
prNumbers := make(map[string][]int)
err = commitIter.ForEach(func(c *object.Commit) error {
// Stop iteration when the hash of the current commit matches the hash of the specified sinceTag commit
if c.Hash == tagCommit.Hash {
return storer.ErrStop
}
commit := &Commit{
SHA: c.Hash.String(),
Message: strings.TrimSpace(c.Message),
Author: c.Author.Name,
Email: c.Author.Email,
Date: c.Author.When,
IsMerge: len(c.ParentHashes) > 1,
}
// Check for version pattern
if matches := versionPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
commit.IsVersion = true
commit.Version = matches[1]
currentVersion = commit.Version
if _, exists := versions[currentVersion]; !exists {
versions[currentVersion] = &Version{
Name: currentVersion,
Date: commit.Date,
CommitSHA: commit.SHA,
Commits: []*Commit{},
}
}
return nil
}
// Check for PR merge pattern
if matches := prPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
prNumber, err := strconv.Atoi(matches[1])
if err != nil {
// Handle parsing error (e.g., log it or skip processing)
return fmt.Errorf("failed to parse PR number: %v", err)
}
commit.PRNumber = prNumber
prNumbers[currentVersion] = append(prNumbers[currentVersion], prNumber)
}
// Add commit to current version
if _, exists := versions[currentVersion]; !exists {
versions[currentVersion] = &Version{
Name: currentVersion,
Date: time.Time{}, // Zero value, will be set by version commit
CommitSHA: "",
Commits: []*Commit{},
}
}
versions[currentVersion].Commits = append(versions[currentVersion].Commits, commit)
return nil
})
// Handle the stop condition - storer.ErrStop is expected
if err == storer.ErrStop {
err = nil
}
// Assign collected PR numbers to each version
for version, prs := range prNumbers {
versions[version].PRNumbers = dedupInts(prs)
}
return versions, err
}
func dedupInts(ints []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, i := range ints {
if !seen[i] {
seen[i] = true
result = append(result, i)
}
}
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 := util.GetTokenFromEnv(""); 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
}
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)
}
})
}
}
package github
import "time"
type PR struct {
Number int
Title string
Body string
Author string
AuthorURL string
AuthorType string // "user", "organization", or "bot"
URL string
MergedAt time.Time
Commits []PRCommit
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
type PullRequestsQuery struct {
Repository struct {
PullRequests struct {
PageInfo struct {
HasNextPage bool
EndCursor string
}
Nodes []struct {
Number int
Title string
Body string
URL string
MergedAt time.Time
Author *struct {
Typename string `graphql:"__typename"`
Login string `graphql:"login"`
URL string `graphql:"url"`
}
Commits struct {
Nodes []struct {
Commit struct {
OID string `graphql:"oid"`
Message string
AuthoredDate time.Time `graphql:"authoredDate"`
Author struct {
Name string
}
}
}
} `graphql:"commits(first: 250)"`
}
} `graphql:"pullRequests(first: 100, after: $after, states: MERGED, orderBy: {field: UPDATED_AT, direction: DESC})"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
package util
import (
"os"
)
// GetTokenFromEnv returns a GitHub token based on the following precedence order:
// 1. If tokenValue is non-empty, it is returned.
// 2. Otherwise, if the GITHUB_TOKEN environment variable is set, its value is returned.
// 3. Otherwise, if the GH_TOKEN environment variable is set, its value is returned.
// 4. If none of the above are set, an empty string is returned.
//
// Example:
//
// os.Setenv("GITHUB_TOKEN", "abc")
// os.Setenv("GH_TOKEN", "def")
// GetTokenFromEnv("xyz") // returns "xyz"
// GetTokenFromEnv("") // returns "abc"
// os.Unsetenv("GITHUB_TOKEN")
// GetTokenFromEnv("") // returns "def"
// os.Unsetenv("GH_TOKEN")
// GetTokenFromEnv("") // returns ""
func GetTokenFromEnv(tokenValue string) string {
if tokenValue == "" {
tokenValue = os.Getenv("GITHUB_TOKEN")
if tokenValue == "" {
tokenValue = os.Getenv("GH_TOKEN")
}
}
return tokenValue
}
IDENTITY and PURPOSE
You are an expert in the Agile framework. You deeply understand user story and acceptance criteria creation. You will be given a topic. Please write the appropriate information for what is requested.
STEPS
Please write a user story and acceptance criteria for the requested topic.
OUTPUT INSTRUCTIONS
Output the results in JSON format as defined in this example:
{ "Topic": "Authentication and User Management", "Story": "As a user, I want to be able to create a new user account so that I can access the system.", "Criteria": "Given that I am a user, when I click the 'Create Account' button, then I should be prompted to enter my email address, password, and confirm password. When I click the 'Submit' button, then I should be redirected to the login page." }
INPUT:
INPUT:
CONTENT:
CONTENT: