From 7c6392ce13e42cff4658c2aa8779df0897e0424d Mon Sep 17 00:00:00 2001 From: Gregorios Leach Date: Tue, 28 Apr 2026 23:19:32 -0700 Subject: [PATCH] feat: PR description draft from config and forge templates Add pr_description module, RepoConfig template path, load_or_default, and forge-specific draft hints. TUI uses composed draft when opening the submit editor instead of a hardcoded markdown stub. Made-with: Cursor --- src/core/config.rs | 26 +++++- src/forge/github.rs | 4 + src/forge/gitlab.rs | 4 + src/forge/mod.rs | 9 +++ src/forge/pr_description.rs | 154 ++++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 16 ++-- 6 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 src/forge/pr_description.rs diff --git a/src/core/config.rs b/src/core/config.rs index 70319e2..9f549e3 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -28,6 +28,10 @@ pub struct ForgeConfig { pub struct RepoConfig { /// Base branch override (e.g. "origin/main"). Auto-detected if not set. pub base: Option, + /// Path under the repo root to a markdown file seeding the PR/MR description editor. + /// `{{subject}}` is replaced with the commit title. If missing or empty, forge hints apply. + #[serde(default)] + pub pr_description_template: Option, } impl Config { @@ -38,6 +42,17 @@ impl Config { toml::from_str(&content).ok() } + /// Load config or defaults suitable for local-only flows (e.g. submit wizard). + pub fn load_or_default(repo_root: &Path) -> Self { + Self::load(repo_root).unwrap_or_else(|| Self { + forge: ForgeConfig { + forge_type: "github".to_string(), + submit_cmd: None, + }, + repo: RepoConfig::default(), + }) + } + /// Save config to `.pilegit.toml`. pub fn save(&self, repo_root: &Path) -> Result<()> { let path = repo_root.join(CONFIG_FILE); @@ -131,7 +146,10 @@ pub fn run_setup(repo_root: &Path) -> Result { forge_type, submit_cmd, }, - repo: RepoConfig { base }, + repo: RepoConfig { + base, + ..Default::default() + }, }; config.save(repo_root)?; @@ -285,6 +303,7 @@ mod tests { }, repo: RepoConfig { base: Some("origin/develop".to_string()), + ..Default::default() }, }; @@ -306,7 +325,10 @@ mod tests { forge_type: "custom".to_string(), submit_cmd: Some("arc diff HEAD^".to_string()), }, - repo: RepoConfig { base: None }, + repo: RepoConfig { + base: None, + ..Default::default() + }, }; config.save(&dir).unwrap(); diff --git a/src/forge/github.rs b/src/forge/github.rs index fe590d4..da19386 100644 --- a/src/forge/github.rs +++ b/src/forge/github.rs @@ -216,6 +216,10 @@ impl Forge for GitHub { Ok(updates) } + + fn pr_description_draft_hint(&self, repo: &Repo, _subject: &str) -> Option { + crate::forge::pr_description::github_conventional_templates(repo) + } } impl GitHub { diff --git a/src/forge/gitlab.rs b/src/forge/gitlab.rs index 3ed3f1b..35d2a33 100644 --- a/src/forge/gitlab.rs +++ b/src/forge/gitlab.rs @@ -147,6 +147,10 @@ impl Forge for GitLab { Ok(updates) } + + fn pr_description_draft_hint(&self, repo: &Repo, _subject: &str) -> Option { + crate::forge::pr_description::gitlab_conventional_templates(repo) + } } impl GitLab { diff --git a/src/forge/mod.rs b/src/forge/mod.rs index 1cd85d4..1d62a70 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -3,6 +3,7 @@ pub mod gitea; pub mod github; pub mod gitlab; pub mod phabricator; +pub mod pr_description; pub mod stack_base_hint; use std::collections::HashMap; @@ -76,6 +77,14 @@ pub trait Forge { true } + /// Optional seed for the PR/MR description editor (host template files, API, etc.). + /// Runs after `[repo].pr_description_template` if that file is missing or empty. + /// Use `None` to rely on config + built-in outline. Implement per forge in + /// [`crate::forge::pr_description`] helpers where conventions exist (GitHub, GitLab, …). + fn pr_description_draft_hint(&self, _repo: &Repo, _subject: &str) -> Option { + None + } + /// Extract trailers from a commit body that should be preserved during squash. /// Each forge knows its own trailer format (e.g. "Differential Revision:" for /// Phabricator, "Change-Id:" for Gerrit). Default: none. diff --git a/src/forge/pr_description.rs b/src/forge/pr_description.rs new file mode 100644 index 0000000..541968b --- /dev/null +++ b/src/forge/pr_description.rs @@ -0,0 +1,154 @@ +//! Initial text for the PR/MR description editor: config path, forge-specific discovery, then builtin. + +use std::fs; +use std::path::PathBuf; + +use crate::core::config::RepoConfig; +use crate::git::ops::Repo; + +use super::Forge; + +/// Standard GitHub-style template locations (single-file; see GitHub docs for PR templates). +const GITHUB_TEMPLATE_PATHS: &[&str] = &[ + ".github/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + "pull_request_template.md", +]; + +/// First existing non-empty file under `repo.workdir` from `relative_paths`, in order. +pub(crate) fn read_first_repo_template(repo: &Repo, relative_paths: &[&str]) -> Option { + for rel in relative_paths { + let path = repo.workdir.join(rel); + if let Ok(s) = fs::read_to_string(&path) { + if !s.trim().is_empty() { + return Some(s); + } + } + } + None +} + +pub(crate) fn github_conventional_templates(repo: &Repo) -> Option { + read_first_repo_template(repo, GITHUB_TEMPLATE_PATHS) +} + +pub(crate) fn gitlab_conventional_templates(repo: &Repo) -> Option { + let dir = repo.workdir.join(".gitlab/merge_request_templates"); + let entries = fs::read_dir(&dir).ok()?; + let mut files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ex| ex == "md")) + .collect(); + if files.is_empty() { + return None; + } + files.sort(); + let default_idx = files.iter().position(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case("Default.md")) + }); + let mut ordered = Vec::new(); + if let Some(i) = default_idx { + ordered.push(files.remove(i)); + } + ordered.extend(files); + for p in ordered { + if let Ok(s) = fs::read_to_string(&p) { + if !s.trim().is_empty() { + return Some(s); + } + } + } + None +} + +fn substitute_subject(template: &str, subject: &str) -> String { + if template.contains("{{subject}}") { + template.replace("{{subject}}", subject) + } else { + template.to_string() + } +} + +fn builtin_fallback(subject: &str) -> String { + format!("## Description\n\n{}\n\n## Test Plan\n\n\n", subject) +} + +/// Editor seed for a new PR/MR: `[repo].pr_description_template` file, then +/// [`Forge::pr_description_draft_hint`], then a small built-in outline. +pub fn compose_initial_draft( + repo: &Repo, + forge: &dyn Forge, + repo_cfg: &RepoConfig, + subject: &str, +) -> String { + if let Some(rel) = repo_cfg.pr_description_template.as_ref() { + let path = repo.workdir.join(rel); + if let Ok(s) = fs::read_to_string(&path) { + if !s.trim().is_empty() { + return substitute_subject(&s, subject); + } + } + } + if let Some(h) = forge.pr_description_draft_hint(repo, subject) { + return substitute_subject(&h, subject); + } + builtin_fallback(subject) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::forge::github::GitHub; + + #[test] + fn github_reads_dot_github_lowercase() { + let dir = + std::env::temp_dir().join(format!("pgit-pr-template-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".github")).unwrap(); + std::fs::write( + dir.join(".github/pull_request_template.md"), + "## Checklist\n\n- [ ] tests\n", + ) + .unwrap(); + let repo = Repo::at_dir(dir.clone()); + let got = github_conventional_templates(&repo).unwrap(); + assert!(got.contains("Checklist")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn compose_prefers_config_path_over_forge() { + let dir = std::env::temp_dir().join(format!("pgit-pr-compose-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".github")).unwrap(); + std::fs::write(dir.join(".github/pull_request_template.md"), "FORGE").unwrap(); + std::fs::write(dir.join("custom.md"), "CONFIG {{subject}}").unwrap(); + let repo = Repo::at_dir(dir.clone()); + let cfg = RepoConfig { + base: None, + pr_description_template: Some("custom.md".to_string()), + }; + let draft = compose_initial_draft(&repo, &GitHub, &cfg, "my feat"); + assert_eq!(draft, "CONFIG my feat"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn compose_falls_back_to_builtin() { + let dir = + std::env::temp_dir().join(format!("pgit-pr-fallback-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let repo = Repo::at_dir(dir.clone()); + let cfg = RepoConfig::default(); + let draft = compose_initial_draft(&repo, &GitHub, &cfg, "only subject"); + assert!(draft.contains("only subject")); + assert!(draft.contains("Test Plan")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f569217..188c1e2 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -47,13 +47,7 @@ pub fn run() -> Result<()> { let mut commits = repo.list_stack_commits()?; // Load config and create the forge integration - let config = Config::load(&repo.workdir).unwrap_or_else(|| Config { - forge: crate::core::config::ForgeConfig { - forge_type: "github".to_string(), - submit_cmd: None, - }, - repo: crate::core::config::RepoConfig { base: None }, - }); + let config = Config::load_or_default(&repo.workdir); let f = forge::create_forge(&config); // Check that required CLI tools are installed @@ -529,7 +523,13 @@ fn handle_submit_commit( } // Open pilegit's editor for PR description - let template = format!("## Description\n\n{}\n\n## Test Plan\n\n\n", subject); + let config = Config::load_or_default(&repo.workdir); + let template = forge::pr_description::compose_initial_draft( + &repo, + app.forge.as_ref(), + &config.repo, + subject, + ); let tmp_path = std::env::temp_dir().join(format!("pgit-pr-msg-{}.txt", std::process::id())); std::fs::write(&tmp_path, &template)?;