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
26 changes: 24 additions & 2 deletions src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

impl Config {
Expand All @@ -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);
Expand Down Expand Up @@ -131,7 +146,10 @@ pub fn run_setup(repo_root: &Path) -> Result<Config> {
forge_type,
submit_cmd,
},
repo: RepoConfig { base },
repo: RepoConfig {
base,
..Default::default()
},
};

config.save(repo_root)?;
Expand Down Expand Up @@ -285,6 +303,7 @@ mod tests {
},
repo: RepoConfig {
base: Some("origin/develop".to_string()),
..Default::default()
},
};

Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/forge/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ impl Forge for GitHub {

Ok(updates)
}

fn pr_description_draft_hint(&self, repo: &Repo, _subject: &str) -> Option<String> {
crate::forge::pr_description::github_conventional_templates(repo)
}
}

impl GitHub {
Expand Down
4 changes: 4 additions & 0 deletions src/forge/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ impl Forge for GitLab {

Ok(updates)
}

fn pr_description_draft_hint(&self, repo: &Repo, _subject: &str) -> Option<String> {
crate::forge::pr_description::gitlab_conventional_templates(repo)
}
}

impl GitLab {
Expand Down
9 changes: 9 additions & 0 deletions src/forge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> {
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.
Expand Down
154 changes: 154 additions & 0 deletions src/forge/pr_description.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
read_first_repo_template(repo, GITHUB_TEMPLATE_PATHS)
}

pub(crate) fn gitlab_conventional_templates(repo: &Repo) -> Option<String> {
let dir = repo.workdir.join(".gitlab/merge_request_templates");
let entries = fs::read_dir(&dir).ok()?;
let mut files: Vec<PathBuf> = 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);
}
}
16 changes: 8 additions & 8 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)?;
Expand Down
Loading