Skip to content

feat: add support for GitHub-style release notes#1804

Open
DaleSeo wants to merge 4 commits intoknope-dev:mainfrom
DaleSeo:gh-release-notes
Open

feat: add support for GitHub-style release notes#1804
DaleSeo wants to merge 4 commits intoknope-dev:mainfrom
DaleSeo:gh-release-notes

Conversation

@DaleSeo
Copy link
Copy Markdown
Contributor

@DaleSeo DaleSeo commented Feb 27, 2026

Enable users to generate release notes that match GitHub's auto-generated format — entries like * Add dark mode by @DaleSeo in #42 — by fetching PR metadata from the GitHub API during PrepareRelease.

Summary

  • Introduce two new change_template variables: $pr_number and $commit_author_login
  • Add a GitHub API integration that fetches PR data per commit SHA
  • Make enrichment lazy/opt-in: API calls only happen when templates reference the new variables and [github] is configured
  • Update reference docs and add a "GitHub-style release notes" recipe

Motivation

Knope already supports $commit_author_name and $commit_hash in change_templates, but there's no way to include PR numbers or GitHub usernames — the two pieces that make GitHub's default release notes so useful for attributing contributions and linking back to discussions. This bridges that gap while keeping the feature zero-cost for users who don't need it.

Design decisions

Lazy / opt-in enrichment — GitHub API calls (authenticated, rate-limited, network-dependent) are only triggered when a user explicitly includes $pr_number or $commit_author_login in their change_templates AND has a [github] config block. Existing users are completely unaffected.

Graceful fallback — If the API call fails or a commit has no associated PR, the template is skipped and Knope falls back to the next template in the list. Failures are logged at warn level but never block the release.

No impact on document-change — PR numbers don't exist when a change file is first created. Enrichment happens at PrepareRelease time, after the change file's commit has been merged via a PR.

Changed files

File What changed
crates/knope-versioning/src/changes/mod.rs Added pr_number and author_login fields to GitInfo
crates/knope-versioning/src/release_notes/mod.rs Template rendering for new variables, needs_forge_data() detection, tests
crates/knope/src/integrations/github/pr_info.rs New — GitHub API call to fetch PR info per commit SHA
crates/knope/src/integrations/github/mod.rs Re-export enrich_git_info
crates/knope/src/integrations/git.rs Initialize new GitInfo fields to None
crates/knope/src/step/releases/package.rs Accept enrich_git_info closure in prepare_release
crates/knope/src/step/releases/mod.rs Construct and pass enrichment closure, needs_forge_data check
docs/.../release-notes.mdx Reference docs for $pr_number and $commit_author_login
docs/.../customizing-release-notes.mdx Recipe: "GitHub-style release notes" with example config

Example config

[github]
owner = "my-org"
repo = "my-repo"

[release_notes]
change_templates = [
    "* $summary by @$commit_author_login in #$pr_number",
    "* $summary by @$commit_author_login",
    "* $summary",
]

Test plan

  • Existing unit tests pass (258 total, 0 failures)
  • cargo clippy --workspace clean
  • New unit tests for needs_forge_data() detection (4 cases: local-only, pr_number, author_login, default)
  • New unit test for full GitHub-style template rendering with mixed data availability
  • Manual test with a real repo and GITHUB_TOKEN to verify API enrichment end-to-end

@netlify
Copy link
Copy Markdown

netlify Bot commented Feb 27, 2026

Deploy Preview for knope ready!

Name Link
🔨 Latest commit f962bc8
🔍 Latest deploy log https://app.netlify.com/projects/knope/deploys/69a20943ba217200085fdb23
😎 Deploy Preview https://deploy-preview-1804--knope.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@DaleSeo DaleSeo force-pushed the gh-release-notes branch 2 times, most recently from 6d4f3d7 to f962bc8 Compare February 27, 2026 21:14
Comment thread .cursor/.gitignore
@@ -0,0 +1 @@
plans/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ignoring the entire .cursor directory from the top-level .gitignore would be better—just so we don't permanently have a .cursor directory now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I didn't mean to include this file in the PR. I've added it to .git/info/exclude so it won't be tracked for me.

Comment on lines +33 to +57
let indices: Vec<usize> = changes
.iter()
.enumerate()
.filter_map(|(i, change)| change.git.as_ref().map(|_| i))
.collect();

if indices.is_empty() {
return Ok(github_state);
}

let (token, agent) = initialize_state(github_state)?;
let authorization = format!("token {token}");

for idx in indices {
let short_hash = match changes.get(idx).and_then(|c| c.git.as_ref()) {
Some(g) => g.hash.clone(),
None => continue,
};
match fetch_pr_for_commit(&agent, &authorization, github_config, &short_hash) {
Ok(Some((pr_number, author_login))) => {
if let Some(info) = changes.get_mut(idx).and_then(|c| c.git.as_mut()) {
info.pr_number = Some(pr_number);
info.author_login = Some(author_login);
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little simpler / more idiomatic. Also, though, I'd expect it should be Bearer not token—have you tried it yet?

Suggested change
let indices: Vec<usize> = changes
.iter()
.enumerate()
.filter_map(|(i, change)| change.git.as_ref().map(|_| i))
.collect();
if indices.is_empty() {
return Ok(github_state);
}
let (token, agent) = initialize_state(github_state)?;
let authorization = format!("token {token}");
for idx in indices {
let short_hash = match changes.get(idx).and_then(|c| c.git.as_ref()) {
Some(g) => g.hash.clone(),
None => continue,
};
match fetch_pr_for_commit(&agent, &authorization, github_config, &short_hash) {
Ok(Some((pr_number, author_login))) => {
if let Some(info) = changes.get_mut(idx).and_then(|c| c.git.as_mut()) {
info.pr_number = Some(pr_number);
info.author_login = Some(author_login);
}
}
let (token, agent) = initialize_state(github_state)?;
let authorization = format!("token {token}");
for git_info in changes.iter_mut().filter_map(|change| change.git.as_mut()) {
let short_hash = &git_info.hash;
match fetch_pr_for_commit(&agent, &authorization, github_config, short_hash) {
Ok(Some((pr_number, author_login))) => {
git_info.pr_number = Some(pr_number);
git_info.author_login = Some(author_login);
}

Copy link
Copy Markdown
Contributor Author

@DaleSeo DaleSeo Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both work:

$ TOKEN=$(gh auth token)

$ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $TOKEN" \
  "https://api.github.com/repos/knope-dev/knope/commits/70c183b/pulls"
200%

$ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" \
  "https://api.github.com/repos/knope-dev/knope/commits/70c183b/pulls"
200%

Per the GitHub docs, both token and Bearer are accepted.

2026-03-21 at 17 17 32

Switched to Bearer though because it's more commonly used and also supports JWTs.

Comment thread crates/knope/src/step/releases/mod.rs Outdated
state.all_versioned_files,
&changeset,
state.ignore_conventional_commits,
|changes| {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to move this logic into prepare_release instead of passing a closure?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason. Moved it. Split prepare_release into gather_changes + apply_release to make it work.

Copy link
Copy Markdown
Contributor Author

@DaleSeo DaleSeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback, @dbanty!

Comment thread .cursor/.gitignore
@@ -0,0 +1 @@
plans/
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I didn't mean to include this file in the PR. I've added it to .git/info/exclude so it won't be tracked for me.

Comment thread crates/knope/src/step/releases/mod.rs Outdated
state.all_versioned_files,
&changeset,
state.ignore_conventional_commits,
|changes| {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason. Moved it. Split prepare_release into gather_changes + apply_release to make it work.

Comment on lines +33 to +57
let indices: Vec<usize> = changes
.iter()
.enumerate()
.filter_map(|(i, change)| change.git.as_ref().map(|_| i))
.collect();

if indices.is_empty() {
return Ok(github_state);
}

let (token, agent) = initialize_state(github_state)?;
let authorization = format!("token {token}");

for idx in indices {
let short_hash = match changes.get(idx).and_then(|c| c.git.as_ref()) {
Some(g) => g.hash.clone(),
None => continue,
};
match fetch_pr_for_commit(&agent, &authorization, github_config, &short_hash) {
Ok(Some((pr_number, author_login))) => {
if let Some(info) = changes.get_mut(idx).and_then(|c| c.git.as_mut()) {
info.pr_number = Some(pr_number);
info.author_login = Some(author_login);
}
}
Copy link
Copy Markdown
Contributor Author

@DaleSeo DaleSeo Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both work:

$ TOKEN=$(gh auth token)

$ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $TOKEN" \
  "https://api.github.com/repos/knope-dev/knope/commits/70c183b/pulls"
200%

$ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" \
  "https://api.github.com/repos/knope-dev/knope/commits/70c183b/pulls"
200%

Per the GitHub docs, both token and Bearer are accepted.

2026-03-21 at 17 17 32

Switched to Bearer though because it's more commonly used and also supports JWTs.

@DaleSeo DaleSeo marked this pull request as ready for review March 21, 2026 21:45
@DaleSeo DaleSeo requested a review from dbanty March 21, 2026 21:45
@DaleSeo
Copy link
Copy Markdown
Contributor Author

DaleSeo commented Apr 1, 2026

Hey @dbanty, how's it going? 👋 Can you take another look at this? 🙏

@dbanty
Copy link
Copy Markdown
Member

dbanty commented Apr 1, 2026

Hey @DaleSeo! I haven't forgotten, I just realized a few things about this I want to set up for first:

  1. Fetching these one by one could be really slow, so I want to be able to batch requests. I'm asyncifying over in chore: Convert to Tokio + Reqwest #1868 to make that easy
  2. It's gonna be really easy to hit rate limits with this, so I'm going to set up support for 429/Retry-After. There's a pretty easy way to do this with reqwest middleware once step 1 is done
  3. We actually don't have to require GitHub tokens for this (for when using with public repos) but we should definitely solve 2 before removing that requirement. It's always annoyed me that I have to set a github token for the node changeset CLI so it'll be nice to ease that

@DaleSeo
Copy link
Copy Markdown
Contributor Author

DaleSeo commented Apr 10, 2026

Thanks for the heads-up, @dbanty! The async and rate limit work sounds great. I can rebase once that PR lands. Just let me know if there's anything I can help with in the meantime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants