tuicr 0.14.0

Review AI-generated diffs like a GitHub pull request, right from your terminal.
use chrono::{DateTime, Utc};
use serde::Deserialize;

use crate::error::{Result, TuicrError};
use crate::forge::traits::{
    ForgeRepository, PullRequestCommit, PullRequestDetails, PullRequestSummary,
};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GhPullRequestSummary {
    pub number: u64,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub author: Option<GhAuthor>,
    #[serde(default)]
    pub head_ref_name: String,
    #[serde(default)]
    pub base_ref_name: String,
    #[serde(default)]
    pub updated_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub url: String,
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub is_draft: bool,
}

impl GhPullRequestSummary {
    pub fn into_summary(self, repository: &ForgeRepository) -> PullRequestSummary {
        PullRequestSummary {
            repository: repository.clone(),
            number: self.number,
            title: self.title,
            author: self.author.and_then(|author| author.login),
            head_ref_name: self.head_ref_name,
            base_ref_name: self.base_ref_name,
            updated_at: self.updated_at,
            url: self.url,
            state: self.state,
            is_draft: self.is_draft,
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GhPullRequestDetails {
    pub number: u64,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub url: String,
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub is_draft: bool,
    #[serde(default)]
    pub author: Option<GhAuthor>,
    #[serde(default)]
    pub head_ref_name: String,
    #[serde(default)]
    pub base_ref_name: String,
    #[serde(default)]
    pub head_ref_oid: String,
    #[serde(default)]
    pub base_ref_oid: String,
    #[serde(default)]
    pub body: String,
    #[serde(default)]
    pub updated_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub closed: bool,
    #[serde(default)]
    pub merged_at: Option<DateTime<Utc>>,
}

impl GhPullRequestDetails {
    pub fn into_details(self, repository: &ForgeRepository) -> Result<PullRequestDetails> {
        require_field(&self.head_ref_oid, "headRefOid")?;
        require_field(&self.base_ref_oid, "baseRefOid")?;

        Ok(PullRequestDetails {
            repository: repository.clone(),
            number: self.number,
            title: self.title,
            url: self.url,
            state: self.state,
            is_draft: self.is_draft,
            author: self.author.and_then(|author| author.login),
            head_ref_name: self.head_ref_name,
            base_ref_name: self.base_ref_name,
            head_sha: self.head_ref_oid,
            base_sha: self.base_ref_oid,
            body: self.body,
            updated_at: self.updated_at,
            closed: self.closed,
            merged_at: self.merged_at,
        })
    }
}

#[derive(Debug, Deserialize)]
pub struct GhAuthor {
    #[serde(default)]
    pub login: Option<String>,
}

/// Response shape for `gh api repos/<owner>/<repo>/pulls/<num>/commits`.
/// We only consume a small subset of fields; the rest are ignored.
#[derive(Debug, Deserialize)]
pub struct GhPrCommit {
    pub sha: String,
    #[serde(default)]
    pub commit: GhCommitDetails,
}

#[derive(Debug, Default, Deserialize)]
pub struct GhCommitDetails {
    #[serde(default)]
    pub message: String,
    #[serde(default)]
    pub author: Option<GhCommitAuthor>,
}

#[derive(Debug, Deserialize)]
pub struct GhCommitAuthor {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub email: Option<String>,
    #[serde(default)]
    pub date: Option<DateTime<Utc>>,
}

impl GhPrCommit {
    pub fn into_pull_request_commit(self) -> PullRequestCommit {
        let summary = self.commit.message.lines().next().unwrap_or("").to_string();
        let (author, timestamp) = match self.commit.author {
            Some(a) => (
                a.name.or(a.email).unwrap_or_else(|| "unknown".to_string()),
                a.date,
            ),
            None => ("unknown".to_string(), None),
        };
        let short_oid = self.sha.chars().take(7).collect();
        PullRequestCommit {
            oid: self.sha,
            short_oid,
            summary,
            author,
            timestamp,
        }
    }
}

fn require_field(value: &str, field: &str) -> Result<()> {
    if value.is_empty() {
        Err(TuicrError::Forge(format!(
            "GitHub response did not include required field `{field}`"
        )))
    } else {
        Ok(())
    }
}