Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/skills/bflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ bflow finish --abort # discard an in-progress release/hotfix finish

You may be on any branch when re-running (main, develop, a release branch); state is tracked in `.git/bflow-finish.state`. Use `bflow finish --abort` to discard the in-progress state if you want to bail out.

### PR templates

PR bodies resolve from `.github/pr-templates/bflow-<key>.md`, most-specific first:

1. Branch-specific: `bflow-<type>.md` (e.g. `bflow-release-fix.md`)
2. Group: the fix family (`fix`, `release-fix`, `hotfix-fix`) shares `bflow-fix.md`; other types' group == their own name
3. `bflow-default.md`
4. Repo's git default (`.github/PULL_REQUEST_TEMPLATE.md` etc.), else empty body

Opt-in: with no `.github/pr-templates/`, behavior is unchanged.

### Release-only commands

```bash
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,26 @@ On feature, fix, and refactor branches, `bflow finish` asks whether the work con

State is tracked in `.git/bflow-finish.state` so re-runs work even after HEAD has moved off the source branch during conflict resolution. Use `bflow finish --abort` to discard the in-progress state and start fresh.

#### PR templates

When `bflow finish` opens a PR, it picks the body template by branch type. Place templates in `.github/pr-templates/` named `bflow-<key>.md`. Resolution is most-specific first:

1. **Branch-specific** — `bflow-<type>.md` (e.g. `bflow-release-fix.md`)
2. **Group** — the fix family (`fix`, `release-fix`, `hotfix-fix`) shares `bflow-fix.md`; every other type's group equals its own name
3. **Default** — `bflow-default.md`
4. **Git default** — the repo's own `.github/PULL_REQUEST_TEMPLATE.md` (and the other paths `gh` recognizes), else an empty body

| File | Applies to |
|------|-----------|
| `bflow-feature.md` | `feature/*` |
| `bflow-fix.md` | the fix family — `fix/*`, `release-fix/*`, `hotfix-fix/*` (unless overridden below) |
| `bflow-release-fix.md` | only `release-fix/*` (overrides `bflow-fix.md`) |
| `bflow-hotfix-fix.md` | only `hotfix-fix/*` (overrides `bflow-fix.md`) |
| `bflow-chore.md` / `bflow-docs.md` / `bflow-refactor.md` | `chore/*` / `docs/*` / `refactor/*` |
| `bflow-default.md` | any PR with no more specific match |

The feature is opt-in: with no `.github/pr-templates/` directory, bflow falls back to the existing git default behavior unchanged.

### Release-only commands

```bash
Expand Down
25 changes: 18 additions & 7 deletions src/flows/finish_work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ use crate::git::branch::BranchType;
use crate::hosting::HostingPlatform;
use crate::menu;

fn push_and_create_pr(git: &dyn Git, hosting: &dyn HostingPlatform, base: &str, title: &str) -> Result<(), String> {
fn push_and_create_pr(git: &dyn Git, hosting: &dyn HostingPlatform, base: &str, title: &str, branch_type: &BranchType) -> Result<(), String> {
let current = git.current_branch()?;

println!("Pushing branch: {current}");
git.push(&current)?;

let template = crate::hosting::template::resolve(branch_type);
if let Some(path) = &template {
println!("Using PR template: {}", path.display());
}

println!("Creating PR: {title} → {base}");
let url = hosting.create_or_get_pr(&current, base, title)?;
let url = hosting.create_or_get_pr(&current, base, title, template.as_deref().and_then(|p| p.to_str()))?;
println!("PR: {url}");
hosting.open_url(&url)?;

Expand Down Expand Up @@ -92,7 +97,7 @@ pub fn finish_work_branch(git: &dyn Git, hosting: &dyn HostingPlatform, branch_t
format!("{commit_type}: {name}")
};

push_and_create_pr(git, hosting, &base, &title)
push_and_create_pr(git, hosting, &base, &title, branch_type)
}

fn commonly_breaking(commit_type: &str) -> bool {
Expand All @@ -104,10 +109,16 @@ fn prompt_breaking_change() -> Result<bool, String> {
Ok(idx == 1)
}

pub fn finish_release_fix(git: &dyn Git, hosting: &dyn HostingPlatform, major: u32, minor: u32, patch: u32, name: &str) -> Result<(), String> {
push_and_create_pr(git, hosting, &format!("release/{major}.{minor}.{patch}"), &format!("fix: {name}"))
pub fn finish_release_fix(git: &dyn Git, hosting: &dyn HostingPlatform, branch_type: &BranchType) -> Result<(), String> {
let BranchType::ReleaseFix { major, minor, patch, name } = branch_type else {
return Err("Cannot finish: not on a release-fix branch".to_string());
};
push_and_create_pr(git, hosting, &format!("release/{major}.{minor}.{patch}"), &format!("fix: {name}"), branch_type)
}

pub fn finish_hotfix_fix(git: &dyn Git, hosting: &dyn HostingPlatform, major: u32, minor: u32, patch: u32, name: &str) -> Result<(), String> {
push_and_create_pr(git, hosting, &format!("hotfix/{major}.{minor}.{patch}"), &format!("fix: {name}"))
pub fn finish_hotfix_fix(git: &dyn Git, hosting: &dyn HostingPlatform, branch_type: &BranchType) -> Result<(), String> {
let BranchType::HotfixFix { major, minor, patch, name } = branch_type else {
return Err("Cannot finish: not on a hotfix-fix branch".to_string());
};
push_and_create_pr(git, hosting, &format!("hotfix/{major}.{minor}.{patch}"), &format!("fix: {name}"), branch_type)
}
46 changes: 46 additions & 0 deletions src/git/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ impl BranchType {
matches!(self, Self::Feature { .. } | Self::Fix { .. } | Self::Chore { .. } | Self::Docs { .. } | Self::Refactor { .. })
}

/// PR-template lookup keys as `(specific, group)` for branch types that open a PR.
/// The fix family (`fix`, `release-fix`, `hotfix-fix`) shares the `fix` group; for
/// every other type the group equals the specific key. Returns `None` for branches
/// that never open a PR (main, develop, release, hotfix, other).
pub fn pr_template_keys(&self) -> Option<(&'static str, &'static str)> {
match self {
Self::Feature { .. } => Some(("feature", "feature")),
Self::Fix { .. } => Some(("fix", "fix")),
Self::Chore { .. } => Some(("chore", "chore")),
Self::Docs { .. } => Some(("docs", "docs")),
Self::Refactor { .. } => Some(("refactor", "refactor")),
Self::ReleaseFix { .. } => Some(("release-fix", "fix")),
Self::HotfixFix { .. } => Some(("hotfix-fix", "fix")),
_ => None,
}
}

fn new_feature(name: String) -> Self { Self::Feature { name } }
fn new_fix(name: String) -> Self { Self::Fix { name } }
fn new_chore(name: String) -> Self { Self::Chore { name } }
Expand All @@ -107,3 +124,32 @@ impl BranchType {
Some((parts[0].parse().ok()?, parts[1].parse().ok()?, parts[2].parse().ok()?))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn pr_template_keys_for_work_branches() {
assert_eq!(BranchType::parse("feature/x").pr_template_keys(), Some(("feature", "feature")));
assert_eq!(BranchType::parse("fix/x").pr_template_keys(), Some(("fix", "fix")));
assert_eq!(BranchType::parse("chore/x").pr_template_keys(), Some(("chore", "chore")));
assert_eq!(BranchType::parse("docs/x").pr_template_keys(), Some(("docs", "docs")));
assert_eq!(BranchType::parse("refactor/x").pr_template_keys(), Some(("refactor", "refactor")));
}

#[test]
fn fix_family_shares_fix_group() {
assert_eq!(BranchType::parse("release-fix/1.2.0/x").pr_template_keys(), Some(("release-fix", "fix")));
assert_eq!(BranchType::parse("hotfix-fix/1.2.0/x").pr_template_keys(), Some(("hotfix-fix", "fix")));
}

#[test]
fn pr_template_keys_none_for_non_pr_branches() {
assert_eq!(BranchType::parse("main").pr_template_keys(), None);
assert_eq!(BranchType::parse("develop").pr_template_keys(), None);
assert_eq!(BranchType::parse("release/1.2.0").pr_template_keys(), None);
assert_eq!(BranchType::parse("hotfix/1.2.0").pr_template_keys(), None);
assert_eq!(BranchType::parse("whatever").pr_template_keys(), None);
}
}
14 changes: 9 additions & 5 deletions src/hosting/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,27 @@ impl GitHub {
}

impl HostingPlatform for GitHub {
fn create_or_get_pr(&self, head: &str, base: &str, title: &str) -> Result<String> {
fn create_or_get_pr(&self, head: &str, base: &str, title: &str, template: Option<&str>) -> Result<String> {
let existing = self.run_gh(&["pr", "view", head, "--json", "url,state", "--jq", "select(.state == \"OPEN\") | .url"]);
if let Ok(url) = existing {
if !url.is_empty() { return Ok(url); }
}

let template_paths = [
// A bflow-resolved template (branch-specific/group/default) wins; otherwise fall
// back to the repository's own default PR template, then to an empty body.
let git_default_paths = [
".github/PULL_REQUEST_TEMPLATE.md",
".github/pull_request_template.md",
"PULL_REQUEST_TEMPLATE.md",
"pull_request_template.md",
"docs/pull_request_template.md",
];
let template = template_paths.iter().find(|p| std::path::Path::new(p).exists());
let body_file = template
.map(|p| p.to_string())
.or_else(|| git_default_paths.iter().find(|p| std::path::Path::new(p).exists()).map(|p| p.to_string()));

if let Some(path) = template {
self.run_gh(&["pr", "create", "--head", head, "--base", base, "--title", title, "--body-file", path])
if let Some(path) = body_file {
self.run_gh(&["pr", "create", "--head", head, "--base", base, "--title", title, "--body-file", &path])
} else {
self.run_gh(&["pr", "create", "--head", head, "--base", base, "--title", title, "--body", ""])
}
Expand Down
6 changes: 5 additions & 1 deletion src/hosting/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
pub mod github;
pub mod template;

pub type Result<T> = std::result::Result<T, String>;

pub trait HostingPlatform {
fn create_or_get_pr(&self, head: &str, base: &str, title: &str) -> Result<String>;
/// Create a PR (or return the URL of an existing open one). When `template` is
/// `Some`, its contents become the PR body; when `None`, the platform falls back to
/// the repository's own default template.
fn create_or_get_pr(&self, head: &str, base: &str, title: &str, template: Option<&str>) -> Result<String>;
fn open_url(&self, url: &str) -> Result<()>;
fn check_auth(&self) -> Result<()>;
}
115 changes: 115 additions & 0 deletions src/hosting/template.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! Branch-aware PR template resolution.
//!
//! bflow looks for templates in `.github/pr-templates/` named `bflow-<key>.md` and
//! resolves them most-specific first:
//! 1. `bflow-<specific>.md` (e.g. `bflow-release-fix.md`)
//! 2. `bflow-<group>.md` (e.g. `bflow-fix.md` for the fix family)
//! 3. `bflow-default.md`
//!
//! When none of these exist this returns `None` and the hosting layer falls back to the
//! repository's git/GitHub default template (or an empty body).

use std::path::{Path, PathBuf};
use crate::git::branch::BranchType;

const DIR: &str = ".github/pr-templates";

/// Resolve the PR template for `branch_type` against the conventional repo location.
pub fn resolve(branch_type: &BranchType) -> Option<PathBuf> {
resolve_in(Path::new(DIR), branch_type)
}

/// Resolution against an explicit directory — kept separate so tests can point at a
/// scratch dir without touching the process working directory.
fn resolve_in(dir: &Path, branch_type: &BranchType) -> Option<PathBuf> {
let (specific, group) = branch_type.pr_template_keys()?;
let mut keys = vec![specific];
if group != specific {
keys.push(group);
}
keys.push("default");
keys.into_iter()
.map(|k| dir.join(format!("bflow-{k}.md")))
.find(|p| p.exists())
}

#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};

static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);

fn tmp_dir() -> PathBuf {
let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = env::temp_dir().join(format!("bflow-template-test-{}-{n}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}

fn touch(dir: &Path, name: &str) {
fs::write(dir.join(name), "body").unwrap();
}

#[test]
fn specific_wins_over_group_and_default() {
let dir = tmp_dir();
touch(&dir, "bflow-release-fix.md");
touch(&dir, "bflow-fix.md");
touch(&dir, "bflow-default.md");
let bt = BranchType::parse("release-fix/1.2.0/foo");
assert_eq!(resolve_in(&dir, &bt), Some(dir.join("bflow-release-fix.md")));
fs::remove_dir_all(&dir).ok();
}

#[test]
fn group_wins_over_default_when_no_specific() {
let dir = tmp_dir();
touch(&dir, "bflow-fix.md");
touch(&dir, "bflow-default.md");
// release-fix has no specific file, so it falls back to the fix group.
let bt = BranchType::parse("release-fix/1.2.0/foo");
assert_eq!(resolve_in(&dir, &bt), Some(dir.join("bflow-fix.md")));
fs::remove_dir_all(&dir).ok();
}

#[test]
fn fix_family_maps_to_fix_group() {
let dir = tmp_dir();
touch(&dir, "bflow-fix.md");
for branch in ["fix/foo", "release-fix/1.2.0/foo", "hotfix-fix/1.2.0/foo"] {
let bt = BranchType::parse(branch);
assert_eq!(resolve_in(&dir, &bt), Some(dir.join("bflow-fix.md")), "branch={branch}");
}
fs::remove_dir_all(&dir).ok();
}

#[test]
fn falls_back_to_default() {
let dir = tmp_dir();
touch(&dir, "bflow-default.md");
let bt = BranchType::parse("feature/foo");
assert_eq!(resolve_in(&dir, &bt), Some(dir.join("bflow-default.md")));
fs::remove_dir_all(&dir).ok();
}

#[test]
fn none_when_no_files() {
let dir = tmp_dir();
let bt = BranchType::parse("feature/foo");
assert_eq!(resolve_in(&dir, &bt), None);
fs::remove_dir_all(&dir).ok();
}

#[test]
fn none_for_non_pr_branch_even_with_default() {
let dir = tmp_dir();
touch(&dir, "bflow-default.md");
// release branches never open a PR — no template key, so no resolution.
let bt = BranchType::parse("release/1.2.0");
assert_eq!(resolve_in(&dir, &bt), None);
fs::remove_dir_all(&dir).ok();
}
}
10 changes: 2 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,10 @@ fn run_flow(
finish_work::finish_work_branch(git, hosting, branch_type, *breaking)?;
}
Action::FinishReleaseFix => {
let BranchType::ReleaseFix { major, minor, patch, name, .. } = branch_type else {
unreachable!("FinishReleaseFix action only from ReleaseFix branch");
};
finish_work::finish_release_fix(git, hosting, *major, *minor, *patch, name)?;
finish_work::finish_release_fix(git, hosting, branch_type)?;
}
Action::FinishHotfixFix => {
let BranchType::HotfixFix { major, minor, patch, name, .. } = branch_type else {
unreachable!("FinishHotfixFix action only from HotfixFix branch");
};
finish_work::finish_hotfix_fix(git, hosting, *major, *minor, *patch, name)?;
finish_work::finish_hotfix_fix(git, hosting, branch_type)?;
}
Action::BumpVersion => {
let BranchType::Release { major, minor, .. } = branch_type else {
Expand Down
5 changes: 3 additions & 2 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,9 @@ impl MockHosting {
}

impl HostingPlatform for MockHosting {
fn create_or_get_pr(&self, head: &str, base: &str, title: &str) -> Result<String, String> {
self.calls.borrow_mut().push(format!("create_or_get_pr:{head}:{base}:{title}"));
fn create_or_get_pr(&self, head: &str, base: &str, title: &str, template: Option<&str>) -> Result<String, String> {
let suffix = template.map(|t| format!(":template={t}")).unwrap_or_default();
self.calls.borrow_mut().push(format!("create_or_get_pr:{head}:{base}:{title}{suffix}"));
Ok(self.pr_url.clone())
}

Expand Down
9 changes: 6 additions & 3 deletions tests/finish_work_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ fn finish_release_fix_pushes_and_creates_pr() {
let mut git = MockGit::new();
git.current_branch = "release-fix/1.1.0/login-bug".to_string();
let hosting = MockHosting::new();
let branch_type = BranchType::ReleaseFix { major: 1, minor: 1, patch: 0, name: "login-bug".to_string() };

finish_release_fix(&git, &hosting, 1, 1, 0, "login-bug").unwrap();
finish_release_fix(&git, &hosting, &branch_type).unwrap();

assert_eq!(git.calls(), vec![
"current_branch",
Expand All @@ -28,8 +29,9 @@ fn finish_hotfix_fix_pushes_and_creates_pr() {
let mut git = MockGit::new();
git.current_branch = "hotfix-fix/1.0.1/crash-fix".to_string();
let hosting = MockHosting::new();
let branch_type = BranchType::HotfixFix { major: 1, minor: 0, patch: 1, name: "crash-fix".to_string() };

finish_hotfix_fix(&git, &hosting, 1, 0, 1, "crash-fix").unwrap();
finish_hotfix_fix(&git, &hosting, &branch_type).unwrap();

assert_eq!(git.calls(), vec![
"current_branch",
Expand All @@ -48,8 +50,9 @@ fn finish_release_fix_with_custom_pr_url() {
git.current_branch = "release-fix/2.0.0/typo".to_string();
let mut hosting = MockHosting::new();
hosting.pr_url = "https://github.com/org/repo/pull/42".to_string();
let branch_type = BranchType::ReleaseFix { major: 2, minor: 0, patch: 0, name: "typo".to_string() };

finish_release_fix(&git, &hosting, 2, 0, 0, "typo").unwrap();
finish_release_fix(&git, &hosting, &branch_type).unwrap();

assert_eq!(hosting.calls(), vec![
"create_or_get_pr:release-fix/2.0.0/typo:release/2.0.0:fix: typo",
Expand Down
Loading