From 374aa77f277b3b03cdd5332237e9746fef16041e Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 23:24:10 +0000 Subject: [PATCH] huskies: merge 1069 --- script/release | 36 ++++- server/src/agents/merge/squash/mod.rs | 12 +- .../agents/merge/squash/tests_changelog.rs | 142 ++++++++++++++++++ 3 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 server/src/agents/merge/squash/tests_changelog.rs diff --git a/script/release b/script/release index 9cecbfce..73aa1f3a 100755 --- a/script/release +++ b/script/release @@ -124,19 +124,43 @@ else fi # Categorise merged work items and format names. +# Supports two subject formats (after stripping the "huskies: merge " prefix): +# New: "1063 story Human Readable Name" +# Old: "1063_story_human_readable_name" FEATURES="" FIXES="" REFACTORS="" while IFS= read -r item; do [ -z "$item" ] && continue - # Strip the numeric prefix and type to get the human name. - name=$(echo "$item" | sed -E 's/^[0-9]+_(story|bug|refactor|spike)_//' | tr '_' ' ') + + # Extract the leading numeric ID (present in both formats). + id=$(echo "$item" | grep -oE '^[0-9]+') + + # Detect format and extract human name + type word. + if echo "$item" | grep -qE '^[0-9]+ (story|bug|refactor|spike|epic) '; then + # New format: "1063 story Human Name Here" + type_word=$(echo "$item" | sed -E 's/^[0-9]+ ([a-z]+) .*/\1/') + name=$(echo "$item" | sed -E 's/^[0-9]+ [a-z]+ //') + else + # Legacy slug format: "1063_story_human_name_here" + type_word=$(echo "$item" | sed -E 's/^[0-9]+_([a-z]+)_.*/\1/') + name=$(echo "$item" | sed -E 's/^[0-9]+_(story|bug|refactor|spike|epic)_//' | tr '_' ' ') + fi + # Capitalise first letter. name="$(echo "${name:0:1}" | tr '[:lower:]' '[:upper:]')${name:1}" - case "$item" in - *_bug_*) FIXES="${FIXES}- ${name}\n" ;; - *_refactor_*) REFACTORS="${REFACTORS}- ${name}\n" ;; - *) FEATURES="${FEATURES}- ${name}\n" ;; + + # Format as "Name (ID)" when a numeric ID was found, plain name otherwise. + if [ -n "$id" ]; then + entry="${name} (${id})" + else + entry="${name}" + fi + + case "$type_word" in + bug) FIXES="${FIXES}- ${entry}\n" ;; + refactor) REFACTORS="${REFACTORS}- ${entry}\n" ;; + *) FEATURES="${FEATURES}- ${entry}\n" ;; esac done <<< "$MERGED_RAW" diff --git a/server/src/agents/merge/squash/mod.rs b/server/src/agents/merge/squash/mod.rs index 71ab24a0..bf451200 100644 --- a/server/src/agents/merge/squash/mod.rs +++ b/server/src/agents/merge/squash/mod.rs @@ -124,7 +124,15 @@ pub(crate) fn run_squash_merge( // ── Commit in the temporary worktree ────────────────────────── all_output.push_str("=== git commit ===\n"); - let commit_msg = format!("huskies: merge {story_id}"); + // Include human-readable name and item type when the CRDT is available. + // Falls back to the bare ID when running outside the server (e.g. in tests). + let story_label = crate::crdt_state::read_item(story_id) + .map(|item| { + let type_str = item.item_type().map(|t| t.as_str()).unwrap_or("story"); + format!(" {} {}", type_str, item.name()) + }) + .unwrap_or_default(); + let commit_msg = format!("huskies: merge {story_id}{story_label}"); let commit = Command::new("git") .args(["commit", "-m", &commit_msg]) .current_dir(&merge_wt_path) @@ -507,3 +515,5 @@ fn run_merge_quality_gates( mod tests_advanced; #[cfg(test)] mod tests_basic; +#[cfg(test)] +mod tests_changelog; diff --git a/server/src/agents/merge/squash/tests_changelog.rs b/server/src/agents/merge/squash/tests_changelog.rs new file mode 100644 index 00000000..28a7f9c5 --- /dev/null +++ b/server/src/agents/merge/squash/tests_changelog.rs @@ -0,0 +1,142 @@ +//! Regression tests for changelog entry parsing — both legacy-slug and new-format +//! merge commit subjects must resolve to a human-readable "Name (ID)" entry. + +/// Parse a single merge commit subject (after stripping the `huskies: merge ` prefix) +/// into `(id, type_word, human_name)`. +/// +/// Returns `None` for subjects that are not recognised merge items. +fn parse_changelog_entry(item: &str) -> Option<(String, String, String)> { + let item = item.trim(); + if item.is_empty() { + return None; + } + + // Extract leading numeric ID present in both formats. + let id: String = item.chars().take_while(|c| c.is_ascii_digit()).collect(); + if id.is_empty() { + return None; + } + + // Detect format by the character immediately following the digits. + // id contains only ASCII digits so id.len() is a valid char boundary. + let rest = item.get(id.len()..).unwrap_or(""); + if let Some(space_rest) = rest.strip_prefix(' ') { + // New format: "1063 story Human Name Here" + let mut words = space_rest.splitn(2, ' '); + let type_word = words.next().unwrap_or("story").to_string(); + let name = words.next().unwrap_or("").trim().to_string(); + if name.is_empty() { + return None; + } + Some((id, type_word, name)) + } else if let Some(slug_rest) = rest.strip_prefix('_') { + // Legacy slug format: "1063_story_human_name_here" + let mut parts = slug_rest.splitn(2, '_'); + let type_word = parts.next().unwrap_or("story").to_string(); + let slug = parts.next().unwrap_or("").replace('_', " "); + if slug.is_empty() { + return None; + } + Some((id, type_word, slug)) + } else { + None + } +} + +/// Format a parsed entry as "Human Name (ID)". +fn format_entry(id: &str, name: &str) -> String { + let mut chars = name.chars(); + let capitalised = match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + }; + format!("{capitalised} ({id})") +} + +#[test] +fn changelog_new_format_story_resolves_to_name_and_id() { + let item = "1063 story Tee pipeline events into gateway context"; + let (id, _type_word, name) = parse_changelog_entry(item).expect("should parse new format"); + assert_eq!(id, "1063"); + assert_eq!( + format_entry(&id, &name), + "Tee pipeline events into gateway context (1063)" + ); +} + +#[test] +fn changelog_new_format_bug_resolves_to_name_and_id() { + let item = "999 bug Fix the broken auth token"; + let (id, type_word, name) = parse_changelog_entry(item).expect("should parse new-format bug"); + assert_eq!(id, "999"); + assert_eq!(type_word, "bug"); + assert_eq!(format_entry(&id, &name), "Fix the broken auth token (999)"); +} + +#[test] +fn changelog_new_format_refactor_resolves_to_name_and_id() { + let item = "777 refactor Extract config parsing"; + let (id, type_word, name) = parse_changelog_entry(item).expect("should parse refactor"); + assert_eq!(type_word, "refactor"); + assert_eq!(format_entry(&id, &name), "Extract config parsing (777)"); +} + +#[test] +fn changelog_legacy_slug_story_resolves_to_name_and_id() { + let item = "1063_story_tee_pipeline_events_into_gateway_context"; + let (id, _type_word, name) = parse_changelog_entry(item).expect("should parse legacy slug"); + assert_eq!(id, "1063"); + assert_eq!( + format_entry(&id, &name), + "Tee pipeline events into gateway context (1063)" + ); +} + +#[test] +fn changelog_legacy_slug_bug_resolves_to_name_and_id() { + let item = "999_bug_fix_the_broken_auth_token"; + let (id, type_word, name) = parse_changelog_entry(item).expect("should parse legacy bug slug"); + assert_eq!(id, "999"); + assert_eq!(type_word, "bug"); + assert_eq!(format_entry(&id, &name), "Fix the broken auth token (999)"); +} + +#[test] +fn changelog_mixed_fixture_all_entries_have_human_names() { + // Fixture: a mix of legacy-slug and new-format subjects (as they appear + // after stripping the "huskies: merge " prefix from the git log). + let fixture = [ + // Legacy slug formats (pre-migration) + "1001_story_add_matrix_transport", + "1002_bug_fix_crdt_sync_disconnect", + "1003_refactor_extract_gateway_config", + // New format (post-story-1069) + "1050 story Add agent pool auto-assign", + "1063 story Tee pipeline events into gateway context", + "1064 bug Stop lagged handler re-emitting via same channel", + "1065 refactor Move squash merge into own module", + ]; + + for item in &fixture { + let result = parse_changelog_entry(item); + assert!(result.is_some(), "failed to parse merge subject: {item:?}"); + let (id, _type_word, name) = result.unwrap(); + let entry = format_entry(&id, &name); + // Every entry must contain the numeric ID in parentheses. + assert!( + entry.contains(&format!("({id})")), + "entry missing numeric ID: {entry:?}" + ); + // Name must not be empty or just whitespace. + assert!( + !name.trim().is_empty(), + "empty human name for item: {item:?}" + ); + // Name must not be a raw slug (contains underscores as word separators). + // (Underscores are OK inside words like "auto-assign" but not as spaces.) + assert!( + !name.contains('_'), + "name still contains underscores (slug not decoded): {name:?}" + ); + } +}