ghzinga 0.5.0

Terminal UI for monitoring GitHub pull requests and issues.
Documentation
use serde_json::{json, Value};

use crate::domain::{ResourceAction, ResourceKind};
use crate::github::auth::github_token;
use crate::github::transport::{
    run_graphql_query_with, GithubHttpTransport, ReqwestGithubHttpTransport,
};

/// Submit a write action against the issue or pull request with the given
/// GraphQL node id. Mutations always require a token; there is no public
/// fallback for writes.
pub async fn submit_resource_action(
    node_id: &str,
    kind: ResourceKind,
    action: &ResourceAction,
) -> anyhow::Result<()> {
    let token = github_token().await?;
    submit_resource_action_with(
        &ReqwestGithubHttpTransport,
        Some(&token),
        node_id,
        kind,
        action,
    )
    .await
}

pub(crate) async fn submit_resource_action_with(
    transport: &impl GithubHttpTransport,
    token: Option<&str>,
    node_id: &str,
    kind: ResourceKind,
    action: &ResourceAction,
) -> anyhow::Result<()> {
    let (mutation, variables) = action_mutation(node_id, kind, action);
    run_graphql_query_with(transport, token, mutation, variables).await?;
    Ok(())
}

fn action_mutation(
    node_id: &str,
    kind: ResourceKind,
    action: &ResourceAction,
) -> (&'static str, Value) {
    match action {
        ResourceAction::Comment { body } => (
            "mutation($id: ID!, $body: String!) { addComment(input: {subjectId: $id, body: $body}) { clientMutationId } }",
            json!({ "id": node_id, "body": body }),
        ),
        ResourceAction::Edit { target, body } => (
            edit_mutation(target.kind),
            json!({ "id": target.node_id, "body": body }),
        ),
        ResourceAction::Close => (state_mutation(kind, StateChange::Close), id_variables(node_id)),
        ResourceAction::Reopen => (
            state_mutation(kind, StateChange::Reopen),
            id_variables(node_id),
        ),
        ResourceAction::Merge { method } => (
            "mutation($id: ID!, $method: PullRequestMergeMethod!) { mergePullRequest(input: {pullRequestId: $id, mergeMethod: $method}) { clientMutationId } }",
            json!({ "id": node_id, "method": method.graphql_value() }),
        ),
    }
}

#[derive(Clone, Copy)]
enum StateChange {
    Close,
    Reopen,
}

fn state_mutation(kind: ResourceKind, change: StateChange) -> &'static str {
    match (kind, change) {
        (ResourceKind::Issue, StateChange::Close) => {
            "mutation($id: ID!) { closeIssue(input: {issueId: $id}) { clientMutationId } }"
        }
        (ResourceKind::Issue, StateChange::Reopen) => {
            "mutation($id: ID!) { reopenIssue(input: {issueId: $id}) { clientMutationId } }"
        }
        (ResourceKind::PullRequest, StateChange::Close) => {
            "mutation($id: ID!) { closePullRequest(input: {pullRequestId: $id}) { clientMutationId } }"
        }
        (ResourceKind::PullRequest, StateChange::Reopen) => {
            "mutation($id: ID!) { reopenPullRequest(input: {pullRequestId: $id}) { clientMutationId } }"
        }
    }
}

fn id_variables(node_id: &str) -> Value {
    json!({ "id": node_id })
}

fn edit_mutation(kind: crate::domain::EditKind) -> &'static str {
    use crate::domain::EditKind;
    match kind {
        EditKind::IssueBody => {
            "mutation($id: ID!, $body: String!) { updateIssue(input: {id: $id, body: $body}) { clientMutationId } }"
        }
        EditKind::PullRequestBody => {
            "mutation($id: ID!, $body: String!) { updatePullRequest(input: {pullRequestId: $id, body: $body}) { clientMutationId } }"
        }
        EditKind::IssueComment => {
            "mutation($id: ID!, $body: String!) { updateIssueComment(input: {id: $id, body: $body}) { clientMutationId } }"
        }
        EditKind::Review => {
            "mutation($id: ID!, $body: String!) { updatePullRequestReview(input: {pullRequestReviewId: $id, body: $body}) { clientMutationId } }"
        }
        EditKind::ReviewComment => {
            "mutation($id: ID!, $body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: $id, body: $body}) { clientMutationId } }"
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::MergeMethod;
    use crate::github::transport::{GithubHttpFuture, GithubHttpRequest, GithubHttpResponse};
    use std::sync::Mutex;

    #[test]
    fn comment_mutation_targets_subject_with_body() {
        let (mutation, variables) = action_mutation(
            "PR_node",
            ResourceKind::PullRequest,
            &ResourceAction::Comment {
                body: "hello\nworld".into(),
            },
        );
        assert!(mutation.contains("addComment"));
        assert_eq!(variables["id"], "PR_node");
        assert_eq!(variables["body"], "hello\nworld");
    }

    #[test]
    fn close_and_reopen_pick_the_kind_specific_mutation() {
        let cases = [
            (ResourceKind::Issue, ResourceAction::Close, "closeIssue"),
            (ResourceKind::Issue, ResourceAction::Reopen, "reopenIssue"),
            (
                ResourceKind::PullRequest,
                ResourceAction::Close,
                "closePullRequest",
            ),
            (
                ResourceKind::PullRequest,
                ResourceAction::Reopen,
                "reopenPullRequest",
            ),
        ];
        for (kind, action, expected) in cases {
            let (mutation, variables) = action_mutation("node", kind, &action);
            assert!(
                mutation.contains(expected),
                "expected {expected} for {kind:?} {action:?}"
            );
            assert_eq!(variables["id"], "node");
        }
    }

    #[test]
    fn edit_mutations_pick_the_kind_specific_update() {
        use crate::domain::{EditKind, EditTarget};
        let cases = [
            (EditKind::IssueBody, "updateIssue(input: {id:"),
            (
                EditKind::PullRequestBody,
                "updatePullRequest(input: {pullRequestId:",
            ),
            (EditKind::IssueComment, "updateIssueComment(input: {id:"),
            (
                EditKind::Review,
                "updatePullRequestReview(input: {pullRequestReviewId:",
            ),
            (
                EditKind::ReviewComment,
                "updatePullRequestReviewComment(input: {pullRequestReviewCommentId:",
            ),
        ];
        for (kind, expected) in cases {
            let action = ResourceAction::Edit {
                target: EditTarget {
                    node_id: "NODE".into(),
                    kind,
                    current_body: "old text".into(),
                },
                body: "updated text".into(),
            };
            let (mutation, variables) =
                action_mutation("unused-subject", ResourceKind::PullRequest, &action);
            assert!(
                mutation.contains(expected),
                "expected `{expected}` for {kind:?}: {mutation}"
            );
            assert_eq!(variables["id"], "NODE");
            assert_eq!(variables["body"], "updated text");
        }
    }

    #[test]
    fn merge_mutation_carries_uppercase_method() {
        let (mutation, variables) = action_mutation(
            "PR_node",
            ResourceKind::PullRequest,
            &ResourceAction::Merge {
                method: MergeMethod::Squash,
            },
        );
        assert!(mutation.contains("mergePullRequest"));
        assert!(mutation.contains("PullRequestMergeMethod"));
        assert_eq!(variables["method"], "SQUASH");
    }

    struct OneShotTransport {
        request: Mutex<Option<GithubHttpRequest>>,
        response: Mutex<Option<anyhow::Result<GithubHttpResponse>>>,
    }

    impl OneShotTransport {
        fn replying(body: &str) -> Self {
            Self {
                request: Mutex::new(None),
                response: Mutex::new(Some(Ok(GithubHttpResponse {
                    status: reqwest::StatusCode::OK,
                    body: body.as_bytes().to_vec(),
                }))),
            }
        }

        fn sent_request(&self) -> GithubHttpRequest {
            self.request
                .lock()
                .expect("request lock")
                .clone()
                .expect("one request sent")
        }
    }

    impl GithubHttpTransport for OneShotTransport {
        fn execute<'a>(&'a self, request: GithubHttpRequest) -> GithubHttpFuture<'a> {
            *self.request.lock().expect("request lock") = Some(request);
            let response = self
                .response
                .lock()
                .expect("response lock")
                .take()
                .expect("single response available");
            Box::pin(async move { response })
        }
    }

    #[tokio::test]
    async fn submit_sends_authenticated_graphql_post() {
        let transport = OneShotTransport::replying(r#"{"data":{"closeIssue":{}}}"#);
        submit_resource_action_with(
            &transport,
            Some("token-123"),
            "I_node",
            ResourceKind::Issue,
            &ResourceAction::Close,
        )
        .await
        .expect("mutation succeeds");
        let request = transport.sent_request();
        assert_eq!(request.token.as_deref(), Some("token-123"));
        let body = request.body.expect("graphql body");
        assert!(body["query"]
            .as_str()
            .expect("query string")
            .contains("closeIssue"));
        assert_eq!(body["variables"]["id"], "I_node");
    }

    #[tokio::test]
    async fn submit_surfaces_graphql_errors() {
        let transport = OneShotTransport::replying(
            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
        );
        let error = submit_resource_action_with(
            &transport,
            Some("token-123"),
            "PR_node",
            ResourceKind::PullRequest,
            &ResourceAction::Merge {
                method: MergeMethod::Squash,
            },
        )
        .await
        .expect_err("mutation fails");
        assert!(error.to_string().contains("not mergeable"));
    }
}