feat: extend ecc2 draft pr prompt metadata

This commit is contained in:
Affaan Mustafa
2026-04-09 21:46:26 -07:00
parent 8936d09951
commit 913c00c74d
2 changed files with 326 additions and 13 deletions

View File

@@ -243,6 +243,14 @@ struct SearchMatch {
line_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PrPromptSpec {
title: String,
base_branch: Option<String>,
labels: Vec<String>,
reviewers: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimelineEventType {
Lifecycle,
@@ -1225,7 +1233,9 @@ impl Dashboard {
} else if let Some(input) = self.commit_input.as_ref() {
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
} else if let Some(input) = self.pr_input.as_ref() {
format!(" pr>{input}_ | [Enter] create draft PR [Esc] cancel |")
format!(
" pr>{input}_ | [Enter] create draft PR | title | base=branch | labels=a,b | reviewers=a,b | [Esc] cancel |"
)
} else if let Some(input) = self.search_input.as_ref() {
format!(
" /{input}_ | {} | {} | [Enter] apply [Esc] cancel |",
@@ -1346,7 +1356,7 @@ impl Dashboard {
" {/} Jump to previous/next diff hunk in the active diff view".to_string(),
" S/U/R Stage, unstage, or reset the selected file or active diff hunk".to_string(),
" C Commit staged changes for the selected worktree".to_string(),
" P Create a draft PR from the selected worktree branch".to_string(),
" P Create a draft PR; supports title | base=branch | labels=a,b | reviewers=a,b".to_string(),
" c Show conflict-resolution protocol for selected conflicted worktree"
.to_string(),
" e Cycle output content filter: all/errors/tool calls/file changes".to_string(),
@@ -2266,7 +2276,9 @@ impl Dashboard {
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| session.task.clone());
self.pr_input = Some(seed);
self.set_operator_note("pr mode | edit the title and press Enter".to_string());
self.set_operator_note(
"pr mode | title | base=branch | labels=a,b | reviewers=a,b".to_string(),
);
}
fn stage_selected_git_hunk(&mut self) {
@@ -3169,8 +3181,16 @@ impl Dashboard {
return;
};
let title = input.trim().to_string();
if title.is_empty() {
let request = match parse_pr_prompt(&input) {
Ok(request) => request,
Err(error) => {
self.pr_input = Some(input);
self.set_operator_note(format!("invalid PR input: {error}"));
return;
}
};
if request.title.is_empty() {
self.pr_input = Some(input);
self.set_operator_note("pr title cannot be empty".to_string());
return;
@@ -3193,11 +3213,20 @@ impl Dashboard {
}
let body = self.build_pull_request_body(&session);
match worktree::create_draft_pr(&worktree, &title, &body) {
let options = worktree::DraftPrOptions {
base_branch: request.base_branch.clone(),
labels: request.labels.clone(),
reviewers: request.reviewers.clone(),
};
match worktree::create_draft_pr_with_options(&worktree, &request.title, &body, &options) {
Ok(url) => {
self.set_operator_note(format!(
"created draft PR for {}: {}",
"created draft PR for {} against {}: {}",
format_session_id(&session.id),
options
.base_branch
.as_deref()
.unwrap_or(&worktree.base_branch),
url
));
}
@@ -7786,6 +7815,59 @@ fn assignment_action_label(action: manager::AssignmentAction) -> &'static str {
}
}
fn parse_pr_prompt(input: &str) -> std::result::Result<PrPromptSpec, String> {
let mut segments = input.split('|').map(str::trim);
let title = segments.next().unwrap_or_default().trim().to_string();
if title.is_empty() {
return Err("missing PR title".to_string());
}
let mut request = PrPromptSpec {
title,
base_branch: None,
labels: Vec::new(),
reviewers: Vec::new(),
};
for segment in segments {
if segment.is_empty() {
continue;
}
let (key, value) = segment
.split_once('=')
.ok_or_else(|| format!("expected key=value segment, got `{segment}`"))?;
let key = key.trim().to_ascii_lowercase();
let value = value.trim();
match key.as_str() {
"base" => {
if value.is_empty() {
return Err("base branch cannot be empty".to_string());
}
request.base_branch = Some(value.to_string());
}
"labels" | "label" => {
request.labels = value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect();
}
"reviewers" | "reviewer" => {
request.reviewers = value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect();
}
_ => return Err(format!("unsupported PR field `{key}`")),
}
}
Ok(request)
}
fn delegate_worktree_health_label(health: worktree::WorktreeHealth) -> &'static str {
match health {
worktree::WorktreeHealth::Clear => "clear",
@@ -8481,13 +8563,127 @@ mod tests {
assert_eq!(dashboard.pr_input.as_deref(), Some("seed pr title"));
assert_eq!(
dashboard.operator_note.as_deref(),
Some("pr mode | edit the title and press Enter")
Some("pr mode | title | base=branch | labels=a,b | reviewers=a,b")
);
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn parse_pr_prompt_supports_base_labels_and_reviewers() {
let parsed = parse_pr_prompt(
"Improve retry flow | base=release/2.0 | labels=billing, ux | reviewers=alice, bob",
)
.expect("parse prompt");
assert_eq!(parsed.title, "Improve retry flow");
assert_eq!(parsed.base_branch.as_deref(), Some("release/2.0"));
assert_eq!(parsed.labels, vec!["billing", "ux"]);
assert_eq!(parsed.reviewers, vec!["alice", "bob"]);
}
#[test]
fn submit_pr_prompt_passes_custom_metadata_to_gh() -> Result<()> {
let temp_root =
std::env::temp_dir().join(format!("ecc2-dashboard-pr-submit-{}", Uuid::new_v4()));
let root = temp_root.join("repo");
init_git_repo(&root)?;
let remote = temp_root.join("remote.git");
run_git(
&root,
&["init", "--bare", remote.to_str().expect("utf8 path")],
)?;
run_git(
&root,
&[
"remote",
"add",
"origin",
remote.to_str().expect("utf8 path"),
],
)?;
run_git(&root, &["push", "-u", "origin", "main"])?;
run_git(&root, &["checkout", "-b", "feat/dashboard-pr"])?;
fs::write(root.join("README.md"), "dashboard pr\n")?;
run_git(&root, &["commit", "-am", "dashboard pr"])?;
let bin_dir = temp_root.join("bin");
fs::create_dir_all(&bin_dir)?;
let gh_path = bin_dir.join("gh");
let args_path = temp_root.join("gh-dashboard-args.txt");
fs::write(
&gh_path,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s\\n' 'https://github.com/example/repo/pull/789'\n",
args_path.display()
),
)?;
let mut perms = fs::metadata(&gh_path)?.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&gh_path, perms)?;
}
#[cfg(not(unix))]
fs::set_permissions(&gh_path, perms)?;
let original_path = std::env::var_os("PATH");
std::env::set_var(
"PATH",
format!(
"{}:{}",
bin_dir.display(),
original_path
.as_deref()
.map(std::ffi::OsStr::to_string_lossy)
.unwrap_or_default()
),
);
let mut session = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
Some("ecc/focus"),
512,
42,
);
session.working_dir = root.clone();
session.worktree = Some(WorktreeInfo {
path: root.clone(),
branch: "feat/dashboard-pr".to_string(),
base_branch: "main".to_string(),
});
let mut dashboard = test_dashboard(vec![session], 0);
dashboard.pr_input = Some(
"Improve retry flow | base=release/2.0 | labels=billing,ux | reviewers=alice,bob"
.to_string(),
);
dashboard.submit_pr_prompt();
assert_eq!(
dashboard.operator_note.as_deref(),
Some("created draft PR for focus-12 against release/2.0: https://github.com/example/repo/pull/789")
);
let gh_args = fs::read_to_string(&args_path)?;
assert!(gh_args.contains("--base\nrelease/2.0"));
assert!(gh_args.contains("--label\nbilling"));
assert!(gh_args.contains("--label\nux"));
assert!(gh_args.contains("--reviewer\nalice"));
assert!(gh_args.contains("--reviewer\nbob"));
if let Some(path) = original_path {
std::env::set_var("PATH", path);
} else {
std::env::remove_var("PATH");
}
let _ = fs::remove_dir_all(temp_root);
Ok(())
}
#[test]
fn toggle_diff_view_mode_switches_to_unified_rendering() {
let mut dashboard = test_dashboard(

View File

@@ -64,6 +64,13 @@ pub struct GitStatusEntry {
pub conflicted: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DraftPrOptions {
pub base_branch: Option<String>,
pub labels: Vec<String>,
pub reviewers: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitPatchSectionKind {
Staged,
@@ -497,7 +504,16 @@ pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result<String> {
}
pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Result<String> {
create_draft_pr_with_gh(worktree, title, body, Path::new("gh"))
create_draft_pr_with_options(worktree, title, body, &DraftPrOptions::default())
}
pub fn create_draft_pr_with_options(
worktree: &WorktreeInfo,
title: &str,
body: &str,
options: &DraftPrOptions,
) -> Result<String> {
create_draft_pr_with_gh(worktree, title, body, options, Path::new("gh"))
}
pub fn github_compare_url(worktree: &WorktreeInfo) -> Result<Option<String>> {
@@ -518,6 +534,7 @@ fn create_draft_pr_with_gh(
worktree: &WorktreeInfo,
title: &str,
body: &str,
options: &DraftPrOptions,
gh_bin: &Path,
) -> Result<String> {
let title = title.trim();
@@ -525,6 +542,13 @@ fn create_draft_pr_with_gh(
anyhow::bail!("PR title cannot be empty");
}
let base_branch = options
.base_branch
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&worktree.base_branch);
let push = Command::new("git")
.arg("-C")
.arg(&worktree.path)
@@ -536,18 +560,36 @@ fn create_draft_pr_with_gh(
anyhow::bail!("git push failed: {stderr}");
}
let output = Command::new(gh_bin)
let mut command = Command::new(gh_bin);
command
.arg("pr")
.arg("create")
.arg("--draft")
.arg("--base")
.arg(&worktree.base_branch)
.arg(base_branch)
.arg("--head")
.arg(&worktree.branch)
.arg("--title")
.arg(title)
.arg("--body")
.arg(body)
.arg(body);
for label in options
.labels
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
command.arg("--label").arg(label);
}
for reviewer in options
.reviewers
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
command.arg("--reviewer").arg(reviewer);
}
let output = command
.current_dir(&worktree.path)
.output()
.context("Failed to create draft PR with gh")?;
@@ -2388,7 +2430,13 @@ mod tests {
base_branch: "main".to_string(),
};
let url = create_draft_pr_with_gh(&worktree, "My PR", "Body line", &gh_path)?;
let url = create_draft_pr_with_gh(
&worktree,
"My PR",
"Body line",
&DraftPrOptions::default(),
&gh_path,
)?;
assert_eq!(url, "https://github.com/example/repo/pull/123");
let remote_branch = Command::new("git")
@@ -2413,6 +2461,75 @@ mod tests {
Ok(())
}
#[test]
fn create_draft_pr_forwards_custom_base_labels_and_reviewers() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-pr-create-options-{}", Uuid::new_v4()));
let repo = init_repo(&root)?;
let remote = root.join("remote.git");
run_git(
&root,
&["init", "--bare", remote.to_str().expect("utf8 path")],
)?;
run_git(
&repo,
&[
"remote",
"add",
"origin",
remote.to_str().expect("utf8 path"),
],
)?;
run_git(&repo, &["push", "-u", "origin", "main"])?;
run_git(&repo, &["checkout", "-b", "feat/pr-options"])?;
fs::write(repo.join("README.md"), "pr options\n")?;
run_git(&repo, &["commit", "-am", "pr options"])?;
let bin_dir = root.join("bin");
fs::create_dir_all(&bin_dir)?;
let gh_path = bin_dir.join("gh");
let args_path = root.join("gh-args-options.txt");
fs::write(
&gh_path,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s\\n' 'https://github.com/example/repo/pull/456'\n",
args_path.display()
),
)?;
let mut perms = fs::metadata(&gh_path)?.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&gh_path, perms)?;
}
#[cfg(not(unix))]
fs::set_permissions(&gh_path, perms)?;
let worktree = WorktreeInfo {
path: repo.clone(),
branch: "feat/pr-options".to_string(),
base_branch: "main".to_string(),
};
let options = DraftPrOptions {
base_branch: Some("release/2.0".to_string()),
labels: vec!["billing".to_string(), "ui".to_string()],
reviewers: vec!["alice".to_string(), "bob".to_string()],
};
let url = create_draft_pr_with_gh(&worktree, "My PR", "Body line", &options, &gh_path)?;
assert_eq!(url, "https://github.com/example/repo/pull/456");
let gh_args = fs::read_to_string(&args_path)?;
assert!(gh_args.contains("--base\nrelease/2.0"));
assert!(gh_args.contains("--label\nbilling"));
assert!(gh_args.contains("--label\nui"));
assert!(gh_args.contains("--reviewer\nalice"));
assert!(gh_args.contains("--reviewer\nbob"));
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn github_compare_url_uses_origin_remote_and_encodes_refs() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-compare-url-{}", Uuid::new_v4()));