quinjet 0.0.50

A fast, live, keyboard-first Git source-control interface for the terminal
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum PullRequestReviewSide {
    Left,
    Right,
    #[serde(other)]
    Unknown,
}

impl PullRequestReviewSide {
    pub(super) const fn graphql(self) -> &'static str {
        match self {
            Self::Left => "LEFT",
            Self::Right | Self::Unknown => "RIGHT",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum PullRequestReviewThreadSubject {
    File,
    Line,
    #[serde(other)]
    Unknown,
}

impl PullRequestReviewThreadSubject {
    pub(super) const fn graphql(self) -> &'static str {
        match self {
            Self::File => "FILE",
            Self::Line | Self::Unknown => "LINE",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum PullRequestReviewDecision {
    Comment,
    Approve,
    RequestChanges,
}

impl PullRequestReviewDecision {
    pub(crate) const ALL: [Self; 3] = [Self::Comment, Self::Approve, Self::RequestChanges];

    pub(crate) const fn label(self) -> &'static str {
        match self {
            Self::Comment => "Comment",
            Self::Approve => "Approve",
            Self::RequestChanges => "Request changes",
        }
    }

    pub(super) const fn graphql(self) -> &'static str {
        match self {
            Self::Comment => "COMMENT",
            Self::Approve => "APPROVE",
            Self::RequestChanges => "REQUEST_CHANGES",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PullRequestPendingReview {
    pub id: String,
    pub body: String,
    pub commit_oid: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PullRequestReviewComment {
    pub id: String,
    pub author: String,
    pub body: String,
    pub created_at: String,
    pub updated_at: String,
    pub url: String,
    pub state: String,
    pub viewer_did_author: bool,
    pub viewer_can_update: bool,
    pub viewer_can_delete: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[expect(
    clippy::struct_excessive_bools,
    reason = "the booleans are independent GitHub thread state and viewer capabilities"
)]
pub(crate) struct PullRequestReviewThread {
    pub id: String,
    pub path: PathBuf,
    pub side: PullRequestReviewSide,
    pub line: Option<usize>,
    pub original_line: Option<usize>,
    pub start_side: Option<PullRequestReviewSide>,
    pub start_line: Option<usize>,
    pub original_start_line: Option<usize>,
    pub subject: PullRequestReviewThreadSubject,
    pub is_resolved: bool,
    pub is_outdated: bool,
    pub resolved_by: Option<String>,
    pub viewer_can_reply: bool,
    pub viewer_can_resolve: bool,
    pub viewer_can_unresolve: bool,
    pub comments: Vec<PullRequestReviewComment>,
    pub comments_truncated: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PullRequestReviewSnapshot {
    pub pull_request_id: String,
    pub head_oid: String,
    pub review_decision: Option<String>,
    pub pending_review: Option<PullRequestPendingReview>,
    pub threads: Vec<PullRequestReviewThread>,
    pub truncated: bool,
}

impl PullRequestReviewSnapshot {
    pub(crate) fn unresolved_count(&self) -> usize {
        self.threads
            .iter()
            .filter(|thread| !thread.is_resolved)
            .count()
    }

    pub(crate) fn pending_comment_count(&self) -> usize {
        self.threads
            .iter()
            .flat_map(|thread| &thread.comments)
            .filter(|comment| comment.state.eq_ignore_ascii_case("PENDING"))
            .count()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PullRequestReviewOperation {
    AddThread {
        body: String,
        path: PathBuf,
        line: Option<usize>,
        side: Option<PullRequestReviewSide>,
        start_line: Option<usize>,
        start_side: Option<PullRequestReviewSide>,
        subject: PullRequestReviewThreadSubject,
    },
    Reply {
        thread_id: String,
        body: String,
    },
    UpdateComment {
        comment_id: String,
        body: String,
    },
    DeleteComment {
        comment_id: String,
    },
    Submit {
        body: String,
        decision: PullRequestReviewDecision,
    },
    Discard,
    Resolve {
        thread_id: String,
    },
    Unresolve {
        thread_id: String,
    },
}

impl PullRequestReviewOperation {
    pub(crate) const fn label(&self) -> &'static str {
        match self {
            Self::AddThread { .. } => "Adding review comment",
            Self::Reply { .. } => "Replying to review thread",
            Self::UpdateComment { .. } => "Updating review comment",
            Self::DeleteComment { .. } => "Deleting review comment",
            Self::Submit { .. } => "Submitting pull-request review",
            Self::Discard => "Discarding pending review",
            Self::Resolve { .. } => "Resolving review thread",
            Self::Unresolve { .. } => "Reopening review thread",
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn review_enums_have_stable_api_values() {
        assert_eq!(PullRequestReviewSide::Left.graphql(), "LEFT");
        assert_eq!(PullRequestReviewSide::Right.graphql(), "RIGHT");
        assert_eq!(PullRequestReviewSide::Unknown.graphql(), "RIGHT");
        assert_eq!(PullRequestReviewThreadSubject::File.graphql(), "FILE");
        assert_eq!(PullRequestReviewThreadSubject::Line.graphql(), "LINE");
        assert_eq!(PullRequestReviewThreadSubject::Unknown.graphql(), "LINE");
        let decisions = [
            (PullRequestReviewDecision::Comment, "Comment", "COMMENT"),
            (PullRequestReviewDecision::Approve, "Approve", "APPROVE"),
            (
                PullRequestReviewDecision::RequestChanges,
                "Request changes",
                "REQUEST_CHANGES",
            ),
        ];
        for (decision, label, graphql) in decisions {
            assert_eq!(decision.label(), label);
            assert_eq!(decision.graphql(), graphql);
        }
    }

    #[test]
    fn snapshot_counts_open_threads_and_pending_comments() {
        let comment = PullRequestReviewComment {
            id: "COMMENT_1".to_owned(),
            author: "reviewer".to_owned(),
            body: "Fix this".to_owned(),
            created_at: String::new(),
            updated_at: String::new(),
            url: String::new(),
            state: "pending".to_owned(),
            viewer_did_author: true,
            viewer_can_update: true,
            viewer_can_delete: true,
        };
        let thread = |is_resolved, comments| PullRequestReviewThread {
            id: "THREAD_1".to_owned(),
            path: PathBuf::from("src/main.rs"),
            side: PullRequestReviewSide::Right,
            line: Some(1),
            original_line: None,
            start_side: None,
            start_line: None,
            original_start_line: None,
            subject: PullRequestReviewThreadSubject::Line,
            is_resolved,
            is_outdated: false,
            resolved_by: None,
            viewer_can_reply: true,
            viewer_can_resolve: true,
            viewer_can_unresolve: false,
            comments,
            comments_truncated: false,
        };
        let snapshot = PullRequestReviewSnapshot {
            threads: vec![thread(false, vec![comment]), thread(true, Vec::new())],
            ..PullRequestReviewSnapshot::default()
        };
        assert_eq!(snapshot.unresolved_count(), 1);
        assert_eq!(snapshot.pending_comment_count(), 1);
    }
}