y.y
Published on

使用Rust构建一个JIRA集成的Git提交工具

Authors

使用Rust编写命令行工具自动生成带有JIRA信息的Git提交消息

在日常开发中,我们经常需要在提交代码时手动添加一些固定格式的内容,比如JIRA ID和标题。这篇博文将介绍如何使用Rust编写一个命令行工具,自动生成带有JIRA信息的Git提交消息,并确保密码在配置文件中的安全存储。

工具功能

  1. 根据当前Git分支名中的JIRA ID自动获取JIRA标题,并在控制台中输出JIRA ID和标题。
  2. 提示用户输入附加到Git提交消息中的内容。
  3. 执行git commit命令,生成格式为[JIRA ID] JIRA标题 附加内容的提交消息。
  4. 确保密码在配置文件中的安全存储。

前提

当前分支名会使用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(&params)
        .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提交消息。我们不仅简化了提交过程,还确保了密码的安全存储。这种工具可以极大地提高开发效率,减少手动错误。希望这篇文章对您有所帮助,并激发您开发更多自动化工具的兴趣。