- Published on
使用Rust构建一个JIRA集成的Git提交工具
- Authors
- Name
- Yvan Yang
使用Rust编写命令行工具自动生成带有JIRA信息的Git提交消息
在日常开发中,我们经常需要在提交代码时手动添加一些固定格式的内容,比如JIRA ID和标题。这篇博文将介绍如何使用Rust编写一个命令行工具,自动生成带有JIRA信息的Git提交消息,并确保密码在配置文件中的安全存储。
工具功能
- 根据当前Git分支名中的JIRA ID自动获取JIRA标题,并在控制台中输出JIRA ID和标题。
- 提示用户输入附加到Git提交消息中的内容。
- 执行
git commit
命令,生成格式为[JIRA ID] JIRA标题 附加内容
的提交消息。 - 确保密码在配置文件中的安全存储。
前提
当前分支名会使用JIRA的ID作为分支名的一部分,例如feature/JIRA-1234-add-login-feature
。
项目结构
jira_git_helper/
├── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── config.rs
├── tests/
│ ├── integration_test.rs
│ ├── unit_tests.rs
├── .gitignore
├── Cargo.toml
├── README.md
代码实现
src/config.rs
配置模块包含配置的读取、写入和重置逻辑,并确保密码加密和解密。
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use dirs::home_dir;
use ring::{aead, rand};
use ring::rand::SecureRandom;
use base64::{encode, decode};
use ring::error::Unspecified;
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
pub username: String,
pub password: String,
pub jira_url: String,
pub jira_id_prefix: String,
pub jsessionid: Option<String>,
pub xsrf_token: Option<String>,
}
impl Config {
pub fn new(username: String, password: String, jira_url: String, jira_id_prefix: String) -> Self {
Config {
username,
password: encrypt_password(&password).unwrap(),
jira_url,
jira_id_prefix,
jsessionid: None,
xsrf_token: None
}
}
}
pub fn get_config_path() -> PathBuf {
let mut config_path = home_dir().expect("Could not find home directory");
config_path.push(".jira_git_helper");
config_path
}
pub fn read_config() -> Result<Config, &'static str> {
let config_path = get_config_path();
if config_path.exists() {
let config_data = fs::read_to_string(config_path).map_err(|_| "Failed to read config file")?;
let mut config: Config = serde_json::from_str(&config_data).map_err(|_| "Failed to parse config file")?;
config.password = decrypt_password(&config.password).map_err(|_| "Failed to decrypt password")?;
Ok(config)
} else {
Err("Config file not found")
}
}
pub fn write_config(config: &Config) -> Result<(), &'static str> {
let mut config = config.clone();
config.password = encrypt_password(&config.password).unwrap();
let config_data = serde_json::to_string(&config).map_err(|_| "Failed to serialize config data")?;
let config_path = get_config_path();
fs::write(config_path, config_data).map_err(|_| "Failed to write config file")
}
pub fn reset_config() -> Result<(), &'static str> {
let config_path = get_config_path();
if config_path.exists() {
fs::remove_file(config_path).map_err(|_| "Failed to delete config file")
} else {
Ok(())
}
}
fn encrypt_password(password: &str) -> Result<String, Unspecified> {
let mut key = [0; 32];
let rng = rand::SystemRandom::new();
rng.fill(&mut key)?;
let mut nonce_bytes = [0; 12];
rng.fill(&mut nonce_bytes)?;
let nonce = aead::Nonce::assume_unique_for_key(nonce_bytes);
let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, &key)?;
let sealing_key = aead::LessSafeKey::new(unbound_key);
let mut in_out = password.as_bytes().to_vec();
sealing_key.seal_in_place_append_tag(nonce, aead::Aad::empty(), &mut in_out)?;
let mut result = Vec::new();
result.extend_from_slice(&key);
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&in_out);
Ok(encode(&result))
}
fn decrypt_password(encoded: &str) -> Result<String, Unspecified> {
let decoded = decode(encoded).map_err(|_| Unspecified)?;
if decoded.len() < 44 {
return Err(Unspecified);
}
let key = &decoded[0..32];
let nonce = aead::Nonce::assume_unique_for_key(decoded[32..44].try_into().map_err(|_| Unspecified)?);
let ciphertext = &decoded[44..];
let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key).map_err(|_| Unspecified)?;
let opening_key = aead::LessSafeKey::new(unbound_key);
let mut in_out = ciphertext.to_vec();
let plaintext = opening_key.open_in_place(nonce, aead::Aad::empty(), &mut in_out).map_err(|_| Unspecified)?;
Ok(String::from_utf8(plaintext.to_vec()).map_err(|_| Unspecified)?)
}
src/lib.rs
核心逻辑,包括获取当前分支、提取JIRA ID、登录JIRA、获取JIRA标题等。
pub mod config;
use std::process::Command;
use std::io::{self, Write};
use reqwest::Client;
use reqwest::header::{COOKIE, SET_COOKIE};
use regex::Regex;
use config::{Config, read_config, write_config, reset_config};
use serde::Deserialize;
pub async fn get_current_branch() -> Result<String, &'static str> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--abbrev-ref")
.arg("HEAD")
.output()
.map_err(|_| "Failed to execute git command")?;
if !output.status.success() {
return Err("Failed to get current branch");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn extract_jira_id<'a>(branch_name: &'a str, jira_id_prefix: &str) -> Option<&'a str> {
let re = Regex::new(&format!(r"{}-\d+", regex::escape(jira_id_prefix))).unwrap();
re.find(branch_name).map(|m| m.as_str())
}
pub async fn login_to_jira(config: &mut Config) -> Result<(), &'static str> {
let login_url = format!("{}/rest/gadget/1.0/login", config.jira_url);
let client = Client::new();
let params = [("os_username", &config.username), ("os_password", &config.password)];
let response = client
.post(&login_url)
.form(¶ms)
.send()
.await
.map_err(|_| "Failed to send login request")?;
if !response.status().is_success() {
return Err("Failed to login to JIRA");
}
let cookies = response
.headers()
.get_all(SET_COOKIE)
.into_iter()
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
for cookie in cookies {
if cookie.starts_with("JSESSIONID") {
config.jsessionid = Some(cookie.to_string());
}
if cookie.contains("atlassian.xsrf.token") {
let token = cookie.split('=').nth(1).unwrap().split(';').next().unwrap();
config.xsrf_token = Some(token.to_string());
}
}
write_config(&config).map_err(|_| "Failed to write config")?;
Ok(())
}
pub async fn get_jira_title(jira_id: &str, config: &mut Config) -> Result<String, &'static str> {
let jira_api_url = format!("{}/rest/api/2/issue/{}", config.jira_url, jira_id);
let client = Client::new();
let mut headers = reqwest::header::HeaderMap::new();
if let Some(ref jsessionid) = config.jsessionid {
headers.insert(COOKIE, jsessionid.parse().unwrap());
}
if let Some(ref xsrf_token) = config.xsrf_token {
headers.insert("X-Atlassian-Token", xsrf_token.parse().unwrap());
}
let response = client
.get(&jira_api_url)
.headers(headers)
.send()
.await
.map_err(|_| "Failed to send request")?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED || response.status() == reqwest::StatusCode::FORBIDDEN {
login_to_jira(config).await?;
let mut headers = reqwest::header
::HeaderMap::new();
headers.insert(COOKIE, config.jsessionid.as_ref().unwrap().parse().unwrap());
headers.insert("X-Atlassian-Token", config.xsrf_token.as_ref().unwrap().parse().unwrap());
let response = client
.get(&jira_api_url)
.headers(headers)
.send()
.await
.map_err(|_| "Failed to send request")?;
if !response.status().is_success() {
return Err("Failed to get JIRA issue");
}
let issue: JiraIssue = response.json().await.map_err(|_| "Failed to parse JSON response")?;
Ok(issue.fields.summary)
} else if response.status().is_success() {
let issue: JiraIssue = response.json().await.map_err(|_| "Failed to parse JSON response")?;
Ok(issue.fields.summary)
} else {
Err("Failed to get JIRA issue")
}
}
#[derive(Deserialize)]
struct JiraIssue {
fields: JiraFields,
}
#[derive(Deserialize)]
struct JiraFields {
summary: String,
}
pub fn prompt_for_input(prompt: &str, default: Option<&str>) -> String {
print!("{} [{}]: ", prompt, default.unwrap_or(""));
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let input = input.trim().to_string();
if input.is_empty() {
default.unwrap_or("").to_string()
} else {
input
}
}
pub fn prompt_for_config() -> Config {
let jira_url = prompt_for_input("Enter your JIRA URL", None);
let username = prompt_for_input("Enter your domain username", None);
print!("Enter your domain password: ");
io::stdout().flush().unwrap();
let password = rpassword::read_password().expect("Failed to read password");
let jira_id_prefix = prompt_for_input("Enter your JIRA ID prefix", Some("JIRA"));
Config { username, password, jira_url, jira_id_prefix, jsessionid: None, xsrf_token: None }
}
pub fn prompt_for_commit_message() -> String {
prompt_for_input("Enter additional commit message", None)
}
pub fn confirm_commit(commit_message: &str) -> bool {
println!("Git commit command: git commit -m \"{}\"", commit_message);
let input = prompt_for_input("Do you want to proceed? (y/n)", Some("y"));
matches!(input.to_lowercase().as_str(), "y" | "yes" | "")
}
pub fn run_git_commit(commit_message: &str) -> Result<(), &'static str> {
let status = Command::new("git")
.arg("commit")
.arg("-m")
.arg(commit_message)
.status()
.map_err(|_| "Failed to execute git commit")?;
if !status.success() {
return Err("git commit failed");
}
Ok(())
}
src/main.rs
主函数逻辑,处理命令行参数并调用相关功能。
use jira_git_helper::{
config::{read_config, write_config, reset_config},
extract_jira_id, get_current_branch, get_jira_title, login_to_jira, prompt_for_commit_message,
prompt_for_config, run_git_commit, confirm_commit,
};
use tokio;
use std::env;
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
if args.len() > 1 && args[1] == "reset" {
reset_config().expect("Failed to reset config");
println!("Configuration reset successfully.");
return;
}
let mut config = read_config().unwrap_or_else(|_| {
let config = prompt_for_config();
write_config(&config).expect("Failed to write config");
config
});
if config.jsessionid.is_none() || config.xsrf_token.is_none() {
login_to_jira(&mut config).await.expect("Failed to login to JIRA");
}
let branch_name = get_current_branch().await.expect("Failed to get current branch");
let jira_id = extract_jira_id(&branch_name, &config.jira_id_prefix).expect("JIRA ID not found in branch name");
let jira_title = get_jira_title(jira_id, &mut config).await.expect("Failed to get JIRA title");
println!("JIRA ID: {}", jira_id);
println!("JIRA Title: {}", jira_title);
let additional_message = prompt_for_commit_message();
let commit_message = format!("[{}] {} {}", jira_id, jira_title, additional_message);
if confirm_commit(&commit_message) {
run_git_commit(&commit_message).expect("Failed to run git commit");
} else {
println!("Commit cancelled.");
}
}
测试
确保所有功能正常工作,并提供单元测试和集成测试。
tests/unit_tests.rs
use jira_git_helper::config::{Config, read_config, write_config, reset_config};
use jira_git_helper::extract_jira_id;
#[test]
fn test_extract_jira_id() {
let branch_name = "feature/JIRA-1234-add-login-feature";
assert_eq!(extract_jira_id(branch_name, "JIRA"), Some("JIRA-1234"));
}
#[tokio::test]
async fn test_config_read_write() {
let config = Config::new(
"user".to_string(),
"pass".to_string(),
"http://example.com".to_string(),
"JIRA".to_string()
);
write_config(&config).expect("Failed to write config");
let read_config = read_config().expect("Failed to read config");
assert_eq!(config.username, read_config.username);
assert_eq!(config.password, read_config.password);
assert_eq!(config.jira_url, read_config.jira_url);
assert_eq!(config.jira_id_prefix, read_config.jira_id_prefix);
// Clean up
reset_config().expect("Failed to reset config");
}
#[test]
fn test_reset_config() {
let config = Config::new(
"user".to_string(),
"pass".to_string(),
"http://example.com".to_string(),
"JIRA".to_string()
);
write_config(&config).expect("Failed to write config");
reset_config().expect("Failed to reset config");
assert!(read_config().is_err());
}
tests/integration_test.rs
use jira_git_helper::{prompt_for_config, read_config, write_config, get_current_branch, extract_jira_id, get_jira_title, run_git_commit, login_to_jira, reset_config};
#[tokio::test]
async fn test_integration() {
// Assuming JIRA credentials and URLs are correct
let mut config = prompt_for_config();
write_config(&config).expect("Failed to write config");
login_to_jira(&mut config).await.expect("Failed to login to JIRA");
let branch_name = "feature/JIRA-1234-add-login-feature";
let jira_id = extract_jira_id(branch_name, &config.jira_id_prefix).expect("Failed to extract JIRA ID");
let jira_title = get_jira_title(jira_id, &config).await.expect("Failed to get JIRA title");
assert!(!jira_title.is_empty(), "JIRA title should not be empty");
let commit_message = format!("[{}] {} {}", jira_id, jira_title, "Test commit message");
run_git_commit(&commit_message).expect("Failed to run git commit");
// Clean up
reset_config().expect("Failed to reset config");
}
使用方法
安装工具
首先确保已经编译和安装了工具:
cargo build --release
cargo install --path .
初始化配置
首次运行工具时,需要进行配置。工具会提示您输入必要的配置信息,包括JIRA URL、用户名、密码和JIRA ID前缀。
运行工具:
jira_git_helper
输出示例:
Enter your JIRA URL: [ ]
Enter your domain username: [ ]
Enter your domain password:
Enter your JIRA ID prefix: [JIRA]
使用工具生成提交消息
确保当前Git分支名中包含JIRA ID,例如feature/JIRA-1234-add-login-feature
。再次运行工具,它会根据分支名自动生成提交消息,并执行git commit
命令。
运行工具:
jira_git_helper
输出示例:
JIRA ID: JIRA-1234
JIRA Title: Add login feature
Enter additional commit message: Fixed login bug
Git commit command: git commit -m "[JIRA-1234] Add login feature Fixed login bug"
Do you want to proceed? (y/n) [y]:
生成并执行以下Git提交命令:
git commit -m "[JIRA-1234] Add login feature Fixed login bug"
重置配置
如果需要重置配置,可以使用reset
命令:
jira_git_helper reset
输出示例:
Configuration reset successfully.
源码
完整源码请访问GitHub仓库:jira_git_helper
总结
通过这篇文章,我们学会了如何使用Rust编写一个命令行工具,自动生成
带有JIRA信息的Git提交消息。我们不仅简化了提交过程,还确保了密码的安全存储。这种工具可以极大地提高开发效率,减少手动错误。希望这篇文章对您有所帮助,并激发您开发更多自动化工具的兴趣。