diff --git a/README.md b/README.md index 78cee2d..9067db2 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` ```bash # Generate commit message for the last commit fastcommit -r HEAD~1 - + # Generate commit message for a range of commits fastcommit -r abc123..def456 ``` @@ -96,10 +96,10 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` ```bash # Disable text wrapping fastcommit --no-wrap - + # Set custom line width fastcommit --wrap-width 60 - + # Combine with other options fastcommit -b -m --wrap-width 100 ``` @@ -114,9 +114,49 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` fastcommit -c --commit-args "-s" --commit-args "--no-verify" ``` +## GitHub PR Integration + +`fastcommit` can generate commit messages for GitHub Pull Requests, which is useful when merging PRs. + +### Prerequisites + +- [GitHub CLI (`gh`)](https://cli.github.com/) must be installed and authenticated + +### Usage + +```bash +# Auto-detect PR from current branch +fastcommit pr + +# Generate commit message for a specific PR +fastcommit pr 123 + +# Specify repository (when not in a git directory) +fastcommit pr 123 --repo owner/repo + +# Use conventional commit style +fastcommit pr 123 --conventional true + +# Specify language +fastcommit pr 123 -l zh +``` + +### PR Command Options + +- `[PR_NUMBER]`: PR number to generate commit message for. If not specified, auto-detects from current branch. +- `--repo `: Specify repository in `owner/repo` format. +- `--conventional `: Enable conventional commit style. +- `-l, --language `: Specify language (`en` or `zh`). +- `-v, --verbosity `: Set detail level (`verbose`, `normal`, `quiet`). +- `-p, --prompt `: Additional context for AI. +- `--no-sanitize`: Disable sensitive info sanitizer. +- `--no-wrap`: Disable text wrapping. + +For more details, see [GitHub PR Integration Guide](docs/github-pr-integration.md). + ## Contributing - Contributions of code or suggestions are welcome! Please read the [Contributing Guide](CONTRIBUTING.md) first. +Contributions of code or suggestions are welcome! Please read the [Contributing Guide](CONTRIBUTING.md) first. ## License diff --git a/README_CN.md b/README_CN.md index 6a560ea..0cb5076 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,7 +17,7 @@ cargo install --git https://github.com/fslongjin/fastcommit --tag v0.6.0 ```bash git add . -fastcommit +fastcommit ``` ### 选项 @@ -29,7 +29,7 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` - `-l, --language `: 指定提交信息的语言。可选值为 `en`(英文)或 `zh`(中文)。 - `-b, --generate-branch`: 生成分支名 - `--branch-prefix`: 生成的分支名的前缀 -- `-m, --message`: 生成提交信息(与 -b 一起使用可同时输出) +- `-m, --message`: 生成提交信息(与 -b 一起使用可同时输出) - `-v, --verbosity `: 设置提交信息的详细级别。可选值为 `verbose`(详细)、`normal`(正常)或 `quiet`(简洁)。 默认为 `quiet`。 - `-p, --prompt `: 额外的提示信息,帮助 AI 理解提交上下文。 - `-r, --range `: 指定差异范围以生成提交信息(例如:HEAD~1, abc123..def456)。 @@ -83,7 +83,7 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` ```bash # 为最近一次提交生成提交信息 fastcommit -r HEAD~1 - + # 为指定提交范围生成提交信息 fastcommit -r abc123..def456 ``` @@ -93,10 +93,10 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` ```bash # 禁用文本换行 fastcommit --no-wrap - + # 设置自定义行宽度 fastcommit --wrap-width 60 - + # 与其他选项组合使用 fastcommit -b -m --wrap-width 100 ``` @@ -111,6 +111,46 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` fastcommit -c --commit-args "-s" --commit-args "--no-verify" ``` +## GitHub PR 集成 + +`fastcommit` 可以为 GitHub Pull Request 生成提交信息,适用于合并 PR 时使用。 + +### 前置条件 + +- 需要安装并登录 [GitHub CLI (`gh`)](https://cli.github.com/) + +### 使用方法 + +```bash +# 自动检测当前分支关联的 PR +fastcommit pr + +# 为指定 PR 生成提交信息 +fastcommit pr 123 + +# 指定仓库(不在 git 目录中时) +fastcommit pr 123 --repo owner/repo + +# 使用约定式提交风格 +fastcommit pr 123 --conventional true + +# 指定语言 +fastcommit pr 123 -l zh +``` + +### PR 命令选项 + +- `[PR_NUMBER]`: PR 编号,不指定则自动检测当前分支关联的 PR +- `--repo `: 指定仓库,格式为 `owner/repo` +- `--conventional `: 启用约定式提交风格 +- `-l, --language `: 指定语言(`en` 或 `zh`) +- `-v, --verbosity `: 设置详细级别(`verbose`、`normal`、`quiet`) +- `-p, --prompt `: 额外的提示信息 +- `--no-sanitize`: 禁用敏感信息清理 +- `--no-wrap`: 禁用文本换行 + +更多详情请参阅 [GitHub PR 集成指南](docs/github-pr-integration.md)。 + ## 贡献 欢迎贡献代码或提出建议!请先阅读 [贡献指南](CONTRIBUTING.md)。 diff --git a/docs/github-pr-integration.md b/docs/github-pr-integration.md new file mode 100644 index 0000000..51fc687 --- /dev/null +++ b/docs/github-pr-integration.md @@ -0,0 +1,191 @@ +# GitHub PR Integration + +This document describes how to use `fastcommit` to generate commit messages for GitHub Pull Requests. + +## Overview + +The `fastcommit pr` command allows you to generate a commit message for an entire Pull Request. This is particularly useful when you're ready to merge a PR and need a comprehensive commit message that summarizes all the changes. + +## Prerequisites + +Before using the PR integration, ensure you have: + +1. **GitHub CLI (`gh`) installed** + + ```bash + # Check if gh is installed + gh --version + + # Install gh on macOS + brew install gh + + # Install gh on Linux + # See: https://github.com/cli/cli/blob/trunk/docs/install_linux.md + ``` + +2. **GitHub CLI authenticated** + + ```bash + gh auth login + ``` + +## Usage + +### Basic Usage + +```bash +# Auto-detect PR from current branch +fastcommit pr + +# Generate commit message for a specific PR +fastcommit pr 123 +``` + +### Specify Repository + +When running `fastcommit pr` outside the target repository, use the `--repo` flag: + +```bash +fastcommit pr 123 --repo owner/repo +``` + +### All Options + +``` +fastcommit pr [PR_NUMBER] [OPTIONS] + +Arguments: + [PR_NUMBER] PR number to generate commit message for. + If not specified, auto-detects from current branch. + +Options: + --repo Specify repository (format: owner/repo) + --conventional Enable conventional commit style (true/false) + -l, --language Specify language (en/zh) + -v, --verbosity Set detail level (verbose/normal/quiet) + -p, --prompt Additional context for AI + --no-sanitize Disable sensitive info sanitizer + --no-wrap Disable text wrapping + --wrap-width Set custom line width for wrapping +``` + +## Examples + +### Generate commit message for current branch's PR + +```bash +# Assuming you're on a branch with an open PR +fastcommit pr +``` + +Output: +``` +feat: add user authentication system + +- Implement JWT-based authentication +- Add login/logout endpoints +- Integrate with existing user service +- Add rate limiting for auth endpoints +``` + +### Generate with conventional commit style + +```bash +fastcommit pr 456 --conventional true +``` + +Output: +``` +fix(auth): resolve token refresh issue + +- Fix improper token validation on refresh +- Add proper error handling for expired tokens +- Update token storage to use secure cookies +``` + +### Add context for better results + +```bash +fastcommit pr 789 -p "This PR fixes the performance issues reported in issue #123" +``` + +### Generate in Chinese + +```bash +fastcommit pr 123 -l zh +``` + +Output: +``` +feat: 添加用户认证系统 + +- 实现 JWT 认证机制 +- 添加登录/登出接口 +- 与现有用户服务集成 +- 添加认证接口的速率限制 +``` + +## How It Works + +1. **PR Detection**: If no PR number is specified, `fastcommit` uses `gh pr view` to detect the PR associated with the current branch. + +2. **Diff Retrieval**: The tool fetches the PR diff using `gh pr diff`. + +3. **Message Generation**: The diff is processed by the AI to generate a commit message, using the same logic as the standard `fastcommit` command. + +## Troubleshooting + +### "GitHub CLI (gh) is not installed" + +Install GitHub CLI: +- **macOS**: `brew install gh` +- **Linux**: See [installation guide](https://github.com/cli/cli/blob/trunk/docs/install_linux.md) +- **Windows**: `winget install GitHub.cli` + +### "Failed to detect current PR" + +This happens when: +- You're not on a branch with an open PR +- The PR is in a different repository + +**Solution**: Specify the PR number explicitly: +```bash +fastcommit pr 123 +``` + +### "gh pr diff failed" + +This usually indicates: +- You're not authenticated with GitHub CLI + +**Solution**: Run `gh auth login` + +### "No diff found for PR" + +This happens when the PR has no changes (empty PR). + +## Tips + +1. **Use with PR merge**: Generate the commit message before merging: + ```bash + # Generate the message + fastcommit pr 123 + + # Then merge with the generated message + gh pr merge 123 --merge + ``` + +2. **Combine with conventional commits**: For projects following conventional commit conventions: + ```bash + fastcommit pr 123 --conventional true + ``` + +3. **Add merge context**: Provide additional context about the PR's purpose: + ```bash + fastcommit pr 123 -p "Closes #456, implements feature requested by users" + ``` + +## Related Documentation + +- [README.md](../README.md) - Main documentation +- [GitHub CLI Documentation](https://cli.github.com/manual/) diff --git a/src/cli.rs b/src/cli.rs index c841a76..19059ca 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::Parser; +use clap::{Parser, Subcommand}; use crate::config::{CommitLanguage, Verbosity}; @@ -11,9 +11,28 @@ use crate::config::{CommitLanguage, Verbosity}; ) )] pub struct Args { - #[clap(short, long, help = "Path to the file containing the diff to analyze")] - pub diff_file: Option, + #[clap(subcommand)] + pub command: Option, +} +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Generate commit message for staged changes (default behavior) + Commit(CommitArgs), + + /// Generate commit message for a GitHub PR + Pr(PrArgs), +} + +impl Default for Commands { + fn default() -> Self { + Commands::Commit(CommitArgs::default()) + } +} + +/// Common arguments shared by commit and pr commands +#[derive(Parser, Debug, Default)] +pub struct CommonArgs { #[clap(long, help = "Enable conventional commit style analysis")] pub conventional: Option, @@ -23,16 +42,6 @@ pub struct Args { #[clap(short, long, help = "Set the verbosity level")] pub verbosity: Option, - #[clap( - long = "generate-branch", - short = 'b', - help = "Generate a branch name based on changes (optionally with prefix)" - )] - pub generate_branch: bool, - - #[clap(long, help = "Override branch prefix (default from config)")] - pub branch_prefix: Option, - #[clap( short, long, @@ -40,20 +49,6 @@ pub struct Args { )] pub prompt: Option, - #[clap( - short = 'r', - long, - help = "Specify diff range (e.g. HEAD~1, abc123..def456)" - )] - pub range: Option, - - #[clap( - short = 'm', - long = "message", - help = "Generate commit message (use with -b to output both)" - )] - pub generate_message: bool, - #[clap( long = "no-sanitize", help = "Temporarily disable sensitive info sanitizer for this run" @@ -84,3 +79,50 @@ pub struct Args { )] pub commit_args: Vec, } + +#[derive(Parser, Debug, Default)] +pub struct CommitArgs { + #[clap(short, long, help = "Path to the file containing the diff to analyze")] + pub diff_file: Option, + + #[clap( + long = "generate-branch", + short = 'b', + help = "Generate a branch name based on changes (optionally with prefix)" + )] + pub generate_branch: bool, + + #[clap(long, help = "Override branch prefix (default from config)")] + pub branch_prefix: Option, + + #[clap( + short = 'r', + long, + help = "Specify diff range (e.g. HEAD~1, abc123..def456)" + )] + pub range: Option, + + #[clap( + short = 'm', + long = "message", + help = "Generate commit message (use with -b to output both)" + )] + pub generate_message: bool, + + #[clap(flatten)] + pub common: CommonArgs, +} + +#[derive(Parser, Debug)] +pub struct PrArgs { + /// PR number, auto-detect from current branch if not specified + #[clap(name = "PR_NUMBER")] + pub pr_number: Option, + + /// Specify repository (format: owner/repo) + #[clap(long)] + pub repo: Option, + + #[clap(flatten)] + pub common: CommonArgs, +} diff --git a/src/generate.rs b/src/generate.rs index 85ea5a7..2e95f2a 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -10,7 +10,7 @@ use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_OPENAI_MODEL, DEFAULT_PROMPT_ use crate::sanitizer::sanitize_with_config; use crate::template_engine::{render_template, TemplateContext}; -async fn generate_commit_message( +pub async fn generate_commit_message( diff: &str, config: &config::Config, user_description: Option<&str>, @@ -162,9 +162,9 @@ fn get_diff(diff_file: Option<&str>, range: Option<&str>) -> anyhow::Result anyhow::Result { +pub async fn generate(args: &cli::CommitArgs, config: &Config) -> anyhow::Result { let diff = get_diff(args.diff_file.as_deref(), args.range.as_deref())?; - let message = generate_commit_message(&diff, config, args.prompt.as_deref()).await?; + let message = generate_commit_message(&diff, config, args.common.prompt.as_deref()).await?; Ok(message) } @@ -245,7 +245,7 @@ async fn generate_branch_name_with_ai( Ok(branch_name) } -pub async fn generate_branch(args: &cli::Args, config: &Config) -> anyhow::Result { +pub async fn generate_branch(args: &cli::CommitArgs, config: &Config) -> anyhow::Result { let diff = get_diff(args.diff_file.as_deref(), args.range.as_deref())?; let prefix = args .branch_prefix @@ -255,14 +255,18 @@ pub async fn generate_branch(args: &cli::Args, config: &Config) -> anyhow::Resul Ok(branch_name) } -pub async fn generate_both(args: &cli::Args, config: &Config) -> anyhow::Result<(String, String)> { +pub async fn generate_both( + args: &cli::CommitArgs, + config: &Config, +) -> anyhow::Result<(String, String)> { let diff = get_diff(args.diff_file.as_deref(), args.range.as_deref())?; let prefix = args .branch_prefix .as_deref() .or(config.branch_prefix.as_deref()); let branch_name = generate_branch_name_with_ai(&diff, prefix, config).await?; - let commit_message = generate_commit_message(&diff, config, args.prompt.as_deref()).await?; + let commit_message = + generate_commit_message(&diff, config, args.common.prompt.as_deref()).await?; Ok((branch_name, commit_message)) } diff --git a/src/main.rs b/src/main.rs index b612515..a114e12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod cli; mod config; mod constants; mod generate; +mod pr; mod sanitizer; mod template_engine; mod text_wrapper; @@ -23,17 +24,31 @@ async fn main() -> anyhow::Result<()> { let args = cli::Args::parse(); let mut config = config::load_config().await?; + // Handle subcommands + match args.command.unwrap_or_default() { + cli::Commands::Commit(commit_args) => { + handle_commit_command(&commit_args, &mut config, &spinner).await + } + cli::Commands::Pr(pr_args) => handle_pr_command(&pr_args, &mut config, &spinner).await, + } +} + +async fn handle_commit_command( + args: &cli::CommitArgs, + config: &mut config::Config, + spinner: &animation::Spinner, +) -> anyhow::Result<()> { // 合并命令行参数和配置文件 - if let Some(c) = args.conventional { + if let Some(c) = args.common.conventional { config.conventional = c; } - if let Some(l) = args.language { + if let Some(l) = args.common.language { config.language = l; } - if let Some(v) = args.verbosity { + if let Some(v) = args.common.verbosity { config.verbosity = v; } - if args.no_sanitize { + if args.common.no_sanitize { // CLI override to disable sanitizer config.sanitize_secrets = false; } @@ -47,12 +62,12 @@ async fn main() -> anyhow::Result<()> { }; // 确定是否启用文本包装 (CLI 参数优先级高于配置) - let enable_wrapping = !args.no_wrap && config.text_wrap.enabled; + let enable_wrapping = !args.common.no_wrap && config.text_wrap.enabled; // 预创建统一的包装配置和包装器 (如果需要) let wrapper = if enable_wrapping { let wrap_config = - WrapConfig::from_config_and_args(&config.text_wrap, args.wrap_width, false); + WrapConfig::from_config_and_args(&config.text_wrap, args.common.wrap_width, false); Some(TextWrapper::new(wrap_config)) } else { None @@ -63,7 +78,7 @@ async fn main() -> anyhow::Result<()> { // 创建提交消息专用的包装器(启用段落保留) let commit_wrapper = if enable_wrapping { let wrap_config = - WrapConfig::from_config_and_args(&config.text_wrap, args.wrap_width, true); + WrapConfig::from_config_and_args(&config.text_wrap, args.common.wrap_width, true); Some(TextWrapper::new(wrap_config)) } else { None @@ -72,7 +87,7 @@ async fn main() -> anyhow::Result<()> { // 根据参数决定生成内容 if args.generate_branch && args.generate_message { // 生成分支名 + 提交信息 - let (branch_name, msg) = generate::generate_both(&args, &config).await?; + let (branch_name, msg) = generate::generate_both(args, config).await?; spinner.finish(); print_wrapped_content(&wrapper, &branch_name, Some("Generated branch name:")); print_wrapped_content(&commit_wrapper, &msg, None); @@ -81,12 +96,12 @@ async fn main() -> anyhow::Result<()> { } } else if args.generate_branch { // 仅生成分支名 - let branch_name = generate::generate_branch(&args, &config).await?; + let branch_name = generate::generate_branch(args, config).await?; spinner.finish(); print_wrapped_content(&wrapper, &branch_name, Some("Generated branch name:")); } else { // 仅生成提交信息(默认行为) - let msg = generate::generate(&args, &config).await?; + let msg = generate::generate(args, config).await?; spinner.finish(); print_wrapped_content(&commit_wrapper, &msg, None); if auto_commit { @@ -96,6 +111,43 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +async fn handle_pr_command( + args: &cli::PrArgs, + config: &mut config::Config, + spinner: &animation::Spinner, +) -> anyhow::Result<()> { + // 合并命令行参数和配置文件 + if let Some(c) = args.common.conventional { + config.conventional = c; + } + if let Some(l) = args.common.language { + config.language = l; + } + if let Some(v) = args.common.verbosity { + config.verbosity = v; + } + if args.common.no_sanitize { + config.sanitize_secrets = false; + } + + // 确定是否启用文本包装 + let enable_wrapping = !args.common.no_wrap && config.text_wrap.enabled; + let commit_wrapper = if enable_wrapping { + let wrap_config = + WrapConfig::from_config_and_args(&config.text_wrap, args.common.wrap_width, true); + Some(TextWrapper::new(wrap_config)) + } else { + None + }; + + // Generate PR commit message + let msg = pr::generate_pr_message(args, config).await?; + spinner.finish(); + print_wrapped_content(&commit_wrapper, &msg, None); + + Ok(()) +} + fn print_wrapped_content(wrapper: &Option, content: &str, prefix: Option<&str>) { if let Some(wrapper) = wrapper { if let Some(p) = prefix { diff --git a/src/pr.rs b/src/pr.rs new file mode 100644 index 0000000..7d32848 --- /dev/null +++ b/src/pr.rs @@ -0,0 +1,99 @@ +use std::process::Command; + +use crate::cli::PrArgs; +use crate::config::Config; +use crate::generate::generate_commit_message; + +/// Get PR diff using gh CLI +fn get_pr_diff_from_gh(pr_number: Option, repo: Option<&str>) -> anyhow::Result { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "diff"]); + + if let Some(num) = pr_number { + cmd.arg(num.to_string()); + } + if let Some(r) = repo { + cmd.args(["--repo", r]); + } + + let output = cmd.output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("gh pr diff failed: {}", stderr.trim())); + } + + let diff = String::from_utf8_lossy(&output.stdout).into_owned(); + if diff.trim().is_empty() { + return Err(anyhow::anyhow!("No diff found for PR")); + } + + Ok(diff) +} + +/// Detect current branch's associated PR number +fn detect_current_pr(repo: Option<&str>) -> anyhow::Result { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "view", "--json", "number"]); + + if let Some(r) = repo { + cmd.args(["--repo", r]); + } + + let output = cmd.output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to detect current PR. Please specify PR number explicitly. Error: {}", + stderr.trim() + )); + } + + let json_output = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&json_output) + .map_err(|e| anyhow::anyhow!("Failed to parse gh output: {}", e))?; + + let pr_number = parsed["number"] + .as_u64() + .ok_or(anyhow::anyhow!("Could not find PR number in gh output"))?; + + Ok(pr_number as u32) +} + +/// Check if gh CLI is available +fn is_gh_available() -> bool { + Command::new("gh") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Generate commit message for a PR +pub async fn generate_pr_message(args: &PrArgs, config: &Config) -> anyhow::Result { + // Check if gh is available + if !is_gh_available() { + return Err(anyhow::anyhow!( + "GitHub CLI (gh) is not installed or not in PATH. Please install it from https://cli.github.com/" + )); + } + + // Detect PR number if not specified + let pr_number = match args.pr_number { + Some(num) => num, + None => detect_current_pr(args.repo.as_deref())?, + }; + + log::info!("Getting diff for PR #{}...", pr_number); + + // Get PR diff + let diff = get_pr_diff_from_gh(Some(pr_number), args.repo.as_deref())?; + + log::info!("Generating commit message..."); + + // Generate commit message using existing logic + let message = generate_commit_message(&diff, config, args.common.prompt.as_deref()).await?; + + Ok(message) +}