mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-09 22:38:10 -05:00
Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd40778472 | ||
|
|
bc1641a68c | ||
|
|
5cf15d22d3 | ||
|
|
2b2a25daaa | ||
|
|
75a7f25642 | ||
|
|
8bab58f225 | ||
|
|
8ec006e02c | ||
|
|
2508dc6397 | ||
|
|
7670df35ad | ||
|
|
3b9782f942 | ||
|
|
3fca3489fb | ||
|
|
bb2d58eae0 | ||
|
|
87df7dc383 | ||
|
|
1d69afa1c9 | ||
|
|
96c18b4c99 | ||
|
|
dd5173963b | ||
|
|
da1c8ec979 | ||
|
|
ac97f9984f | ||
|
|
181b812eaf | ||
|
|
fe94165d31 | ||
|
|
16e92690aa | ||
|
|
1c33799aa8 | ||
|
|
9559e618c3 | ||
|
|
ac32e8e64a | ||
|
|
82340e6126 | ||
|
|
5dec53726a | ||
|
|
b0eb136cbb | ||
|
|
63f4370ff1 | ||
|
|
b3cc2c737d | ||
|
|
e43b4191e4 | ||
|
|
744c565120 | ||
|
|
1473ac1465 | ||
|
|
c38c16f0db | ||
|
|
a4b1db4193 | ||
|
|
d44bc19a84 | ||
|
|
a2e618e11c | ||
|
|
cb90379b30 | ||
|
|
4868687746 | ||
|
|
85780fee76 | ||
|
|
497b1ed682 | ||
|
|
135433b749 | ||
|
|
f185dedb37 | ||
|
|
c74a157dcf | ||
|
|
91a336e870 | ||
|
|
5212fbcc37 | ||
|
|
6d8eb3d2b9 | ||
|
|
d3bba5d026 | ||
|
|
699762b694 | ||
|
|
f2a6f1bd98 | ||
|
|
3176adf59b | ||
|
|
7e29966622 | ||
|
|
0af0ab683d | ||
|
|
e72e67de71 | ||
|
|
414b6174e7 | ||
|
|
f63e0dfc05 | ||
|
|
4ef8578e47 | ||
|
|
12ee690ae4 | ||
|
|
cc378be485 | ||
|
|
06fc8d8732 | ||
|
|
9e4ed8ecb3 | ||
|
|
c369425708 | ||
|
|
cf074d3411 | ||
|
|
47f75237ff | ||
|
|
fad0a065d4 | ||
|
|
a59a3517d8 | ||
|
|
04c3c0c512 | ||
|
|
cb837bde2d | ||
|
|
2ad454b6dc | ||
|
|
c0ea25f816 | ||
|
|
87796d4fa9 | ||
|
|
e1945a0b62 | ||
|
|
ecac2b4c34 | ||
|
|
7ed4de269e | ||
|
|
6bd305906d | ||
|
|
6aeca6e4da | ||
|
|
b34f249e24 | ||
|
|
b187a80275 | ||
|
|
a6fc54a991 | ||
|
|
b9f4b9837a | ||
|
|
2bedf35957 | ||
|
|
b9df64a0d8 | ||
|
|
6b07b33ff2 | ||
|
|
ff245edd51 | ||
|
|
2e0a4da876 | ||
|
|
1f3befbbbc | ||
|
|
8988206fbe | ||
|
|
1bd5f9d7e4 | ||
|
|
832fd2f718 | ||
|
|
dd0935fb70 | ||
|
|
e64bdd849c | ||
|
|
be82b4b013 | ||
|
|
6e2f00090c | ||
|
|
7d6505fe98 | ||
|
|
23c1437794 | ||
|
|
dd5e57477f | ||
|
|
2c2b374664 | ||
|
|
b884c529bd | ||
|
|
137aff2268 | ||
|
|
42d3f45c57 | ||
|
|
f8ada0b148 | ||
|
|
cb5fa50f68 | ||
|
|
921d12a153 | ||
|
|
725c6f9327 | ||
|
|
9a64238f18 | ||
|
|
af318aca17 | ||
|
|
4d7bc7deb8 | ||
|
|
da1336e8cb | ||
|
|
81adb3b050 | ||
|
|
ebc59ee82a | ||
|
|
e242e0fc52 | ||
|
|
4004c51b9e | ||
|
|
6d67223a4b | ||
|
|
265f2b807e | ||
|
|
dc63e0d1cc | ||
|
|
75842d8610 | ||
|
|
bcd4c6caea | ||
|
|
a6a63698e1 | ||
|
|
0528556b5c | ||
|
|
47cf24e19d | ||
|
|
3f07afbef4 | ||
|
|
38d714dccd | ||
|
|
d0b5c95d61 | ||
|
|
f8f80ca206 | ||
|
|
0af458872f | ||
|
|
24e46a6f37 | ||
|
|
d6a31e68b0 | ||
|
|
b1013ca61b | ||
|
|
6b4ce946a5 | ||
|
|
2d2830e9c8 | ||
|
|
115327fdab | ||
|
|
e672f9b73f | ||
|
|
ef4364a1aa | ||
|
|
cb3f8ed43d | ||
|
|
4c1803cb6d | ||
|
|
d1c614d44e | ||
|
|
dbaa0b9754 | ||
|
|
4cfe2375ab | ||
|
|
2b371b69c7 | ||
|
|
6222a613e4 | ||
|
|
0882c43532 | ||
|
|
f0e1a1b77f | ||
|
|
a774f991ab | ||
|
|
a40bacaf34 | ||
|
|
969b85380c | ||
|
|
e8fe4434db | ||
|
|
7c7ceca264 | ||
|
|
c19d7ccd9d | ||
|
|
bd0c5f730e | ||
|
|
5900dac58f | ||
|
|
237219c3cc | ||
|
|
26fd700098 | ||
|
|
6bd926dd0f | ||
|
|
16ac519415 | ||
|
|
a43f267a69 | ||
|
|
c78fe41ebc | ||
|
|
cab246bc74 | ||
|
|
50c05e2d5c |
59
.github/ISSUE_TEMPLATE/bug.yml
vendored
59
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -7,29 +7,74 @@ body:
|
||||
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: "I was doing THIS, when THAT happened. I was expecting THAT_OTHER_THING to happen instead."
|
||||
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: checkboxes
|
||||
|
||||
- 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 check
|
||||
description: Please make sure you were using the latest version of this project available in the `main` branch.
|
||||
options:
|
||||
- label: Yes I was.
|
||||
required: true
|
||||
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:
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -4,13 +4,13 @@ on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- 'patterns/**'
|
||||
- '**/*.md'
|
||||
- "data/patterns/**"
|
||||
- "**/*.md"
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- 'patterns/**'
|
||||
- '**/*.md'
|
||||
- "data/patterns/**"
|
||||
- "**/*.md"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
6
.github/workflows/patterns.yaml
vendored
6
.github/workflows/patterns.yaml
vendored
@@ -3,7 +3,7 @@ name: Patterns Artifact
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "patterns/**" # Trigger only on changes to files in the patterns folder
|
||||
- "data/patterns/**" # Trigger only on changes to files in the patterns folder
|
||||
|
||||
jobs:
|
||||
zip-and-upload:
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
- name: Verify Changes in Patterns Folder
|
||||
run: |
|
||||
git fetch origin
|
||||
if git diff --quiet HEAD~1 -- patterns; then
|
||||
if git diff --quiet HEAD~1 -- data/patterns; then
|
||||
echo "No changes detected in patterns folder."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Zip the Patterns Folder
|
||||
run: zip -r patterns.zip patterns/
|
||||
run: zip -r patterns.zip data/patterns/
|
||||
|
||||
- name: Upload Patterns Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
GOOS: ${{ env.OS }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
go build -o fabric-${OS}-${{ matrix.arch }} .
|
||||
go build -o fabric-${OS}-${{ matrix.arch }} ./cmd/fabric
|
||||
|
||||
- name: Build binary on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
GOOS: windows
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
go build -o fabric-windows-${{ matrix.arch }}.exe .
|
||||
go build -o fabric-windows-${{ matrix.arch }}.exe ./cmd/fabric
|
||||
|
||||
- name: Upload build artifact
|
||||
if: matrix.os != 'windows-latest'
|
||||
|
||||
@@ -3,13 +3,17 @@ name: Update Version File and Create Tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # Monitor the main branch
|
||||
- main # Monitor the main branch
|
||||
paths-ignore:
|
||||
- 'patterns/**'
|
||||
- '**/*.md'
|
||||
- "data/patterns/**"
|
||||
- "**/*.md"
|
||||
- "data/strategies/**"
|
||||
- "cmd/generate_changelog/*.db"
|
||||
- "scripts/pattern_descriptions/*.json"
|
||||
- "web/static/data/pattern_descriptions.json"
|
||||
|
||||
permissions:
|
||||
contents: write # Ensure the workflow has write permissions
|
||||
contents: write # Ensure the workflow has write permissions
|
||||
|
||||
concurrency:
|
||||
group: version-update
|
||||
@@ -63,14 +67,14 @@ jobs:
|
||||
|
||||
- name: Update version.go file
|
||||
run: |
|
||||
echo "package main" > version.go
|
||||
echo "" >> version.go
|
||||
echo "var version = \"${{ env.new_tag }}\"" >> version.go
|
||||
echo "package main" > cmd/fabric/version.go
|
||||
echo "" >> cmd/fabric/version.go
|
||||
echo "var version = \"${{ env.new_tag }}\"" >> cmd/fabric/version.go
|
||||
|
||||
- name: Update version.nix file
|
||||
run: |
|
||||
echo "\"${{ env.new_version }}\"" > nix/pkgs/fabric/version.nix
|
||||
|
||||
|
||||
- name: Format source code
|
||||
run: |
|
||||
nix fmt
|
||||
@@ -81,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git add version.go
|
||||
git add cmd/fabric/version.go
|
||||
git add nix/pkgs/fabric/version.nix
|
||||
git add nix/pkgs/fabric/gomod2nix.toml
|
||||
git add .
|
||||
@@ -93,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Push changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN to authenticate the push
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN to authenticate the push
|
||||
run: |
|
||||
git push origin main # Push changes to the main branch
|
||||
|
||||
@@ -106,7 +110,7 @@ jobs:
|
||||
|
||||
- name: Dispatch event to trigger release workflow
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN to authenticate the dispatch
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN to authenticate the dispatch
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -131,9 +131,7 @@ celerybeat.pid
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
@@ -349,5 +347,6 @@ web/package-lock.json
|
||||
.gitignore_backup
|
||||
web/static/*.png
|
||||
|
||||
# Local VSCode project settings
|
||||
.vscode/
|
||||
# Local tmp directory
|
||||
.tmp/
|
||||
tmp/
|
||||
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["davidanson.vscode-markdownlint"]
|
||||
}
|
||||
145
.vscode/settings.json
vendored
Normal file
145
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"addextension",
|
||||
"AIML",
|
||||
"anthropics",
|
||||
"badfile",
|
||||
"Behrens",
|
||||
"blindspots",
|
||||
"Bombal",
|
||||
"Cerebras",
|
||||
"compinit",
|
||||
"creatordate",
|
||||
"custompatterns",
|
||||
"danielmiessler",
|
||||
"davidanson",
|
||||
"Debugf",
|
||||
"dedup",
|
||||
"deepseek",
|
||||
"direnv",
|
||||
"dryrun",
|
||||
"dsrp",
|
||||
"editability",
|
||||
"Eisler",
|
||||
"elif",
|
||||
"envrc",
|
||||
"eugeis",
|
||||
"Eugen",
|
||||
"excalidraw",
|
||||
"exolab",
|
||||
"fabriclogo",
|
||||
"fpath",
|
||||
"frequencypenalty",
|
||||
"fsdb",
|
||||
"gantt",
|
||||
"genai",
|
||||
"githelper",
|
||||
"gjson",
|
||||
"GOARCH",
|
||||
"godotenv",
|
||||
"gofmt",
|
||||
"goimports",
|
||||
"gomod",
|
||||
"gonic",
|
||||
"goopenai",
|
||||
"GOPATH",
|
||||
"gopkg",
|
||||
"GOROOT",
|
||||
"Graphviz",
|
||||
"grokai",
|
||||
"Groq",
|
||||
"hackerone",
|
||||
"Haddix",
|
||||
"hasura",
|
||||
"hormozi",
|
||||
"Hormozi's",
|
||||
"HTMLURL",
|
||||
"jaredmontoya",
|
||||
"jessevdk",
|
||||
"Jina",
|
||||
"joho",
|
||||
"ksylvan",
|
||||
"Langdock",
|
||||
"ldflags",
|
||||
"libexec",
|
||||
"listcontexts",
|
||||
"listextensions",
|
||||
"listmodels",
|
||||
"listpatterns",
|
||||
"listsessions",
|
||||
"liststrategies",
|
||||
"listvendors",
|
||||
"lmstudio",
|
||||
"Makefiles",
|
||||
"markmap",
|
||||
"matplotlib",
|
||||
"mattn",
|
||||
"Miessler",
|
||||
"nometa",
|
||||
"numpy",
|
||||
"ollama",
|
||||
"opencode",
|
||||
"openrouter",
|
||||
"otiai",
|
||||
"pdflatex",
|
||||
"pipx",
|
||||
"PKCE",
|
||||
"pkgs",
|
||||
"presencepenalty",
|
||||
"printcontext",
|
||||
"printsession",
|
||||
"pycache",
|
||||
"pyperclip",
|
||||
"readystream",
|
||||
"restapi",
|
||||
"rmextension",
|
||||
"samber",
|
||||
"sashabaranov",
|
||||
"sdist",
|
||||
"seaborn",
|
||||
"semgrep",
|
||||
"sess",
|
||||
"storer",
|
||||
"Streamlit",
|
||||
"stretchr",
|
||||
"talkpanel",
|
||||
"Telos",
|
||||
"testpattern",
|
||||
"Thacker",
|
||||
"tidwall",
|
||||
"topp",
|
||||
"ttrc",
|
||||
"unalias",
|
||||
"unmarshalling",
|
||||
"updatepatterns",
|
||||
"videoid",
|
||||
"webp",
|
||||
"wipecontext",
|
||||
"wipesession",
|
||||
"writeups",
|
||||
"xclip",
|
||||
"yourpatternname"
|
||||
],
|
||||
"cSpell.ignorePaths": ["go.mod", ".gitignore", "CHANGELOG.md"],
|
||||
"markdownlint.config": {
|
||||
"MD004": false,
|
||||
"MD011": false,
|
||||
"MD024": false,
|
||||
"MD025": false,
|
||||
"M032": false,
|
||||
"MD033": {
|
||||
"allowed_elements": [
|
||||
"a",
|
||||
"br",
|
||||
"code",
|
||||
"div",
|
||||
"em",
|
||||
"h4",
|
||||
"img",
|
||||
"module",
|
||||
"p"
|
||||
]
|
||||
},
|
||||
"MD041": false
|
||||
}
|
||||
}
|
||||
2341
CHANGELOG.md
Normal file
2341
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
98
README.md
98
README.md
@@ -3,7 +3,7 @@ Fabric is graciously supported by…
|
||||
|
||||
[](https://warp.dev/fabric)
|
||||
|
||||
<img src="./images/fabric-logo-gif.gif" alt="fabriclogo" width="400" height="400"/>
|
||||
<img src="./docs/images/fabric-logo-gif.gif" alt="fabriclogo" width="400" height="400"/>
|
||||
|
||||
# `fabric`
|
||||
|
||||
@@ -29,7 +29,7 @@ Fabric is graciously supported by…
|
||||
[Helper Apps](#helper-apps) •
|
||||
[Meta](#meta)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,9 @@ Keep in mind that many of these were recorded when Fabric was Python-based, so r
|
||||
- [Just use the Patterns](#just-use-the-patterns)
|
||||
- [Prompt Strategies](#prompt-strategies)
|
||||
- [Custom Patterns](#custom-patterns)
|
||||
- [Setting Up Custom Patterns](#setting-up-custom-patterns)
|
||||
- [Using Custom Patterns](#using-custom-patterns)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Helper Apps](#helper-apps)
|
||||
- [`to_pdf`](#to_pdf)
|
||||
- [`to_pdf` Installation](#to_pdf-installation)
|
||||
@@ -110,30 +113,9 @@ Keep in mind that many of these were recorded when Fabric was Python-based, so r
|
||||
|
||||
## Updates
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> July 4, 2025
|
||||
>
|
||||
> - **Web Search**: Fabric now supports web search for Anthropic and OpenAI models using the `--search` and `--search-location` flags. This replaces the previous plugin-based search, so you may want to remove the old `ANTHROPIC_WEB_SEARCH_TOOL_*` variables from your `~/.config/fabric/.env` file.
|
||||
> - **Image Generation**: Fabric now has powerful image generation capabilities with OpenAI.
|
||||
> - Generate images from text prompts and save them using `--image-file`.
|
||||
> - Edit existing images by providing an input image with `--attachment`.
|
||||
> - Control image `size`, `quality`, `compression`, and `background` with the new `--image-*` flags.
|
||||
>
|
||||
>June 17, 2025
|
||||
>
|
||||
>- Fabric now supports Perplexity AI. Configure it by using `fabric -S` to add your Perplexity AI API Key,
|
||||
> and then try:
|
||||
>
|
||||
> ```bash
|
||||
> fabric -m sonar-pro "What is the latest world news?"
|
||||
> ```
|
||||
>
|
||||
>June 11, 2025
|
||||
>
|
||||
>- Fabric's YouTube transcription now needs `yt-dlp` to be installed. Make sure to install the latest
|
||||
> version (2025.06.09 as of this note). The YouTube API key is only needed for comments (the `--comments` flag)
|
||||
> and metadata extraction (the `--metadata` flag).
|
||||
Fabric is evolving rapidly.
|
||||
|
||||
Stay current with the latest features by reviewing the [CHANGELOG](./CHANGELOG.md) for all recent changes.
|
||||
|
||||
## Philosophy
|
||||
|
||||
@@ -215,7 +197,7 @@ To install Fabric, [make sure Go is installed](https://go.dev/doc/install), and
|
||||
|
||||
```bash
|
||||
# Install Fabric directly from the repo
|
||||
go install github.com/danielmiessler/fabric@latest
|
||||
go install github.com/danielmiessler/fabric/cmd/fabric@latest
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
@@ -425,7 +407,7 @@ pipx uninstall fabric
|
||||
# Clear any old Fabric aliases
|
||||
(check your .bashrc, .zshrc, etc.)
|
||||
# Install the Go version
|
||||
go install github.com/danielmiessler/fabric@latest
|
||||
go install github.com/danielmiessler/fabric/cmd/fabric@latest
|
||||
# Run setup for the new version. Important because things have changed
|
||||
fabric --setup
|
||||
```
|
||||
@@ -437,7 +419,7 @@ Then [set your environmental variables](#environment-variables) as shown above.
|
||||
The great thing about Go is that it's super easy to upgrade. Just run the same command you used to install it in the first place and you'll always get the latest version.
|
||||
|
||||
```bash
|
||||
go install github.com/danielmiessler/fabric@latest
|
||||
go install github.com/danielmiessler/fabric/cmd/fabric@latest
|
||||
```
|
||||
|
||||
### Shell Completions
|
||||
@@ -562,10 +544,13 @@ Application Options:
|
||||
--image-compression= Compression level 0-100 for JPEG/WebP formats (default: not set)
|
||||
--image-background= Background type: opaque, transparent (default: opaque, only for
|
||||
PNG/WebP)
|
||||
--suppress-think Suppress text enclosed in thinking tags
|
||||
--think-start-tag= Start tag for thinking sections (default: <think>)
|
||||
--think-end-tag= End tag for thinking sections (default: </think>)
|
||||
--disable-responses-api Disable OpenAI Responses API (default: false)
|
||||
|
||||
Help Options:
|
||||
-h, --help Show this help message
|
||||
|
||||
```
|
||||
|
||||
## Our approach to prompting
|
||||
@@ -625,7 +610,7 @@ Now let's look at some things you can do with Fabric.
|
||||
<br />
|
||||
<br />
|
||||
|
||||
If you're not looking to do anything fancy, and you just want a lot of great prompts, you can navigate to the [`/patterns`](https://github.com/danielmiessler/fabric/tree/main/patterns) directory and start exploring!
|
||||
If you're not looking to do anything fancy, and you just want a lot of great prompts, you can navigate to the [`/patterns`](https://github.com/danielmiessler/fabric/tree/main/data/patterns) directory and start exploring!
|
||||
|
||||
We hope that if you used nothing else from Fabric, the Patterns by themselves will make the project useful.
|
||||
|
||||
@@ -641,7 +626,7 @@ be used in addition to the basic patterns.
|
||||
See the [Thinking Faster by Writing Less](https://arxiv.org/pdf/2502.18600) paper and
|
||||
the [Thought Generation section of Learn Prompting](https://learnprompting.org/docs/advanced/thought_generation/introduction) for examples of prompt strategies.
|
||||
|
||||
Each strategy is available as a small `json` file in the [`/strategies`](https://github.com/danielmiessler/fabric/tree/main/strategies) directory.
|
||||
Each strategy is available as a small `json` file in the [`/strategies`](https://github.com/danielmiessler/fabric/tree/main/data/strategies) directory.
|
||||
|
||||
The prompt modification of the strategy is applied to the system prompt and passed on to the
|
||||
LLM in the chat session.
|
||||
@@ -652,11 +637,48 @@ Use `fabric -S` and select the option to install the strategies in your `~/.conf
|
||||
|
||||
You may want to use Fabric to create your own custom Patterns—but not share them with others. No problem!
|
||||
|
||||
Just make a directory in `~/.config/custompatterns/` (or wherever) and put your `.md` files in there.
|
||||
Fabric now supports a dedicated custom patterns directory that keeps your personal patterns separate from the built-in ones. This means your custom patterns won't be overwritten when you update Fabric's built-in patterns.
|
||||
|
||||
When you're ready to use them, copy them into `~/.config/fabric/patterns/`
|
||||
### Setting Up Custom Patterns
|
||||
|
||||
You can then use them like any other Patterns, but they won't be public unless you explicitly submit them as Pull Requests to the Fabric project. So don't worry—they're private to you.
|
||||
1. Run the Fabric setup:
|
||||
|
||||
```bash
|
||||
fabric --setup
|
||||
```
|
||||
|
||||
2. Select the "Custom Patterns" option from the Tools menu and enter your desired directory path (e.g., `~/my-custom-patterns`)
|
||||
|
||||
3. Fabric will automatically create the directory if it does not exist.
|
||||
|
||||
### Using Custom Patterns
|
||||
|
||||
1. Create your custom pattern directory structure:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/my-custom-patterns/my-analyzer
|
||||
```
|
||||
|
||||
2. Create your pattern file
|
||||
|
||||
```bash
|
||||
echo "You are an expert analyzer of ..." > ~/my-custom-patterns/my-analyzer/system.md
|
||||
```
|
||||
|
||||
3. **Use your custom pattern:**
|
||||
|
||||
```bash
|
||||
fabric --pattern my-analyzer "analyze this text"
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Priority System**: Custom patterns take precedence over built-in patterns with the same name
|
||||
- **Seamless Integration**: Custom patterns appear in `fabric --listpatterns` alongside built-in ones
|
||||
- **Update Safe**: Your custom patterns are never affected by `fabric --updatepatterns`
|
||||
- **Private by Default**: Custom patterns remain private unless you explicitly share them
|
||||
|
||||
Your custom patterns are completely private and won't be affected by Fabric updates!
|
||||
|
||||
## Helper Apps
|
||||
|
||||
@@ -685,7 +707,7 @@ This will create a PDF file named `output.pdf` in the current directory.
|
||||
To install `to_pdf`, install it the same way as you install Fabric, just with a different repo name.
|
||||
|
||||
```bash
|
||||
go install github.com/danielmiessler/fabric/plugins/tools/to_pdf@latest
|
||||
go install github.com/danielmiessler/fabric/cmd/to_pdf@latest
|
||||
```
|
||||
|
||||
Make sure you have a LaTeX distribution (like TeX Live or MiKTeX) installed on your system, as `to_pdf` requires `pdflatex` to be available in your system's PATH.
|
||||
@@ -696,12 +718,12 @@ Make sure you have a LaTeX distribution (like TeX Live or MiKTeX) installed on y
|
||||
It generates a `json` representation of a directory of code that can be fed into an AI model
|
||||
with instructions to create a new feature or edit the code in a specified way.
|
||||
|
||||
See [the Create Coding Feature Pattern README](./patterns/create_coding_feature/README.md) for details.
|
||||
See [the Create Coding Feature Pattern README](./data/patterns/create_coding_feature/README.md) for details.
|
||||
|
||||
Install it first using:
|
||||
|
||||
```bash
|
||||
go install github.com/danielmiessler/fabric/plugins/tools/code_helper@latest
|
||||
go install github.com/danielmiessler/fabric/cmd/code_helper@latest
|
||||
```
|
||||
|
||||
## pbpaste
|
||||
|
||||
361
cli/cli.go
361
cli/cli.go
@@ -1,361 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/danielmiessler/fabric/plugins/tools/youtube"
|
||||
|
||||
"github.com/danielmiessler/fabric/common"
|
||||
"github.com/danielmiessler/fabric/core"
|
||||
"github.com/danielmiessler/fabric/plugins/ai"
|
||||
"github.com/danielmiessler/fabric/plugins/db/fsdb"
|
||||
"github.com/danielmiessler/fabric/plugins/tools/converter"
|
||||
"github.com/danielmiessler/fabric/restapi"
|
||||
)
|
||||
|
||||
// Cli Controls the cli. It takes in the flags and runs the appropriate functions
|
||||
func Cli(version string) (err error) {
|
||||
var currentFlags *Flags
|
||||
if currentFlags, err = Init(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.Version {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
|
||||
var homedir string
|
||||
if homedir, err = os.UserHomeDir(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fabricDb := fsdb.NewDb(filepath.Join(homedir, ".config/fabric"))
|
||||
|
||||
if err = fabricDb.Configure(); err != nil {
|
||||
if !currentFlags.Setup {
|
||||
println(err.Error())
|
||||
currentFlags.Setup = true
|
||||
}
|
||||
}
|
||||
|
||||
var registry *core.PluginRegistry
|
||||
if registry, err = core.NewPluginRegistry(fabricDb); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// if the setup flag is set, run the setup function
|
||||
if currentFlags.Setup {
|
||||
err = registry.Setup()
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.Serve {
|
||||
registry.ConfigureVendors()
|
||||
err = restapi.Serve(registry, currentFlags.ServeAddress, currentFlags.ServeAPIKey)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ServeOllama {
|
||||
registry.ConfigureVendors()
|
||||
err = restapi.ServeOllama(registry, currentFlags.ServeAddress, version)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.UpdatePatterns {
|
||||
err = registry.PatternsLoader.PopulateDB()
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ChangeDefaultModel {
|
||||
if err = registry.Defaults.Setup(); err != nil {
|
||||
return
|
||||
}
|
||||
err = registry.SaveEnvFile()
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.LatestPatterns != "0" {
|
||||
var parsedToInt int
|
||||
if parsedToInt, err = strconv.Atoi(currentFlags.LatestPatterns); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = fabricDb.Patterns.PrintLatestPatterns(parsedToInt); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ListPatterns {
|
||||
err = fabricDb.Patterns.ListNames(currentFlags.ShellCompleteOutput)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ListAllModels {
|
||||
var models *ai.VendorsModels
|
||||
if models, err = registry.VendorManager.GetModels(); err != nil {
|
||||
return
|
||||
}
|
||||
models.Print(currentFlags.ShellCompleteOutput)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ListAllContexts {
|
||||
err = fabricDb.Contexts.ListNames(currentFlags.ShellCompleteOutput)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ListAllSessions {
|
||||
err = fabricDb.Sessions.ListNames(currentFlags.ShellCompleteOutput)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.WipeContext != "" {
|
||||
err = fabricDb.Contexts.Delete(currentFlags.WipeContext)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.WipeSession != "" {
|
||||
err = fabricDb.Sessions.Delete(currentFlags.WipeSession)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.PrintSession != "" {
|
||||
err = fabricDb.Sessions.PrintSession(currentFlags.PrintSession)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.PrintContext != "" {
|
||||
err = fabricDb.Contexts.PrintContext(currentFlags.PrintContext)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.HtmlReadability {
|
||||
if msg, cleanErr := converter.HtmlReadability(currentFlags.Message); cleanErr != nil {
|
||||
fmt.Println("use original input, because can't apply html readability", err)
|
||||
} else {
|
||||
currentFlags.Message = msg
|
||||
}
|
||||
}
|
||||
|
||||
if currentFlags.ListExtensions {
|
||||
err = registry.TemplateExtensions.ListExtensions()
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.AddExtension != "" {
|
||||
err = registry.TemplateExtensions.RegisterExtension(currentFlags.AddExtension)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.RemoveExtension != "" {
|
||||
err = registry.TemplateExtensions.RemoveExtension(currentFlags.RemoveExtension)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ListStrategies {
|
||||
err = registry.Strategies.ListStrategies(currentFlags.ShellCompleteOutput)
|
||||
return
|
||||
}
|
||||
|
||||
if currentFlags.ListVendors {
|
||||
err = registry.ListVendors(os.Stdout)
|
||||
return
|
||||
}
|
||||
|
||||
// if the interactive flag is set, run the interactive function
|
||||
// if currentFlags.Interactive {
|
||||
// interactive.Interactive()
|
||||
// }
|
||||
|
||||
// if none of the above currentFlags are set, run the initiate chat function
|
||||
|
||||
var messageTools string
|
||||
|
||||
if currentFlags.YouTube != "" {
|
||||
if !registry.YouTube.IsConfigured() {
|
||||
err = fmt.Errorf("YouTube is not configured, please run the setup procedure")
|
||||
return
|
||||
}
|
||||
|
||||
var videoId string
|
||||
var playlistId string
|
||||
if videoId, playlistId, err = registry.YouTube.GetVideoOrPlaylistId(currentFlags.YouTube); err != nil {
|
||||
return
|
||||
} else if (videoId == "" || currentFlags.YouTubePlaylist) && playlistId != "" {
|
||||
if currentFlags.Output != "" {
|
||||
err = registry.YouTube.FetchAndSavePlaylist(playlistId, currentFlags.Output)
|
||||
} else {
|
||||
var videos []*youtube.VideoMeta
|
||||
if videos, err = registry.YouTube.FetchPlaylistVideos(playlistId); err != nil {
|
||||
err = fmt.Errorf("error fetching playlist videos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, video := range videos {
|
||||
var message string
|
||||
if message, err = processYoutubeVideo(currentFlags, registry, video.Id); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !currentFlags.IsChatRequest() {
|
||||
if err = WriteOutput(message, fmt.Sprintf("%v.md", video.TitleNormalized)); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
messageTools = AppendMessage(messageTools, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if messageTools, err = processYoutubeVideo(currentFlags, registry, videoId); err != nil {
|
||||
return
|
||||
}
|
||||
if !currentFlags.IsChatRequest() {
|
||||
err = currentFlags.WriteOutput(messageTools)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFlags.ScrapeURL != "" || currentFlags.ScrapeQuestion != "") && registry.Jina.IsConfigured() {
|
||||
// Check if the scrape_url flag is set and call ScrapeURL
|
||||
if currentFlags.ScrapeURL != "" {
|
||||
var website string
|
||||
if website, err = registry.Jina.ScrapeURL(currentFlags.ScrapeURL); err != nil {
|
||||
return
|
||||
}
|
||||
messageTools = AppendMessage(messageTools, website)
|
||||
}
|
||||
|
||||
// Check if the scrape_question flag is set and call ScrapeQuestion
|
||||
if currentFlags.ScrapeQuestion != "" {
|
||||
var website string
|
||||
if website, err = registry.Jina.ScrapeQuestion(currentFlags.ScrapeQuestion); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
messageTools = AppendMessage(messageTools, website)
|
||||
}
|
||||
|
||||
if !currentFlags.IsChatRequest() {
|
||||
err = currentFlags.WriteOutput(messageTools)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if messageTools != "" {
|
||||
currentFlags.AppendMessage(messageTools)
|
||||
}
|
||||
|
||||
var chatter *core.Chatter
|
||||
if chatter, err = registry.GetChatter(currentFlags.Model, currentFlags.ModelContextLength,
|
||||
currentFlags.Strategy, currentFlags.Stream, currentFlags.DryRun); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var session *fsdb.Session
|
||||
var chatReq *common.ChatRequest
|
||||
if chatReq, err = currentFlags.BuildChatRequest(strings.Join(os.Args[1:], " ")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if chatReq.Language == "" {
|
||||
chatReq.Language = registry.Language.DefaultLanguage.Value
|
||||
}
|
||||
var chatOptions *common.ChatOptions
|
||||
if chatOptions, err = currentFlags.BuildChatOptions(); err != nil {
|
||||
return
|
||||
}
|
||||
if session, err = chatter.Send(chatReq, chatOptions); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
result := session.GetLastMessage().Content
|
||||
|
||||
if !currentFlags.Stream {
|
||||
// print the result if it was not streamed already
|
||||
fmt.Println(result)
|
||||
}
|
||||
|
||||
// if the copy flag is set, copy the message to the clipboard
|
||||
if currentFlags.Copy {
|
||||
if err = CopyToClipboard(result); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// if the output flag is set, create an output file
|
||||
if currentFlags.Output != "" {
|
||||
if currentFlags.OutputSession {
|
||||
sessionAsString := session.String()
|
||||
err = CreateOutputFile(sessionAsString, currentFlags.Output)
|
||||
} else {
|
||||
err = CreateOutputFile(result, currentFlags.Output)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func processYoutubeVideo(
|
||||
flags *Flags, registry *core.PluginRegistry, videoId string) (message string, err error) {
|
||||
|
||||
if (!flags.YouTubeComments && !flags.YouTubeMetadata) || flags.YouTubeTranscript || flags.YouTubeTranscriptWithTimestamps {
|
||||
var transcript string
|
||||
var language = "en"
|
||||
if flags.Language != "" || registry.Language.DefaultLanguage.Value != "" {
|
||||
if flags.Language != "" {
|
||||
language = flags.Language
|
||||
} else {
|
||||
language = registry.Language.DefaultLanguage.Value
|
||||
}
|
||||
}
|
||||
if flags.YouTubeTranscriptWithTimestamps {
|
||||
if transcript, err = registry.YouTube.GrabTranscriptWithTimestamps(videoId, language); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if transcript, err = registry.YouTube.GrabTranscript(videoId, language); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
message = AppendMessage(message, transcript)
|
||||
}
|
||||
|
||||
if flags.YouTubeComments {
|
||||
var comments []string
|
||||
if comments, err = registry.YouTube.GrabComments(videoId); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
commentsString := strings.Join(comments, "\n")
|
||||
|
||||
message = AppendMessage(message, commentsString)
|
||||
}
|
||||
|
||||
if flags.YouTubeMetadata {
|
||||
var metadata *youtube.VideoMetadata
|
||||
if metadata, err = registry.YouTube.GrabMetadata(videoId); err != nil {
|
||||
return
|
||||
}
|
||||
metadataJson, _ := json.MarshalIndent(metadata, "", " ")
|
||||
message = AppendMessage(message, string(metadataJson))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func WriteOutput(message string, outputFile string) (err error) {
|
||||
fmt.Println(message)
|
||||
if outputFile != "" {
|
||||
err = CreateOutputFile(message, outputFile)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jessevdk/go-flags"
|
||||
|
||||
"github.com/danielmiessler/fabric/cli"
|
||||
"github.com/danielmiessler/fabric/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
3
cmd/fabric/version.go
Normal file
3
cmd/fabric/version.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package main
|
||||
|
||||
var version = "v1.4.258"
|
||||
151
cmd/generate_changelog/PRD.md
Normal file
151
cmd/generate_changelog/PRD.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Product Requirements Document: Changelog Generator
|
||||
|
||||
## Overview
|
||||
|
||||
The Changelog Generator is a high-performance Go tool that automatically generates comprehensive changelogs from git history and GitHub pull requests.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Performance**: Very fast. Efficient enough to be used in CI/CD as part of release process.
|
||||
2. **Completeness**: Capture ALL commits including unreleased changes
|
||||
3. **Efficiency**: Minimize API calls through caching and batch operations
|
||||
4. **Reliability**: Handle errors gracefully with proper Go error handling
|
||||
5. **Simplicity**: Single binary with no runtime dependencies
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. One-Pass Git History Algorithm
|
||||
|
||||
- Walk git history once from newest to oldest
|
||||
- Start with "Unreleased" bucket for all new commits
|
||||
- Switch buckets when encountering version commits
|
||||
- No need to calculate ranges between versions
|
||||
|
||||
### 2. Native Library Integration
|
||||
|
||||
- **go-git**: Pure Go git implementation (no git binary required)
|
||||
- **go-github**: Official GitHub Go client library
|
||||
- Benefits: Type safety, better error handling, no subprocess overhead
|
||||
|
||||
### 3. Smart Caching System
|
||||
|
||||
- SQLite-based persistent cache
|
||||
- Stores: versions, commits, PR details, last processed commit
|
||||
- Enables incremental updates on subsequent runs
|
||||
- Instant changelog regeneration from cache
|
||||
|
||||
### 4. Concurrent Processing
|
||||
|
||||
- Parallel GitHub API calls (up to 10 concurrent)
|
||||
- Batch PR fetching with deduplication
|
||||
- Rate limiting awareness
|
||||
|
||||
### 5. Enhanced Output
|
||||
|
||||
- "Unreleased" section for commits since last version
|
||||
- Clean markdown formatting
|
||||
- Configurable version limiting
|
||||
- Direct commit tracking (non-PR commits)
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Module Structure
|
||||
|
||||
```text
|
||||
cmd/generate_changelog/
|
||||
├── main.go # CLI entry point with cobra
|
||||
├── internal/
|
||||
│ ├── git/ # Git operations (go-git)
|
||||
│ ├── github/ # GitHub API client (go-github)
|
||||
│ ├── cache/ # SQLite caching layer
|
||||
│ ├── changelog/ # Core generation logic
|
||||
│ └── config/ # Configuration management
|
||||
└── changelog.db # SQLite cache (generated)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Git walker collects all commits in one pass
|
||||
2. Commits bucketed by version (starting with "Unreleased")
|
||||
3. PR numbers extracted from merge commits
|
||||
4. GitHub API batch-fetches PR details
|
||||
5. Cache stores everything for future runs
|
||||
6. Formatter generates markdown output
|
||||
|
||||
### Cache Schema
|
||||
|
||||
- **metadata**: Last processed commit SHA
|
||||
- **versions**: Version names, dates, commit SHAs
|
||||
- **commits**: Full commit details with version associations
|
||||
- **pull_requests**: PR details including commits
|
||||
- Indexes on version and PR number for fast lookups
|
||||
|
||||
### Features
|
||||
|
||||
- **Unreleased section**: Shows all new commits
|
||||
- **Better caching**: SQLite vs JSON, incremental updates
|
||||
- **Smarter deduplication**: Removes consecutive duplicate commits
|
||||
- **Direct commit tracking**: Shows non-PR commits
|
||||
|
||||
### Reliability
|
||||
|
||||
- **No subprocess errors**: Direct library usage
|
||||
- **Type safety**: Compile-time checking
|
||||
- **Better error handling**: Go's explicit error returns
|
||||
|
||||
### Deployment
|
||||
|
||||
- **Single binary**: No Python/pip/dependencies
|
||||
- **Cross-platform**: Compile for any OS/architecture
|
||||
- **No git CLI required**: Uses go-git library
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `GITHUB_TOKEN`: GitHub API authentication token
|
||||
|
||||
### Command Line Flags
|
||||
|
||||
- `--repo, -r`: Repository path (default: current directory)
|
||||
- `--output, -o`: Output file (default: stdout)
|
||||
- `--limit, -l`: Version limit (default: all)
|
||||
- `--version, -v`: Target specific version
|
||||
- `--save-data`: Export debug JSON
|
||||
- `--cache`: Cache file location
|
||||
- `--no-cache`: Disable caching
|
||||
- `--rebuild-cache`: Force cache rebuild
|
||||
- `--token`: GitHub token override
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Performance**: Generate full changelog in <5 seconds for fabric repo
|
||||
2. **Completeness**: 100% commit coverage including unreleased
|
||||
3. **Accuracy**: Correct PR associations and change extraction
|
||||
4. **Reliability**: Handle network failures gracefully
|
||||
5. **Usability**: Simple CLI with sensible defaults
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Multiple output formats**: JSON, HTML, etc.
|
||||
2. **Custom version patterns**: Configurable regex
|
||||
3. **Change categorization**: feat/fix/docs auto-grouping
|
||||
4. **Conventional commits**: Full support for semantic versioning
|
||||
5. **GitLab/Bitbucket**: Support other platforms
|
||||
6. **Web UI**: Interactive changelog browser
|
||||
7. **Incremental updates**: Update existing CHANGELOG.md file
|
||||
8. **Breaking change detection**: Highlight breaking changes
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- ✅ Core architecture and modules
|
||||
- ✅ One-pass git walking algorithm
|
||||
- ✅ GitHub API integration with concurrency
|
||||
- ✅ SQLite caching system
|
||||
- ✅ Changelog formatting and generation
|
||||
- ✅ CLI with all planned flags
|
||||
- ✅ Documentation (README and PRD)
|
||||
|
||||
## Conclusion
|
||||
|
||||
This Go implementation provides a modern, efficient, and feature-rich changelog generator.
|
||||
263
cmd/generate_changelog/README.md
Normal file
263
cmd/generate_changelog/README.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Changelog Generator
|
||||
|
||||
A high-performance changelog generator for Git repositories that automatically creates comprehensive, well-formatted changelogs from your git history and GitHub pull requests.
|
||||
|
||||
## Features
|
||||
|
||||
- **One-pass git history walking**: Efficiently processes entire repository history in a single pass
|
||||
- **Automatic PR detection**: Extracts pull request information from merge commits
|
||||
- **GitHub API integration**: Fetches detailed PR information including commits, authors, and descriptions
|
||||
- **Smart caching**: SQLite-based caching for instant incremental updates
|
||||
- **Unreleased changes**: Tracks all commits since the last release
|
||||
- **Concurrent processing**: Parallel GitHub API calls for improved performance
|
||||
- **Flexible output**: Generate complete changelogs or target specific versions
|
||||
- **GraphQL optimization**: Ultra-fast PR fetching using GitHub GraphQL API (~5-10 calls vs 1000s)
|
||||
- **Intelligent sync**: Automatically syncs new PRs every 24 hours or when missing PRs are detected
|
||||
- **AI-powered summaries**: Optional Fabric integration for enhanced changelog summaries
|
||||
- **Advanced caching**: Content-based change detection for AI summaries with hash comparison
|
||||
- **Author type detection**: Distinguishes between users, bots, and organizations
|
||||
- **Lightning-fast incremental updates**: SHA→PR mapping for instant git operations
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go install github.com/danielmiessler/fabric/cmd/generate_changelog@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic usage (generate complete changelog)
|
||||
|
||||
```bash
|
||||
generate_changelog
|
||||
```
|
||||
|
||||
### Save to file
|
||||
|
||||
```bash
|
||||
generate_changelog -o CHANGELOG.md
|
||||
```
|
||||
|
||||
### Generate for specific version
|
||||
|
||||
```bash
|
||||
generate_changelog -v v1.4.244
|
||||
```
|
||||
|
||||
### Limit to recent versions
|
||||
|
||||
```bash
|
||||
generate_changelog -l 10
|
||||
```
|
||||
|
||||
### Using GitHub token for private repos or higher rate limits
|
||||
|
||||
```bash
|
||||
export GITHUB_TOKEN=your_token_here
|
||||
generate_changelog
|
||||
|
||||
# Or pass directly
|
||||
generate_changelog --token your_token_here
|
||||
```
|
||||
|
||||
### AI-enhanced summaries
|
||||
|
||||
```bash
|
||||
# Enable AI summaries using Fabric
|
||||
generate_changelog --ai-summarize
|
||||
|
||||
# Use custom model for AI summaries
|
||||
FABRIC_CHANGELOG_SUMMARIZE_MODEL=claude-opus-4 generate_changelog --ai-summarize
|
||||
```
|
||||
|
||||
### Cache management
|
||||
|
||||
```bash
|
||||
# Rebuild cache from scratch
|
||||
generate_changelog --rebuild-cache
|
||||
|
||||
# Force a full PR sync from GitHub
|
||||
generate_changelog --force-pr-sync
|
||||
|
||||
# Disable cache usage
|
||||
generate_changelog --no-cache
|
||||
|
||||
# Use custom cache location
|
||||
generate_changelog --cache /path/to/cache.db
|
||||
```
|
||||
|
||||
## Command Line Options
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
|------|-------|-------------|---------|
|
||||
| `--repo` | `-r` | Repository path | `.` (current directory) |
|
||||
| `--output` | `-o` | Output file | stdout |
|
||||
| `--limit` | `-l` | Limit number of versions | 0 (all) |
|
||||
| `--version` | `-v` | Generate for specific version | |
|
||||
| `--save-data` | | Save version data to JSON | false |
|
||||
| `--cache` | | Cache database file | `./cmd/generate_changelog/changelog.db` |
|
||||
| `--no-cache` | | Disable cache usage | false |
|
||||
| `--rebuild-cache` | | Rebuild cache from scratch | false |
|
||||
| `--force-pr-sync` | | Force a full PR sync from GitHub | false |
|
||||
| `--token` | | GitHub API token | `$GITHUB_TOKEN` |
|
||||
| `--ai-summarize` | | Generate AI-enhanced summaries using Fabric | false |
|
||||
|
||||
## Output Format
|
||||
|
||||
The generated changelog follows this structure:
|
||||
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### PR [#1601](url) by [author](profile): PR Title
|
||||
- Change description 1
|
||||
- Change description 2
|
||||
|
||||
### Direct commits
|
||||
- Direct commit message 1
|
||||
- Direct commit message 2
|
||||
|
||||
## v1.4.244 (2025-07-09)
|
||||
|
||||
### PR [#1598](url) by [author](profile): PR Title
|
||||
- Change description
|
||||
...
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Git History Walking**: The tool walks through your git history from newest to oldest commits
|
||||
2. **Version Detection**: Identifies version bump commits (pattern: "Update version to vX.Y.Z")
|
||||
3. **PR Extraction**: Detects merge commits and extracts PR numbers
|
||||
4. **GitHub API Calls**: Fetches detailed PR information in parallel batches
|
||||
5. **Change Extraction**: Extracts changes from PR commit messages or PR body
|
||||
6. **Formatting**: Generates clean, organized markdown output
|
||||
|
||||
## Performance
|
||||
|
||||
- **Native Go libraries**: Uses go-git and go-github for maximum performance
|
||||
- **Concurrent API calls**: Processes up to 10 GitHub API requests in parallel
|
||||
- **Smart caching**: SQLite cache eliminates redundant API calls
|
||||
- **Incremental updates**: Only processes new commits on subsequent runs
|
||||
- **GraphQL optimization**: Uses GitHub GraphQL API to fetch all PR data in ~5-10 calls
|
||||
- **AI-powered summaries**: Optional Fabric integration with intelligent caching
|
||||
- **Content-based change detection**: AI summaries only regenerated when content changes
|
||||
- **Lightning-fast git operations**: SHA→PR mapping stored in database for instant lookups
|
||||
|
||||
### Major Optimization: GraphQL + Advanced Caching
|
||||
|
||||
The tool has been optimized to drastically reduce GitHub API calls and improve performance:
|
||||
|
||||
**Previous approach**: Individual API calls for each PR (2 API calls per PR)
|
||||
|
||||
- For a repo with 500 PRs: 1,000 API calls
|
||||
|
||||
**Current approach**: GraphQL batch fetching with intelligent caching
|
||||
|
||||
- For a repo with 500 PRs: ~5-10 GraphQL calls (initial fetch) + 0 calls (subsequent runs with cache)
|
||||
- **99%+ reduction in API calls after initial run!**
|
||||
|
||||
The optimization includes:
|
||||
|
||||
1. **GraphQL Batch Fetch**: Uses GitHub's GraphQL API to fetch all merged PRs with commits in minimal calls
|
||||
2. **Smart Caching**: Stores complete PR data, commits, and SHA mappings in SQLite
|
||||
3. **Incremental Sync**: Only fetches PRs merged after the last sync timestamp
|
||||
4. **Automatic Refresh**: PRs are synced every 24 hours or when missing PRs are detected
|
||||
5. **AI Summary Caching**: Content-based change detection prevents unnecessary AI regeneration
|
||||
6. **Fallback Support**: If GraphQL fails, falls back to REST API batch fetching
|
||||
7. **Lightning Git Operations**: Pre-computed SHA→PR mappings for instant commit association
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.24+ (for installation from source)
|
||||
- Git repository
|
||||
- GitHub token (optional, for private repos or higher rate limits)
|
||||
- Fabric CLI (optional, for AI-enhanced summaries)
|
||||
|
||||
## Authentication
|
||||
|
||||
The tool supports GitHub authentication via:
|
||||
|
||||
1. Environment variable: `export GITHUB_TOKEN=your_token`
|
||||
2. Command line flag: `--token your_token`
|
||||
3. `.env` file in the same directory as the binary
|
||||
|
||||
### Environment File Support
|
||||
|
||||
Create a `.env` file next to the `generate_changelog` binary:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=your_github_token_here
|
||||
FABRIC_CHANGELOG_SUMMARIZE_MODEL=claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
The tool automatically loads `.env` files for convenient configuration management.
|
||||
|
||||
Without authentication, the tool is limited to 60 GitHub API requests per hour.
|
||||
|
||||
## Caching
|
||||
|
||||
The SQLite cache stores:
|
||||
|
||||
- Version information and commit associations
|
||||
- Pull request details (title, body, commits, authors)
|
||||
- Last processed commit SHA for incremental updates
|
||||
- Last PR sync timestamp for intelligent refresh
|
||||
- AI summaries with content-based change detection
|
||||
- SHA→PR mappings for lightning-fast git operations
|
||||
|
||||
Cache benefits:
|
||||
|
||||
- Instant changelog regeneration
|
||||
- Drastically reduced GitHub API usage (99%+ reduction after initial run)
|
||||
- Offline changelog generation (after initial cache build)
|
||||
- Automatic PR data refresh every 24 hours
|
||||
- Batch database transactions for better performance
|
||||
- Content-aware AI summary regeneration
|
||||
|
||||
## AI-Enhanced Summaries
|
||||
|
||||
The tool can generate AI-powered summaries using Fabric for more polished, professional changelogs:
|
||||
|
||||
```bash
|
||||
# Enable AI summarization
|
||||
generate_changelog --ai-summarize
|
||||
|
||||
# Custom model (default: claude-sonnet-4-20250514)
|
||||
FABRIC_CHANGELOG_SUMMARIZE_MODEL=claude-opus-4 generate_changelog --ai-summarize
|
||||
```
|
||||
|
||||
### AI Summary Features
|
||||
|
||||
- **Content-based change detection**: AI summaries are only regenerated when version content changes
|
||||
- **Intelligent caching**: Preserves existing summaries and only processes changed versions
|
||||
- **Content hash comparison**: Uses SHA256 hashing to detect when "Unreleased" content changes
|
||||
- **Automatic fallback**: Falls back to raw content if AI processing fails
|
||||
- **Error detection**: Identifies and handles AI processing errors gracefully
|
||||
- **Minimum content filtering**: Skips AI processing for very brief content (< 256 characters)
|
||||
|
||||
### AI Model Configuration
|
||||
|
||||
Set the model via environment variable:
|
||||
|
||||
```bash
|
||||
export FABRIC_CHANGELOG_SUMMARIZE_MODEL=claude-opus-4
|
||||
# or
|
||||
export FABRIC_CHANGELOG_SUMMARIZE_MODEL=gpt-4
|
||||
```
|
||||
|
||||
AI summaries are cached and only regenerated when:
|
||||
|
||||
- Version content changes (detected via hash comparison)
|
||||
- No existing AI summary exists for the version
|
||||
- Force rebuild is requested
|
||||
|
||||
## Contributing
|
||||
|
||||
This tool is part of the Fabric project. Contributions are welcome!
|
||||
|
||||
## License
|
||||
|
||||
The MIT License. Same as the Fabric project.
|
||||
BIN
cmd/generate_changelog/changelog.db
Normal file
BIN
cmd/generate_changelog/changelog.db
Normal file
Binary file not shown.
448
cmd/generate_changelog/internal/cache/cache.go
vendored
Normal file
448
cmd/generate_changelog/internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,448 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 {
|
||||
v.Date, _ = time.Parse(time.RFC3339, dateStr.String)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
699
cmd/generate_changelog/internal/changelog/generator.go
Normal file
699
cmd/generate_changelog/internal/changelog/generator.go
Normal file
@@ -0,0 +1,699 @@
|
||||
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(); 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() 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 || g.cfg.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)
|
||||
}
|
||||
79
cmd/generate_changelog/internal/changelog/summarize.go
Normal file
79
cmd/generate_changelog/internal/changelog/summarize.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
15
cmd/generate_changelog/internal/config/config.go
Normal file
15
cmd/generate_changelog/internal/config/config.go
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
}
|
||||
26
cmd/generate_changelog/internal/git/types.go
Normal file
26
cmd/generate_changelog/internal/git/types.go
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
402
cmd/generate_changelog/internal/git/walker.go
Normal file
402
cmd/generate_changelog/internal/git/walker.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
versionPattern = regexp.MustCompile(`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
|
||||
}
|
||||
354
cmd/generate_changelog/internal/github/client.go
Normal file
354
cmd/generate_changelog/internal/github/client.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v66/github"
|
||||
"github.com/hasura/go-graphql-client"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *github.Client
|
||||
graphqlClient *graphql.Client
|
||||
owner string
|
||||
repo string
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(token, owner, repo string) *Client {
|
||||
var githubClient *github.Client
|
||||
var httpClient *http.Client
|
||||
var gqlClient *graphql.Client
|
||||
|
||||
if token != "" {
|
||||
ts := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: token},
|
||||
)
|
||||
httpClient = oauth2.NewClient(context.Background(), ts)
|
||||
githubClient = github.NewClient(httpClient)
|
||||
gqlClient = graphql.NewClient("https://api.github.com/graphql", httpClient)
|
||||
} else {
|
||||
httpClient = http.DefaultClient
|
||||
githubClient = github.NewClient(nil)
|
||||
gqlClient = graphql.NewClient("https://api.github.com/graphql", httpClient)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
client: githubClient,
|
||||
graphqlClient: gqlClient,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) FetchPRs(prNumbers []int) ([]*PR, error) {
|
||||
if len(prNumbers) == 0 {
|
||||
return []*PR{}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
prs := make([]*PR, 0, len(prNumbers))
|
||||
prsChan := make(chan *PR, len(prNumbers))
|
||||
errChan := make(chan error, len(prNumbers))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 10)
|
||||
|
||||
for _, prNumber := range prNumbers {
|
||||
wg.Add(1)
|
||||
go func(num int) {
|
||||
defer wg.Done()
|
||||
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
pr, err := c.fetchSinglePR(ctx, num)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to fetch PR #%d: %w", num, err)
|
||||
return
|
||||
}
|
||||
prsChan <- pr
|
||||
}(prNumber)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(prsChan)
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
var errors []error
|
||||
for pr := range prsChan {
|
||||
prs = append(prs, pr)
|
||||
}
|
||||
for err := range errChan {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return prs, fmt.Errorf("some PRs failed to fetch: %v", errors)
|
||||
}
|
||||
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchSinglePR(ctx context.Context, prNumber int) (*PR, error) {
|
||||
pr, _, err := c.client.PullRequests.Get(ctx, c.owner, c.repo, prNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commits, _, err := c.client.PullRequests.ListCommits(ctx, c.owner, c.repo, prNumber, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch commits: %w", err)
|
||||
}
|
||||
|
||||
result := &PR{
|
||||
Number: prNumber,
|
||||
Title: getString(pr.Title),
|
||||
Body: getString(pr.Body),
|
||||
URL: getString(pr.HTMLURL),
|
||||
Commits: make([]PRCommit, 0, len(commits)),
|
||||
}
|
||||
|
||||
if pr.MergedAt != nil {
|
||||
result.MergedAt = pr.MergedAt.Time
|
||||
}
|
||||
|
||||
if pr.User != nil {
|
||||
result.Author = getString(pr.User.Login)
|
||||
result.AuthorURL = getString(pr.User.HTMLURL)
|
||||
userType := getString(pr.User.Type) // GitHub API returns "User", "Organization", or "Bot"
|
||||
|
||||
// Convert GitHub API type to lowercase
|
||||
switch userType {
|
||||
case "User":
|
||||
result.AuthorType = "user"
|
||||
case "Organization":
|
||||
result.AuthorType = "organization"
|
||||
case "Bot":
|
||||
result.AuthorType = "bot"
|
||||
default:
|
||||
result.AuthorType = "user" // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
if pr.MergeCommitSHA != nil {
|
||||
result.MergeCommit = *pr.MergeCommitSHA
|
||||
}
|
||||
|
||||
for _, commit := range commits {
|
||||
if commit.Commit != nil {
|
||||
prCommit := PRCommit{
|
||||
SHA: getString(commit.SHA),
|
||||
Message: strings.TrimSpace(getString(commit.Commit.Message)),
|
||||
}
|
||||
if commit.Commit.Author != nil {
|
||||
prCommit.Author = getString(commit.Commit.Author.Name)
|
||||
}
|
||||
result.Commits = append(result.Commits, prCommit)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func getString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// FetchAllMergedPRs fetches all merged PRs using GitHub's search API
|
||||
// This is much more efficient than fetching PRs individually
|
||||
func (c *Client) FetchAllMergedPRs(since time.Time) ([]*PR, error) {
|
||||
ctx := context.Background()
|
||||
var allPRs []*PR
|
||||
|
||||
// Build search query for merged PRs
|
||||
query := fmt.Sprintf("repo:%s/%s is:pr is:merged", c.owner, c.repo)
|
||||
if !since.IsZero() {
|
||||
query += fmt.Sprintf(" merged:>=%s", since.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
opts := &github.SearchOptions{
|
||||
Sort: "created",
|
||||
Order: "desc",
|
||||
ListOptions: github.ListOptions{
|
||||
PerPage: 100, // Maximum allowed
|
||||
},
|
||||
}
|
||||
|
||||
for {
|
||||
result, resp, err := c.client.Search.Issues(ctx, query, opts)
|
||||
if err != nil {
|
||||
return allPRs, fmt.Errorf("failed to search PRs: %w", err)
|
||||
}
|
||||
|
||||
// Process PRs in parallel
|
||||
prsChan := make(chan *PR, len(result.Issues))
|
||||
errChan := make(chan error, len(result.Issues))
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 10) // Limit concurrent requests
|
||||
|
||||
for _, issue := range result.Issues {
|
||||
if issue.PullRequestLinks == nil {
|
||||
continue // Not a PR
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(prNumber int) {
|
||||
defer wg.Done()
|
||||
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
pr, err := c.fetchSinglePR(ctx, prNumber)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to fetch PR #%d: %w", prNumber, err)
|
||||
return
|
||||
}
|
||||
prsChan <- pr
|
||||
}(*issue.Number)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(prsChan)
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
for pr := range prsChan {
|
||||
allPRs = append(allPRs, pr)
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
for err := range errChan {
|
||||
// Log error but continue processing
|
||||
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
|
||||
}
|
||||
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opts.Page = resp.NextPage
|
||||
}
|
||||
|
||||
return allPRs, nil
|
||||
}
|
||||
|
||||
// FetchAllMergedPRsGraphQL fetches all merged PRs with their commits using GraphQL
|
||||
// This is the ultimate optimization - gets everything in ~5-10 API calls
|
||||
func (c *Client) FetchAllMergedPRsGraphQL(since time.Time) ([]*PR, error) {
|
||||
ctx := context.Background()
|
||||
var allPRs []*PR
|
||||
var after *string
|
||||
totalFetched := 0
|
||||
|
||||
for {
|
||||
// Prepare variables
|
||||
variables := map[string]interface{}{
|
||||
"owner": graphql.String(c.owner),
|
||||
"repo": graphql.String(c.repo),
|
||||
"after": (*graphql.String)(after),
|
||||
}
|
||||
|
||||
// Execute GraphQL query
|
||||
var query PullRequestsQuery
|
||||
err := c.graphqlClient.Query(ctx, &query, variables)
|
||||
if err != nil {
|
||||
return allPRs, fmt.Errorf("GraphQL query failed: %w", err)
|
||||
}
|
||||
|
||||
prs := query.Repository.PullRequests.Nodes
|
||||
fmt.Fprintf(os.Stderr, "Fetched %d PRs via GraphQL (page %d)\n", len(prs), (totalFetched/100)+1)
|
||||
|
||||
// Convert GraphQL PRs to our PR struct
|
||||
for _, gqlPR := range prs {
|
||||
// If we have a since filter, stop when we reach older PRs
|
||||
if !since.IsZero() && gqlPR.MergedAt.Before(since) {
|
||||
fmt.Fprintf(os.Stderr, "Reached PRs older than %s, stopping\n", since.Format("2006-01-02"))
|
||||
return allPRs, nil
|
||||
}
|
||||
|
||||
pr := &PR{
|
||||
Number: gqlPR.Number,
|
||||
Title: gqlPR.Title,
|
||||
Body: gqlPR.Body,
|
||||
URL: gqlPR.URL,
|
||||
MergedAt: gqlPR.MergedAt,
|
||||
Commits: make([]PRCommit, 0, len(gqlPR.Commits.Nodes)),
|
||||
}
|
||||
|
||||
// Handle author - check if it's nil first
|
||||
if gqlPR.Author != nil {
|
||||
pr.Author = gqlPR.Author.Login
|
||||
pr.AuthorURL = gqlPR.Author.URL
|
||||
|
||||
switch gqlPR.Author.Typename {
|
||||
case "Bot":
|
||||
pr.AuthorType = "bot"
|
||||
case "Organization":
|
||||
pr.AuthorType = "organization"
|
||||
case "User":
|
||||
pr.AuthorType = "user"
|
||||
default:
|
||||
pr.AuthorType = "user" // fallback
|
||||
if gqlPR.Author.Typename != "" {
|
||||
fmt.Fprintf(os.Stderr, "PR #%d: Unknown author typename '%s'\n", gqlPR.Number, gqlPR.Author.Typename)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Author is nil - try to fetch from REST API as fallback
|
||||
fmt.Fprintf(os.Stderr, "PR #%d: Author is nil in GraphQL response, fetching from REST API\n", gqlPR.Number)
|
||||
|
||||
// Fetch this specific PR from REST API
|
||||
restPR, err := c.fetchSinglePR(ctx, gqlPR.Number)
|
||||
if err == nil && restPR != nil && restPR.Author != "" {
|
||||
pr.Author = restPR.Author
|
||||
pr.AuthorURL = restPR.AuthorURL
|
||||
pr.AuthorType = restPR.AuthorType
|
||||
} else {
|
||||
// Fallback if REST API also fails
|
||||
pr.Author = "[unknown]"
|
||||
pr.AuthorURL = ""
|
||||
pr.AuthorType = "user"
|
||||
}
|
||||
}
|
||||
|
||||
// Convert commits
|
||||
for _, commitNode := range gqlPR.Commits.Nodes {
|
||||
commit := PRCommit{
|
||||
SHA: commitNode.Commit.OID,
|
||||
Message: strings.TrimSpace(commitNode.Commit.Message),
|
||||
Author: commitNode.Commit.Author.Name,
|
||||
}
|
||||
pr.Commits = append(pr.Commits, commit)
|
||||
}
|
||||
|
||||
allPRs = append(allPRs, pr)
|
||||
}
|
||||
|
||||
totalFetched += len(prs)
|
||||
|
||||
// Check if we need to fetch more pages
|
||||
if !query.Repository.PullRequests.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
|
||||
after = &query.Repository.PullRequests.PageInfo.EndCursor
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Total PRs fetched via GraphQL: %d\n", len(allPRs))
|
||||
return allPRs, nil
|
||||
}
|
||||
57
cmd/generate_changelog/internal/github/types.go
Normal file
57
cmd/generate_changelog/internal/github/types.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
|
||||
type PRCommit struct {
|
||||
SHA string
|
||||
Message string
|
||||
Author string
|
||||
}
|
||||
|
||||
// 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
|
||||
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)"`
|
||||
}
|
||||
84
cmd/generate_changelog/main.go
Normal file
84
cmd/generate_changelog/main.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/changelog"
|
||||
"github.com/danielmiessler/fabric/cmd/generate_changelog/internal/config"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
cfg = &config.Config{}
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "generate_changelog",
|
||||
Short: "Generate changelog from git history and GitHub PRs",
|
||||
Long: `A high-performance changelog generator that walks git history,
|
||||
collects version information and pull requests, and generates a
|
||||
comprehensive changelog in markdown format.`,
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&cfg.RepoPath, "repo", "r", ".", "Repository path")
|
||||
rootCmd.Flags().StringVarP(&cfg.OutputFile, "output", "o", "", "Output file (default: stdout)")
|
||||
rootCmd.Flags().IntVarP(&cfg.Limit, "limit", "l", 0, "Limit number of versions (0 = all)")
|
||||
rootCmd.Flags().StringVarP(&cfg.Version, "version", "v", "", "Generate changelog for specific version")
|
||||
rootCmd.Flags().BoolVar(&cfg.SaveData, "save-data", false, "Save version data to JSON for debugging")
|
||||
rootCmd.Flags().StringVar(&cfg.CacheFile, "cache", "./cmd/generate_changelog/changelog.db", "Cache database file")
|
||||
rootCmd.Flags().BoolVar(&cfg.NoCache, "no-cache", false, "Disable cache usage")
|
||||
rootCmd.Flags().BoolVar(&cfg.RebuildCache, "rebuild-cache", false, "Rebuild cache from scratch")
|
||||
rootCmd.Flags().StringVar(&cfg.GitHubToken, "token", "", "GitHub API token (or set GITHUB_TOKEN env var)")
|
||||
rootCmd.Flags().BoolVar(&cfg.ForcePRSync, "force-pr-sync", false, "Force a full PR sync from GitHub (ignores cache age)")
|
||||
rootCmd.Flags().BoolVar(&cfg.EnableAISummary, "ai-summarize", false, "Generate AI-enhanced summaries using Fabric")
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
if cfg.GitHubToken == "" {
|
||||
cfg.GitHubToken = os.Getenv("GITHUB_TOKEN")
|
||||
}
|
||||
|
||||
generator, err := changelog.New(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create changelog generator: %w", err)
|
||||
}
|
||||
|
||||
output, err := generator.Generate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate changelog: %w", err)
|
||||
}
|
||||
|
||||
if cfg.OutputFile != "" {
|
||||
if err := os.WriteFile(cfg.OutputFile, []byte(output), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write output file: %w", err)
|
||||
}
|
||||
fmt.Printf("Changelog written to %s\n", cfg.OutputFile)
|
||||
} else {
|
||||
fmt.Print(output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load .env file from the same directory as the binary
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
envPath := filepath.Join(filepath.Dir(exePath), ".env")
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
// .env file exists, load it
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to load .env file: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,10 @@ _fabric() {
|
||||
'(--liststrategies)--liststrategies[List all strategies]' \
|
||||
'(--listvendors)--listvendors[List all vendors]' \
|
||||
'(--shell-complete-list)--shell-complete-list[Output raw list without headers/formatting (for shell completion)]' \
|
||||
'(--suppress-think)--suppress-think[Suppress text enclosed in thinking tags]' \
|
||||
'(--think-start-tag)--think-start-tag[Start tag for thinking sections (default: <think>)]:start tag:' \
|
||||
'(--think-end-tag)--think-end-tag[End tag for thinking sections (default: </think>)]:end tag:' \
|
||||
'(--disable-responses-api)--disable-responses-api[Disable OpenAI Responses API (default: false)]' \
|
||||
'(-h --help)'{-h,--help}'[Show this help message]' \
|
||||
'*:arguments:'
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ _fabric() {
|
||||
_get_comp_words_by_ref -n : cur prev words cword
|
||||
|
||||
# Define all possible options/flags
|
||||
local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
|
||||
local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
|
||||
|
||||
# Helper function for dynamic completions
|
||||
_fabric_get_list() {
|
||||
@@ -81,7 +81,7 @@ _fabric() {
|
||||
return 0
|
||||
;;
|
||||
# Options requiring simple arguments (no specific completion logic here)
|
||||
-v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression)
|
||||
-v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression | --think-start-tag | --think-end-tag)
|
||||
# No specific completion suggestions, user types the value
|
||||
return 0
|
||||
;;
|
||||
|
||||
@@ -69,6 +69,8 @@ complete -c fabric -l image-background -d "Background type: opaque, transparent
|
||||
complete -c fabric -l addextension -d "Register a new extension from config file path" -r -a "*.yaml *.yml"
|
||||
complete -c fabric -l rmextension -d "Remove a registered extension by name" -a "(__fabric_get_extensions)"
|
||||
complete -c fabric -l strategy -d "Choose a strategy from the available strategies" -a "(__fabric_get_strategies)"
|
||||
complete -c fabric -l think-start-tag -d "Start tag for thinking sections (default: <think>)"
|
||||
complete -c fabric -l think-end-tag -d "End tag for thinking sections (default: </think>)"
|
||||
|
||||
# Boolean flags (no arguments)
|
||||
complete -c fabric -s S -l setup -d "Run setup for all reconfigurable parts of fabric"
|
||||
@@ -98,4 +100,6 @@ complete -c fabric -l listextensions -d "List all registered extensions"
|
||||
complete -c fabric -l liststrategies -d "List all strategies"
|
||||
complete -c fabric -l listvendors -d "List all vendors"
|
||||
complete -c fabric -l shell-complete-list -d "Output raw list without headers/formatting (for shell completion)"
|
||||
complete -c fabric -l suppress-think -d "Suppress text enclosed in thinking tags"
|
||||
complete -c fabric -l disable-responses-api -d "Disable OpenAI Responses API (default: false)"
|
||||
complete -c fabric -s h -l help -d "Show this help message"
|
||||
|
||||
2345
coverage.out
2345
coverage.out
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user