1use regex::Regex;
2use crate::ai_service;
3use crate::config;
4use crate::git;
5
6fn extract_change_id(message: &str) -> Option<String> {
8 let change_id_regex = Regex::new(r"(?m)^Change-Id:\s*(.+)$").ok()?;
9 change_id_regex.captures(message)
10 .and_then(|cap| cap.get(1))
11 .map(|m| m.as_str().trim().to_string())
12}
13
14fn append_change_id(message: &str, change_id: &str) -> String {
16 if message.contains("Change-Id:") {
18 return message.to_string();
19 }
20
21 let mut result = message.to_string();
22
23 if !result.ends_with('\n') {
25 result.push('\n');
26 }
27
28 result.push('\n');
30 result.push_str(&format!("Change-Id: {}", change_id));
31
32 result
33}
34
35#[derive(Debug, Clone, Copy)]
37enum LanguageMode {
38 ChineseOnly,
39 EnglishOnly,
40 Bilingual,
41}
42
43fn parse_issue_reference(issues: &str) -> anyhow::Result<String> {
45 let mut fixes_refs = Vec::new();
46 let mut pms_refs = Vec::new();
47
48 let links: Vec<&str> = issues.split_whitespace()
50 .flat_map(|s| s.split(','))
51 .filter(|s| !s.is_empty())
52 .collect();
53
54 for link in links {
55 let link = link.trim();
56
57 if link.starts_with("https://github.com/") {
59 match parse_github_issue(link) {
60 Ok(ref_str) => {
61 if let Some(fix_ref) = ref_str.strip_prefix("Fixes: ") {
63 fixes_refs.push(fix_ref.to_string());
64 }
65 }
66 Err(e) => return Err(e),
67 }
68 }
69 else if link.contains("pms.uniontech.com") {
71 match parse_pms_link(link) {
72 Ok(ref_str) => {
73 if let Some(pms_ref) = ref_str.strip_prefix("PMS: ") {
75 pms_refs.push(pms_ref.to_string());
76 }
77 }
78 Err(e) => return Err(e),
79 }
80 }
81 else if let Ok(_issue_num) = link.parse::<u32>() {
83 fixes_refs.push(format!("#{}", link));
84 }
85 else {
86 return Err(anyhow::anyhow!("无法解析 issue 引用格式: {}", link));
87 }
88 }
89
90 let mut result = Vec::new();
92
93 if !fixes_refs.is_empty() {
94 result.push(format!("Fixes: {}", fixes_refs.join(" ")));
95 }
96
97 if !pms_refs.is_empty() {
98 result.push(format!("PMS: {}", pms_refs.join(" ")));
99 }
100
101 if result.is_empty() {
102 return Err(anyhow::anyhow!("没有找到有效的 issue 引用"));
103 }
104
105 Ok(result.join("\n"))
106}
107
108fn parse_github_issue(url: &str) -> anyhow::Result<String> {
110 let issue_regex = Regex::new(r"https://github\.com/([^/]+/[^/]+)/issues/(\d+)")?;
111
112 if let Some(captures) = issue_regex.captures(url) {
113 let repo = captures.get(1).unwrap().as_str();
114 let issue_num = captures.get(2).unwrap().as_str();
115
116 if is_current_project_repo(repo)? {
118 Ok(format!("Fixes: #{}", issue_num))
119 } else {
120 Ok(format!("Fixes: {}#{}", repo, issue_num))
121 }
122 } else {
123 Err(anyhow::anyhow!("无效的 GitHub issue URL 格式: {}", url))
124 }
125}
126
127fn parse_pms_link(url: &str) -> anyhow::Result<String> {
129 let bug_regex = Regex::new(r"bug-view-(\d+)\.html")?;
131 let task_regex = Regex::new(r"task-view-(\d+)\.html")?;
132 let story_regex = Regex::new(r"story-view-(\d+)\.html")?;
133
134 if let Some(captures) = bug_regex.captures(url) {
135 let id = &captures[1];
136 Ok(format!("PMS: BUG-{}", id))
137 } else if let Some(captures) = task_regex.captures(url) {
138 let id = &captures[1];
139 Ok(format!("PMS: TASK-{}", id))
140 } else if let Some(captures) = story_regex.captures(url) {
141 let id = &captures[1];
142 Ok(format!("PMS: STORY-{}", id))
143 } else {
144 Err(anyhow::anyhow!("无效的 PMS 链接格式: {}", url))
145 }
146}
147
148fn is_current_project_repo(repo: &str) -> anyhow::Result<bool> {
150 use std::process::Command;
151
152 let output = Command::new("git")
154 .args(&["remote", "get-url", "origin"])
155 .output()?;
156
157 if !output.status.success() {
158 return Ok(false);
159 }
160
161 let remote_url = String::from_utf8_lossy(&output.stdout);
162 let remote_url = remote_url.trim();
163
164 let repo_regex = Regex::new(r"github\.com[:/]([^/]+/[^/\.]+)")?;
167
168 if let Some(captures) = repo_regex.captures(remote_url) {
169 let current_repo = captures.get(1).unwrap().as_str();
170 Ok(current_repo == repo)
171 } else {
172 Ok(false)
173 }
174}
175
176const ENGLISH_PROMPT_TEMPLATE: &str = r#"Please analyze the git diff content and generate a commit message in English only:
1781. First line: type: message (under 50 characters)
1792. Empty line after the title
1803. Detailed explanation in English (what was changed and why)
1814. Empty line after explanation
1825. Log field (ONLY if this change involves user-facing features/UI changes that product managers would communicate to users)
1836. Empty line after Log field (if present)
1847. Influence section with black-box testing recommendations
1858. Type must be one of: feat/fix/docs/style/refactor/test/chore
1869. Focus on both WHAT changed and WHY it was necessary
18710. Include any important technical details or context
18811. DO NOT include any Chinese content
18912. DO NOT wrap the response in any markdown or code block markers
190
191Example response format:
192feat: add user authentication module
193
1941. Implement JWT-based authentication system
1952. Add user login and registration endpoints
1963. Include password hashing with bcrypt
1974. Set up token refresh mechanism
198
199Log: Added user authentication feature with login and registration
200
201Influence:
2021. Test user registration with valid and invalid inputs
2032. Verify login functionality with correct and incorrect credentials
2043. Test JWT token generation and validation
2054. Verify password security and hashing
2065. Test token refresh mechanism and expiration handling
2076. Verify access control for protected endpoints
208
209Please respond with ONLY the commit message following this format,
210DO NOT end commit titles with any punctuation."#;
211
212const CHINESE_PROMPT_TEMPLATE: &str = r#"请分析以下 git diff 内容,并按照以下格式生成提交信息:
2131. 第一行为标题:type: message(不超过50个字符)
214
2153. 详细的中文说明(解释做了什么改动以及为什么需要这些改动)
2164. 说明下方空一行
2175. Log 字段(仅当此次变更涉及用户可感知的功能/UI层面变化,产品经理会向用户说明的内容时才添加)
2186. Log 字段下方空一行(如果存在 Log 字段)
2197. Influence 部分,提供黑盒测试的重点和范围
2208. type 必须是以下之一:feat/fix/docs/style/refactor/test/chore
2219. 关注点:变更内容(做了什么)和变更原因(为什么)
22210. 包含重要的技术细节或上下文
22311. 不要使用任何 markdown 或代码块标记
22412. 标题结尾不要使用标点符号
225
226示例格式:
227feat: 添加用户认证模块
228
2291. 实现基于 JWT 的认证系统
2302. 添加用户登录和注册端点
2313. 包含使用 bcrypt 的密码哈希处理
2324. 设置令牌刷新机制
233
234Log: 新增用户登录注册功能
235
236Influence:
2371. 测试用户注册功能,包括有效和无效输入
2382. 验证登录功能,测试正确和错误的凭据
2393. 测试 JWT 令牌生成和验证流程
2404. 验证密码安全性和哈希处理
2415. 测试令牌刷新机制和过期处理
2426. 验证受保护端点的访问控制"#;
243
244const BILINGUAL_PROMPT_TEMPLATE: &str = r#"Please analyze the git diff content and generate a detailed bilingual commit message with:
2451. First line in English: type: message (under 50 characters)
2462. Empty line after the title
2473. Detailed explanation in English (what was changed and why)
2484. Empty line after English explanation
2495. Log field in English (ONLY if this change involves user-facing features/UI changes)
2506. Empty line after English Log field (if present)
2517. Influence section in English with black-box testing recommendations
252
2539. Chinese title and explanation (translate the English content)
25410. Empty line after Chinese explanation
25511. Chinese Log field (translate the English Log field, only if present)
25612. Empty line after Chinese Log field (if present)
25713. Chinese Influence section (translate the English testing suggestions)
25814. Type must be one of: feat/fix/docs/style/refactor/test/chore
25915. Focus on both WHAT changed and WHY it was necessary
26016. Include any important technical details or context
26117. DO NOT wrap the response in any markdown or code block markers
262
263Example response format:
264feat: add user authentication module
265
2661. Implement JWT-based authentication system
2672. Add user login and registration endpoints
2683. Include password hashing with bcrypt
2694. Set up token refresh mechanism
270
271Log: Added user authentication feature with login and registration
272
273Influence:
2741. Test user registration with valid and invalid inputs
2752. Verify login functionality with correct and incorrect credentials
2763. Test JWT token generation and validation
2774. Verify password security and hashing
2785. Test token refresh mechanism and expiration handling
2796. Verify access control for protected endpoints
280
281feat: 添加用户认证模块
282
2831. 实现基于 JWT 的认证系统
2842. 添加用户登录和注册端点
2853. 包含使用 bcrypt 的密码哈希处理
2864. 设置令牌刷新机制
287
288Log: 新增用户登录注册功能
289
290Influence:
2911. 测试用户注册功能,包括有效和无效输入
2922. 验证登录功能,测试正确和错误的凭据
2933. 测试 JWT 令牌生成和验证流程
2944. 验证密码安全性和哈希处理
2955. 测试令牌刷新机制和过期处理
2966. 验证受保护端点的访问控制
297
298Please respond with ONLY the commit message following this format,
299DO NOT end commit titles with any punctuation."#;
300
301const ENGLISH_PROMPT_TEMPLATE_NO_TEST: &str = r#"Please analyze the git diff content and generate a commit message in English only:
3031. First line: type: message (under 50 characters)
3042. Empty line after the title
3053. Detailed explanation in English (what was changed and why)
3064. Empty line after explanation
3075. Log field (ONLY if this change involves user-facing features/UI changes that product managers would communicate to users)
3086. Type must be one of: feat/fix/docs/style/refactor/test/chore
3097. Focus on both WHAT changed and WHY it was necessary
3108. Include any important technical details or context
3119. DO NOT include any Chinese content
31210. DO NOT wrap the response in any markdown or code block markers
313
314Example response format:
315feat: add user authentication module
316
3171. Implement JWT-based authentication system
3182. Add user login and registration endpoints
3193. Include password hashing with bcrypt
3204. Set up token refresh mechanism
321
322Log: Added user authentication feature with login and registration
323
324Please respond with ONLY the commit message following this format,
325DO NOT end commit titles with any punctuation."#;
326
327const CHINESE_PROMPT_TEMPLATE_NO_TEST: &str = r#"请分析以下 git diff 内容,并按照以下格式生成提交信息:
3281. 第一行为标题:type: message(不超过50个字符)
3292. 标题下方空一行
3303. 详细的中文说明(解释做了什么改动以及为什么需要这些改动)
3314. 说明下方空一行
3325. Log 字段(仅当此次变更涉及用户可感知的功能/UI层面变化时才添加)
3336. type 必须是以下之一:feat/fix/docs/style/refactor/test/chore
3347. 关注点:变更内容(做了什么)和变更原因(为什么)
3358. 包含重要的技术细节或上下文
3369. 不要使用任何 markdown 或代码块标记
33710. 标题结尾不要使用标点符号
338
339示例格式:
340feat: 添加用户认证模块
341
3421. 实现基于 JWT 的认证系统
3432. 添加用户登录和注册端点
3443. 包含使用 bcrypt 的密码哈希处理
3454. 设置令牌刷新机制
346
347Log: 新增用户登录注册功能"#;
348
349const BILINGUAL_PROMPT_TEMPLATE_NO_TEST: &str = r#"Please analyze the git diff content and generate a detailed bilingual commit message with:
3501. First line in English: type: message (under 50 characters)
3512. Empty line after the title
3523. Detailed explanation in English (what was changed and why)
3534. Empty line after English explanation
3545. Log field in English (ONLY if this change involves user-facing features/UI changes)
3556. Empty line after English Log field (if present)
3567. Chinese title and explanation (translate the English content)
3578. Empty line after Chinese explanation
3589. Chinese Log field (translate the English Log field, only if present)
35910. Type must be one of: feat/fix/docs/style/refactor/test/chore
36011. Focus on both WHAT changed and WHY it was necessary
36112. Include any important technical details or context
36213. DO NOT wrap the response in any markdown or code block markers
363
364Example response format:
365feat: add user authentication module
366
3671. Implement JWT-based authentication system
3682. Add user login and registration endpoints
3693. Include password hashing with bcrypt
3704. Set up token refresh mechanism
371
372Log: Added user authentication feature with login and registration
373
374feat: 添加用户认证模块
375
3761. 实现基于 JWT 的认证系统
3772. 添加用户登录和注册端点
3783. 包含使用 bcrypt 的密码哈希处理
3794. 设置令牌刷新机制
380
381Log: 新增用户登录注册功能
382
383Please respond with ONLY the commit message following this format,
384DO NOT end commit titles with any punctuation."#;
385
386const ENGLISH_PROMPT_TEMPLATE_NO_LOG: &str = r#"Please analyze the git diff content and generate a commit message in English only:
3881. First line: type: message (under 50 characters)
3892. Empty line after the title
3903. Detailed explanation in English (what was changed and why)
3914. Empty line after explanation
3925. Influence section with black-box testing recommendations
3936. Type must be one of: feat/fix/docs/style/refactor/test/chore
3947. Focus on both WHAT changed and WHY it was necessary
3958. Include any important technical details or context
3969. DO NOT include any Chinese content
39710. DO NOT wrap the response in any markdown or code block markers
398
399Example response format:
400feat: add user authentication module
401
4021. Implement JWT-based authentication system
4032. Add user login and registration endpoints
4043. Include password hashing with bcrypt
4054. Set up token refresh mechanism
406
407Influence:
4081. Test user registration with valid and invalid inputs
4092. Verify login functionality with correct and incorrect credentials
4103. Test JWT token generation and validation
4114. Verify password security and hashing
4125. Test token refresh mechanism and expiration handling
4136. Verify access control for protected endpoints
414
415Please respond with ONLY the commit message following this format,
416DO NOT end commit titles with any punctuation."#;
417
418const CHINESE_PROMPT_TEMPLATE_NO_LOG: &str = r#"请分析以下 git diff 内容,并按照以下格式生成提交信息:
4191. 第一行为标题:type: message(不超过50个字符)
420
4213. 详细的中文说明(解释做了什么改动以及为什么需要这些改动)
4224. 说明下方空一行
4235. Influence 部分,提供黑盒测试的重点和范围
4246. type 必须是以下之一:feat/fix/docs/style/refactor/test/chore
4257. 关注点:变更内容(做了什么)和变更原因(为什么)
4268. 包含重要的技术细节或上下文
4279. 不要使用任何 markdown 或代码块标记
42810. 标题结尾不要使用标点符号
429
430示例格式:
431feat: 添加用户认证模块
432
4331. 实现基于 JWT 的认证系统
4342. 添加用户登录和注册端点
4353. 包含使用 bcrypt 的密码哈希处理
4364. 设置令牌刷新机制
437
438Influence:
4391. 测试用户注册功能,包括有效和无效输入
4402. 验证登录功能,测试正确和错误的凭据
4413. 测试 JWT 令牌生成和验证流程
4424. 验证密码安全性和哈希处理
4435. 测试令牌刷新机制和过期处理
4446. 验证受保护端点的访问控制"#;
445
446const BILINGUAL_PROMPT_TEMPLATE_NO_LOG: &str = r#"Please analyze the git diff content and generate a detailed bilingual commit message with:
4471. First line in English: type: message (under 50 characters)
4482. Empty line after the title
4493. Detailed explanation in English (what was changed and why)
4504. Empty line after English explanation
4515. Influence section in English with black-box testing recommendations
452
4537. Chinese title and explanation (translate the English content)
4548. Empty line after Chinese explanation
4559. Chinese Influence section (translate the English testing suggestions)
45610. Type must be one of: feat/fix/docs/style/refactor/test/chore
45711. Focus on both WHAT changed and WHY it was necessary
45812. Include any important technical details or context
45913. DO NOT wrap the response in any markdown or code block markers
460
461Example response format:
462feat: add user authentication module
463
4641. Implement JWT-based authentication system
4652. Add user login and registration endpoints
4663. Include password hashing with bcrypt
4674. Set up token refresh mechanism
468
469Influence:
4701. Test user registration with valid and invalid inputs
4712. Verify login functionality with correct and incorrect credentials
4723. Test JWT token generation and validation
4734. Verify password security and hashing
4745. Test token refresh mechanism and expiration handling
4756. Verify access control for protected endpoints
476
477feat: 添加用户认证模块
478
4791. 实现基于 JWT 的认证系统
4802. 添加用户登录和注册端点
4813. 包含使用 bcrypt 的密码哈希处理
4824. 设置令牌刷新机制
483
484Influence:
4851. 测试用户注册功能,包括有效和无效输入
4862. 验证登录功能,测试正确和错误的凭据
4873. 测试 JWT 令牌生成和验证流程
4884. 验证密码安全性和哈希处理
4895. 测试令牌刷新机制和过期处理
4906. 验证受保护端点的访问控制
491
492Please respond with ONLY the commit message following this format,
493DO NOT end commit titles with any punctuation."#;
494
495const ENGLISH_PROMPT_TEMPLATE_NO_TEST_NO_LOG: &str = r#"Please analyze the git diff content and generate a commit message in English only:
4971. First line: type: message (under 50 characters)
4982. Empty line after the title
4993. Detailed explanation in English (what was changed and why)
5004. Type must be one of: feat/fix/docs/style/refactor/test/chore
5015. Focus on both WHAT changed and WHY it was necessary
5026. Include any important technical details or context
5037. DO NOT include any Chinese content
5048. DO NOT wrap the response in any markdown or code block markers
505
506Example response format:
507feat: add user authentication module
508
5091. Implement JWT-based authentication system
5102. Add user login and registration endpoints
5113. Include password hashing with bcrypt
5124. Set up token refresh mechanism
513
514Please respond with ONLY the commit message following this format,
515DO NOT end commit titles with any punctuation."#;
516
517const CHINESE_PROMPT_TEMPLATE_NO_TEST_NO_LOG: &str = r#"请分析以下 git diff 内容,并按照以下格式生成提交信息:
5181. 第一行为标题:type: message(不超过50个字符)
5192. 标题下方空一行
5203. 详细的中文说明(解释做了什么改动以及为什么需要这些改动)
5214. type 必须是以下之一:feat/fix/docs/style/refactor/test/chore
5225. 关注点:变更内容(做了什么)和变更原因(为什么)
5236. 包含重要的技术细节或上下文
5247. 不要使用任何 markdown 或代码块标记
5258. 标题结尾不要使用标点符号
526
527示例格式:
528feat: 添加用户认证模块
529
5301. 实现基于 JWT 的认证系统
5312. 添加用户登录和注册端点
5323. 包含使用 bcrypt 的密码哈希处理
5334. 设置令牌刷新机制"#;
534
535const BILINGUAL_PROMPT_TEMPLATE_NO_TEST_NO_LOG: &str = r#"Please analyze the git diff content and generate a detailed bilingual commit message with:
5361. First line in English: type: message (under 50 characters)
5372. Empty line after the title
5383. Detailed explanation in English (what was changed and why)
5394. Empty line after English explanation
5405. Chinese title and explanation (translate the English content)
5416. Type must be one of: feat/fix/docs/style/refactor/test/chore
5427. Focus on both WHAT changed and WHY it was necessary
5438. Include any important technical details or context
5449. DO NOT wrap the response in any markdown or code block markers
545
546Example response format:
547feat: add user authentication module
548
5491. Implement JWT-based authentication system
5502. Add user login and registration endpoints
5513. Include password hashing with bcrypt
5524. Set up token refresh mechanism
553
554feat: 添加用户认证模块
555
5561. 实现基于 JWT 的认证系统
5572. 添加用户登录和注册端点
5583. 包含使用 bcrypt 的密码哈希处理
5594. 设置令牌刷新机制
560
561Please respond with ONLY the commit message following this format,
562DO NOT end commit titles with any punctuation."#;
563
564impl LanguageMode {
565 fn determine(only_chinese: bool, only_english: bool) -> Self {
566 if only_english {
567 Self::EnglishOnly
568 } else if only_chinese {
569 Self::ChineseOnly
570 } else {
571 Self::Bilingual
572 }
573 }
574
575 fn template(&self, include_test_suggestions: bool, include_log: bool) -> &'static str {
576 match (self, include_test_suggestions, include_log) {
577 (Self::EnglishOnly, true, true) => ENGLISH_PROMPT_TEMPLATE,
578 (Self::EnglishOnly, false, true) => ENGLISH_PROMPT_TEMPLATE_NO_TEST,
579 (Self::EnglishOnly, true, false) => ENGLISH_PROMPT_TEMPLATE_NO_LOG,
580 (Self::EnglishOnly, false, false) => ENGLISH_PROMPT_TEMPLATE_NO_TEST_NO_LOG,
581 (Self::ChineseOnly, true, true) => CHINESE_PROMPT_TEMPLATE,
582 (Self::ChineseOnly, false, true) => CHINESE_PROMPT_TEMPLATE_NO_TEST,
583 (Self::ChineseOnly, true, false) => CHINESE_PROMPT_TEMPLATE_NO_LOG,
584 (Self::ChineseOnly, false, false) => CHINESE_PROMPT_TEMPLATE_NO_TEST_NO_LOG,
585 (Self::Bilingual, true, true) => BILINGUAL_PROMPT_TEMPLATE,
586 (Self::Bilingual, false, true) => BILINGUAL_PROMPT_TEMPLATE_NO_TEST,
587 (Self::Bilingual, true, false) => BILINGUAL_PROMPT_TEMPLATE_NO_LOG,
588 (Self::Bilingual, false, false) => BILINGUAL_PROMPT_TEMPLATE_NO_TEST_NO_LOG,
589 }
590 }
591}
592
593fn build_prompt(mode: LanguageMode, user_message: Option<&str>, include_test_suggestions: bool, include_log: bool, original_message: Option<&str>) -> String {
595 let mut prompt = String::from(mode.template(include_test_suggestions, include_log));
596
597 if let Some(orig_msg) = original_message {
599 match mode {
600 LanguageMode::ChineseOnly => {
601 prompt.push_str(&format!("\n\n原始提交信息(请参考但不要完全照搬):\n{}\n", orig_msg));
602 }
603 _ => {
604 prompt.push_str(&format!("\n\nOriginal commit message (for reference, but create improved version):\n{}\n", orig_msg));
605 }
606 }
607 }
608
609 if let Some(msg) = user_message {
610 match mode {
611 LanguageMode::ChineseOnly => {
612 prompt.push_str(&format!("\n\n用户描述:\n{}\n\n变更内容:\n", msg));
613 }
614 _ => {
615 prompt.push_str(&format!("\n\nUser Description:\n{}\n\nChanges:\n", msg));
616 }
617 }
618 } else {
619 match mode {
620 LanguageMode::ChineseOnly => {
621 prompt.push_str("\n\n变更内容:\n");
622 }
623 _ => {
624 prompt.push_str("\n\nHere are the changes:\n");
625 }
626 }
627 }
628
629 prompt
630}
631
632pub struct CommitMessage {
633 pub title: String,
634 pub body: Option<String>,
635 pub marks: Vec<String>,
636}
637
638impl CommitMessage {
639 pub fn parse(content: &str) -> Self {
640 let mark_regex = Regex::new(r"^[a-zA-Z-]+:\s*.+$").unwrap();
641 let comment_regex = Regex::new(r"^#.*$").unwrap();
642 let mut lines = content.lines().peekable();
643
644 let title = lines
646 .by_ref()
647 .find(|line| !comment_regex.is_match(line.trim()))
648 .unwrap_or("")
649 .to_string();
650
651 let mut body = Vec::new();
652 let mut marks = Vec::new();
653 let mut is_body = false;
654
655 while let Some(line) = lines.next() {
656 if comment_regex.is_match(line.trim()) {
658 continue;
659 }
660
661 if line.trim().is_empty() {
662 if !is_body && body.is_empty() {
663 continue;
664 }
665 is_body = true;
666 body.push(line.to_string());
667 } else if mark_regex.is_match(line) {
668 marks.push(line.to_string());
669 } else {
670 is_body = true;
671 body.push(line.to_string());
672 }
673 }
674
675 while body.last().map_or(false, |line| line.trim().is_empty()) {
677 body.pop();
678 }
679
680 let body = if body.is_empty() {
682 None
683 } else {
684 Some(body
685 .into_iter()
686 .filter(|line| !comment_regex.is_match(line.trim()))
687 .collect::<Vec<_>>()
688 .join("\n"))
689 };
690
691 CommitMessage {
692 title,
693 body,
694 marks,
695 }
696 }
697
698 pub fn format(&self) -> String {
699 let mut result = Vec::new();
700 result.push(self.title.clone());
701
702 if let Some(body) = &self.body {
703 if !body.is_empty() {
704 result.push(String::new());
705 result.push(body.clone());
706 }
707 }
708
709 if !self.marks.is_empty() {
711 if !result.last().map_or(false, |s| s.is_empty()) {
712 result.push(String::new()); }
714 result.extend(self.marks.clone());
715 }
716
717 result.join("\n")
718 }
719}
720
721use crate::review;
722use dialoguer::Confirm;
723use log::{debug, info};
724use std::process::Command;
725
726pub async fn generate_commit_message(
727 commit_type: Option<String>,
728 message: Option<String>,
729 auto_add: bool,
730 amend: bool,
731 no_review: bool,
732 no_translate: bool,
733 mut only_chinese: bool,
734 mut only_english: bool,
735 no_influence: bool,
736 no_log: bool,
737 issues: Option<String>,
738) -> anyhow::Result<()> {
739 if let Ok(config) = config::Config::load() {
741 if !only_chinese && !only_english {
742 only_chinese = config.only_chinese;
743 only_english = config.only_english;
744 }
745 }
746
747 if only_english {
749 only_chinese = false;
750 } else if only_chinese {
751 only_english = false;
752 }
753
754 if auto_add {
756 info!("自动添加已修改的文件...");
757 let status = Command::new("git")
758 .args(["add", "-u"])
759 .status()?;
760
761 if !status.success() {
762 return Err(anyhow::anyhow!("执行 git add -u 命令失败"));
763 }
764 }
765
766 if no_translate {
768 std::env::set_var("GIT_COMMIT_HELPER_NO_TRANSLATE", "1");
769 }
770
771 let diff = if amend {
773 println!("正在分析上一次提交的更改内容...");
774 git::get_last_commit_diff()?
775 } else {
776 get_staged_diff()?
777 };
778
779 if diff.is_empty() {
780 if amend {
781 return Err(anyhow::anyhow!("无法获取上一次提交的差异内容,可能没有足够的提交历史"));
782 } else {
783 return Err(anyhow::anyhow!("没有已暂存的改动,请先使用 git add 添加改动"));
784 }
785 }
786
787 let config = config::Config::load()?;
788
789 if !amend && !no_review && config.ai_review {
791 info!("正在进行代码审查...");
792 if let Some(review) = review::review_changes(&config, no_review).await? {
793 println!("\n{}\n", review);
794 }
795 }
796
797 std::env::set_var("GIT_COMMIT_HELPER_SKIP_REVIEW", "1");
799
800 let language_mode = LanguageMode::determine(only_chinese, only_english);
802 let include_test_suggestions = !no_influence;
803 let include_log = !no_log;
804
805 let (original_message, original_change_id) = if amend {
807 match git::get_last_commit_message() {
808 Ok(msg) => {
809 let change_id = extract_change_id(&msg);
810 (Some(msg), change_id)
811 }
812 Err(_) => (None, None)
813 }
814 } else {
815 (None, None)
816 };
817
818 let prompt = build_prompt(
819 language_mode,
820 message.as_deref(),
821 include_test_suggestions,
822 include_log,
823 original_message.as_deref()
824 );
825
826 debug!("生成的提示信息:\n{}", prompt);
827
828 info!("使用 {:?} 服务生成提交信息", config.default_service);
829 let service = config.get_default_service()?;
830 let translator = ai_service::create_translator_for_service(service).await?;
831
832 if amend {
833 println!("\n正在基于上一次提交的更改生成新的提交信息...");
834 if let Some(ref original_msg) = original_message {
836 println!("原提交信息:");
837 println!("----------------------------------------");
838 println!("{}", original_msg.trim());
839 println!("----------------------------------------\n");
840 }
841 } else {
842 println!("\n正在生成提交信息建议...");
843 }
844
845 let mut message = translator.chat(&prompt, &diff).await?
846 .trim_start_matches("[NO_TRANSLATE]")
847 .trim_start_matches("、、、plaintext")
848 .trim()
849 .to_string();
850
851 if let Some(t) = commit_type {
853 message = ensure_commit_type(&message, &[t]);
854 }
855
856 let mut content = message.lines().map(|line| {
858 if line.trim().is_empty() {
859 line.to_string()
860 } else {
861 git::wrap_text(line, 72)
862 }
863 }).collect::<Vec<_>>().join("\n");
864
865 if let Some(issues_str) = issues {
867 match parse_issue_reference(&issues_str) {
868 Ok(reference) => {
869 if !content.ends_with('\n') {
871 content.push('\n');
872 }
873 content.push('\n');
874 content.push_str(&reference);
875 }
876 Err(e) => {
877 eprintln!("警告: 解析 issues 参数失败: {}", e);
878 }
879 }
880 }
881
882 if amend {
885 if let Some(ref orig_msg) = original_message {
886 let orig_commit = CommitMessage::parse(orig_msg);
887 let marks_to_add: Vec<String> = orig_commit.marks.iter()
888 .filter(|mark| {
889 let mark_key = mark.split(':').next().unwrap_or("").trim().to_lowercase();
890 mark_key != "change-id" && !content.lines().any(|line| {
891 line.trim().split(':').next()
892 .map_or(false, |k| k.trim().to_lowercase() == mark_key)
893 })
894 })
895 .cloned()
896 .collect();
897 if !marks_to_add.is_empty() {
898 if !content.ends_with('\n') {
899 content.push('\n');
900 }
901 content.push('\n');
902 content.push_str(&marks_to_add.join("\n"));
903 }
904 }
905 if let Some(change_id) = original_change_id {
906 content = append_change_id(&content, &change_id);
907 }
908 }
909
910 if amend {
912 println!("\n生成的修改后提交信息预览:");
913 } else {
914 println!("\n生成的提交信息预览:");
915 }
916 println!("----------------------------------------");
917 println!("{}", content);
918 println!("----------------------------------------");
919
920 let prompt_text = if amend {
922 "是否使用此提交信息修改上一次提交?"
923 } else {
924 "是否使用此提交信息?"
925 };
926
927 if !Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
928 .with_prompt(prompt_text)
929 .default(true)
930 .interact()?
931 {
932 if amend {
933 println!("已取消修改上一次提交");
934 } else {
935 println!("已取消提交");
936 }
937 return Ok(());
938 }
939
940 let mut cmd = Command::new("git");
942 cmd.current_dir(std::env::current_dir()?);
943 cmd.arg("commit");
944
945 if amend {
946 cmd.arg("--amend");
947 }
948
949 cmd.arg("-m").arg(content);
950
951 let status = cmd.status()?;
952
953 std::env::remove_var("GIT_COMMIT_HELPER_SKIP_REVIEW");
955 std::env::remove_var("GIT_COMMIT_HELPER_NO_TRANSLATE");
956
957 if !status.success() {
958 let action = if amend { "修改提交" } else { "提交" };
959 return Err(anyhow::anyhow!("git commit 命令执行失败,{} 失败", action));
960 }
961
962 if amend {
963 println!("修改提交成功!");
964 } else {
965 println!("提交成功!");
966 }
967 Ok(())
968}
969
970#[allow(dead_code)]
971pub async fn generate_commit_suggestion(commit_types: &[String], user_description: Option<String>) -> anyhow::Result<String> {
972 let config = crate::config::Config::load()?;
973 let service = config.services.iter()
974 .find(|s| s.service == config.default_service)
975 .ok_or_else(|| anyhow::anyhow!("找不到默认服务的配置"))?;
976
977 let translator = ai_service::create_translator_for_service(service).await?;
978 let prompt = match user_description {
979 Some(desc) => format!("用户描述:\n{}\n\n改动内容:\n{}", desc, get_staged_diff()?),
980 None => get_staged_diff()?
981 };
982
983 let message = translator.translate(&prompt, &config::TranslateDirection::ChineseToEnglish).await?.to_string();
984
985 let message = message
987 .trim_start_matches("[NO_TRANSLATE]")
988 .trim_start_matches("plaintext")
989 .trim()
990 .to_string();
991
992 if !commit_types.is_empty() {
994 return Ok(ensure_commit_type(&message, commit_types));
995 }
996
997 Ok(message)
998}
999
1000fn get_staged_diff() -> anyhow::Result<String> {
1001 let output = Command::new("git")
1002 .args(["diff", "--cached", "--no-prefix"])
1003 .output()?;
1004
1005 if !output.status.success() {
1006 return Err(anyhow::anyhow!("执行 git diff 命令失败"));
1007 }
1008
1009 Ok(String::from_utf8(output.stdout)?)
1010}
1011
1012fn ensure_commit_type(message: &str, commit_types: &[String]) -> String {
1013 let first_line = message.lines().next().unwrap_or_default();
1014
1015 if let Some(colon_pos) = first_line.find(':') {
1016 let current_type = first_line[..colon_pos].trim();
1017 if !commit_types.contains(¤t_type.to_string()) {
1018 return format!("{}: {}",
1019 &commit_types[0],
1020 first_line[colon_pos + 1..].trim()
1021 ) + &message[first_line.len()..];
1022 }
1023 } else {
1024 return format!("{}: {}", &commit_types[0], first_line) + &message[first_line.len()..];
1025 }
1026
1027 message.to_string()
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[test]
1035 fn test_parse_github_issue_url() {
1036 let url = "https://github.com/zccrs/git-commit-helper/issues/123";
1037 let result = parse_github_issue(url).unwrap();
1038 assert!(result.contains("#123") || result.contains("zccrs/git-commit-helper#123"));
1040 }
1041
1042 #[test]
1043 fn test_parse_pms_bug_link() {
1044 let url = "https://pms.uniontech.com/bug-view-320461.html";
1045 let result = parse_pms_link(url).unwrap();
1046 assert_eq!(result, "PMS: BUG-320461");
1047 }
1048
1049 #[test]
1050 fn test_parse_pms_task_link() {
1051 let url = "https://pms.uniontech.com/task-view-374223.html";
1052 let result = parse_pms_link(url).unwrap();
1053 assert_eq!(result, "PMS: TASK-374223");
1054 }
1055
1056 #[test]
1057 fn test_parse_pms_story_link() {
1058 let url = "https://pms.uniontech.com/story-view-38949.html";
1059 let result = parse_pms_link(url).unwrap();
1060 assert_eq!(result, "PMS: STORY-38949");
1061 }
1062
1063 #[test]
1064 fn test_parse_issue_number() {
1065 let issue = "123";
1066 let result = parse_issue_reference(issue).unwrap();
1067 assert_eq!(result, "Fixes: #123");
1068 }
1069
1070 #[test]
1071 fn test_parse_invalid_format() {
1072 let invalid = "invalid-format";
1073 let result = parse_issue_reference(invalid);
1074 assert!(result.is_err());
1075 }
1076
1077 #[test]
1078 fn test_parse_multiple_issues() {
1079 let issues = "123 456 https://github.com/owner/repo/issues/789";
1080 let result = parse_issue_reference(issues).unwrap();
1081 assert!(result.contains("Fixes: #123 #456"));
1082 assert!(result.contains("owner/repo#789") || result.contains("#789"));
1083 }
1084
1085 #[test]
1086 fn test_parse_multiple_pms_links() {
1087 let issues = "https://pms.uniontech.com/bug-view-320461.html https://pms.uniontech.com/task-view-374223.html https://pms.uniontech.com/story-view-38949.html";
1088 let result = parse_pms_link_multiple(issues).unwrap();
1089 assert_eq!(result, "PMS: BUG-320461 TASK-374223 STORY-38949");
1090 }
1091
1092 #[test]
1093 fn test_parse_mixed_issues_and_pms() {
1094 let issues = "123 https://pms.uniontech.com/bug-view-320461.html https://github.com/owner/repo/issues/456";
1095 let result = parse_issue_reference(issues).unwrap();
1096
1097 let lines: Vec<&str> = result.lines().collect();
1099 assert_eq!(lines.len(), 2);
1100
1101 let fixes_line = lines.iter().find(|&&line| line.starts_with("Fixes:")).unwrap();
1102 let pms_line = lines.iter().find(|&&line| line.starts_with("PMS:")).unwrap();
1103
1104 assert!(fixes_line.contains("#123"));
1105 assert!(fixes_line.contains("owner/repo#456") || fixes_line.contains("#456"));
1106 assert!(pms_line.contains("BUG-320461"));
1107 }
1108
1109 #[test]
1110 fn test_parse_comma_separated_issues() {
1111 let issues = "123,456,789";
1112 let result = parse_issue_reference(issues).unwrap();
1113 assert_eq!(result, "Fixes: #123 #456 #789");
1114 }
1115
1116 #[test]
1117 fn test_parse_mixed_separators() {
1118 let issues = "123 456,789 https://pms.uniontech.com/task-view-374223.html";
1119 let result = parse_issue_reference(issues).unwrap();
1120
1121 let lines: Vec<&str> = result.lines().collect();
1122 assert_eq!(lines.len(), 2);
1123
1124 let fixes_line = lines.iter().find(|&&line| line.starts_with("Fixes:")).unwrap();
1125 let pms_line = lines.iter().find(|&&line| line.starts_with("PMS:")).unwrap();
1126
1127 assert_eq!(*fixes_line, "Fixes: #123 #456 #789");
1128 assert_eq!(*pms_line, "PMS: TASK-374223");
1129 }
1130
1131 fn parse_pms_link_multiple(issues: &str) -> anyhow::Result<String> {
1133 parse_issue_reference(issues)
1134 }
1135
1136 #[test]
1137 fn test_extract_change_id() {
1138 let message = "feat: add new feature\n\nThis is a commit message\n\nChange-Id: I1234567890abcdef1234567890abcdef12345678\n";
1139 let change_id = extract_change_id(message);
1140 assert_eq!(change_id, Some("I1234567890abcdef1234567890abcdef12345678".to_string()));
1141 }
1142
1143 #[test]
1144 fn test_extract_change_id_not_found() {
1145 let message = "feat: add new feature\n\nThis is a commit message without change id\n";
1146 let change_id = extract_change_id(message);
1147 assert_eq!(change_id, None);
1148 }
1149
1150 #[test]
1151 fn test_append_change_id() {
1152 let message = "feat: add new feature\n\nThis is a commit message\n";
1153 let change_id = "I1234567890abcdef1234567890abcdef12345678";
1154 let result = append_change_id(message, change_id);
1155 assert!(result.contains("Change-Id: I1234567890abcdef1234567890abcdef12345678"));
1156 }
1157
1158 #[test]
1159 fn test_append_change_id_already_exists() {
1160 let message = "feat: add new feature\n\nThis is a commit message\n\nChange-Id: I1234567890abcdef1234567890abcdef12345678\n";
1161 let change_id = "I1234567890abcdef1234567890abcdef12345678";
1162 let result = append_change_id(message, change_id);
1163 assert_eq!(result, message);
1165 }
1166
1167 #[test]
1168 fn test_append_change_id_with_other_marks() {
1169 let message = "feat: add new feature\n\nThis is a commit message\n\nFixes: #123\nPMS: BUG-456\n";
1170 let change_id = "I1234567890abcdef1234567890abcdef12345678";
1171 let result = append_change_id(message, change_id);
1172 assert!(result.contains("Fixes: #123"));
1173 assert!(result.contains("PMS: BUG-456"));
1174 assert!(result.contains("Change-Id: I1234567890abcdef1234567890abcdef12345678"));
1175 assert!(result.ends_with("Change-Id: I1234567890abcdef1234567890abcdef12345678"));
1177 }
1178
1179 #[test]
1181 fn test_extract_issue_marks_from_original_commit() {
1182 let original = "fix: some bug\n\nDescription here\n\nFixes: #123\nPMS: BUG-456\nLog: Fix critical bug\nChange-Id: Iabc123\n";
1183 let orig_commit = CommitMessage::parse(original);
1184 let marks_to_preserve: Vec<String> = orig_commit.marks.iter()
1185 .filter(|mark| {
1186 let mark_key = mark.split(':').next().unwrap_or("").trim().to_lowercase();
1187 mark_key != "change-id"
1188 })
1189 .cloned()
1190 .collect();
1191 assert_eq!(marks_to_preserve.len(), 3);
1192 assert!(marks_to_preserve.contains(&"Fixes: #123".to_string()));
1193 assert!(marks_to_preserve.contains(&"PMS: BUG-456".to_string()));
1194 assert!(marks_to_preserve.contains(&"Log: Fix critical bug".to_string()));
1195 assert!(!marks_to_preserve.iter().any(|m| m.to_lowercase().starts_with("change-id:")));
1197 }
1198
1199 #[test]
1201 fn test_amend_no_duplicate_issue_marks() {
1202 let original = "fix: some bug\n\nFixes: #123\nLog: Fix bug\n";
1203 let orig_commit = CommitMessage::parse(original);
1204
1205 let new_content = "fix: improved bug fix\n\nBetter description\n\nFixes: #123\nLog: Fix bug";
1207 let marks_to_add: Vec<String> = orig_commit.marks.iter()
1208 .filter(|mark| {
1209 let mark_key = mark.split(':').next().unwrap_or("").trim().to_lowercase();
1210 mark_key != "change-id" && !new_content.lines().any(|line| {
1211 line.trim().split(':').next()
1212 .map_or(false, |k| k.trim().to_lowercase() == mark_key)
1213 })
1214 })
1215 .cloned()
1216 .collect();
1217 assert!(marks_to_add.is_empty());
1219 }
1220
1221 #[test]
1223 fn test_amend_preserves_issue_marks_when_missing() {
1224 let original = "fix: some bug\n\nFixes: #123\nPMS: BUG-456\nLog: Fix critical bug\nChange-Id: Iabc123\n";
1225 let orig_commit = CommitMessage::parse(original);
1226
1227 let new_content = "fix: improved bug fix\n\nBetter description";
1229 let marks_to_add: Vec<String> = orig_commit.marks.iter()
1230 .filter(|mark| {
1231 let mark_key = mark.split(':').next().unwrap_or("").trim().to_lowercase();
1232 mark_key != "change-id" && !new_content.lines().any(|line| {
1233 line.trim().split(':').next()
1234 .map_or(false, |k| k.trim().to_lowercase() == mark_key)
1235 })
1236 })
1237 .cloned()
1238 .collect();
1239 assert_eq!(marks_to_add.len(), 3);
1240 assert!(marks_to_add.contains(&"Fixes: #123".to_string()));
1241 assert!(marks_to_add.contains(&"PMS: BUG-456".to_string()));
1242 assert!(marks_to_add.contains(&"Log: Fix critical bug".to_string()));
1243 assert!(!marks_to_add.iter().any(|m| m.to_lowercase().starts_with("change-id:")));
1245 }
1246}