Initial commit: git-issue-commit CLI tool
Some checks failed
CI / test (push) Has been cancelled
CI / release (push) Has been cancelled

This commit is contained in:
2026-01-29 19:59:28 +00:00
parent 4199a71e6f
commit 63ddae495b

132
src/parser/issue.rs Normal file
View File

@@ -0,0 +1,132 @@
use crate::parser::conventional::ConventionalMessage;
pub fn parse_issue_content(title: &str, body: &str) -> ConventionalMessage {
let description = extract_description(title, body);
let change_type = detect_change_type(&description, body);
ConventionalMessage {
r#type: change_type,
scope: None,
description: clean_description(&description),
body: None,
breaking: is_breaking_change(body),
breaking_description: extract_breaking_description(body),
footer: None,
}
}
fn extract_description(title: &str, body: &str) -> String {
if !title.is_empty() {
title.to_string()
} else if !body.is_empty() {
let first_line = body.lines()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.find(|s| !s.starts_with('#'))
.unwrap_or("");
first_line.to_string()
} else {
String::new()
}
}
fn detect_change_type(description: &str, body: &str) -> String {
let text = format!("{} {}", description, body).to_lowercase();
if text.contains("fix") || text.contains("bug") || text.contains("error") || text.contains("crash") {
return "fix".to_string();
}
if text.contains("feature") || text.contains("add") || text.contains("implement") {
return "feat".to_string();
}
if text.contains("document") || text.contains("readme") || text.contains("comment") {
return "docs".to_string();
}
if text.contains("test") || text.contains("coverage") {
return "test".to_string();
}
if text.contains("refactor") || text.contains("restructur") {
return "refactor".to_string();
}
if text.contains("performance") || text.contains("optimize") || text.contains("speed") {
return "perf".to_string();
}
if text.contains("style") || text.contains("format") || text.contains("lint") {
return "style".to_string();
}
if text.contains("ci") || text.contains("workflow") || text.contains("pipeline") {
return "ci".to_string();
}
if text.contains("build") || text.contains("dependenc") || text.contains("package") {
return "build".to_string();
}
if text.contains("revert") || text.contains("undo") {
return "revert".to_string();
}
"chore".to_string()
}
fn clean_description(desc: &str) -> String {
let cleaned = desc
.trim()
.trim_end_matches(|c: char| c == '.' || c == '!')
.to_string();
cleaned
.split_whitespace()
.take(20)
.collect::<Vec<&str>>()
.join(" ")
}
fn is_breaking_change(body: &str) -> bool {
let body_lower = body.to_lowercase();
body_lower.contains("breaking change") ||
body_lower.contains("breaking changes") ||
body_lower.contains("! ") ||
body.contains("BREAKING CHANGE")
}
fn extract_breaking_description(body: &str) -> Option<String> {
let lines: Vec<&str> = body.lines().collect();
let mut in_breaking_section = false;
let mut breaking_lines = Vec::new();
for line in &lines {
let line_lower = line.to_lowercase();
if line_lower.contains("breaking change") && line_lower.contains("change") {
in_breaking_section = true;
let parts: Vec<&str> = line.splitn(2, "BREAKING CHANGE").collect();
if parts.len() > 1 && !parts[1].trim().is_empty() && !parts[1].trim().starts_with(':') {
breaking_lines.push(parts[1].trim());
} else {
let parts_lower: Vec<&str> = line.splitn(2, "BREAKING CHANGE").collect();
if parts_lower.len() > 1 {
let after_break = parts_lower[1].trim().trim_start_matches(':').trim();
if !after_break.is_empty() {
breaking_lines.push(after_break);
}
}
}
continue;
}
if in_breaking_section {
if line.trim().is_empty() {
if !breaking_lines.is_empty() {
break;
}
} else if line.starts_with('#') && !line_lower.contains("breaking") {
break;
} else {
breaking_lines.push(line.trim());
}
}
}
if breaking_lines.is_empty() {
None
} else {
Some(breaking_lines.join(" ").trim().to_string())
}
}