knope 0.22.0

A command line tool for automating common development tasks
use miette::Diagnostic;
use tracing::info;
use ureq::Agent;

use super::Issue;
use crate::{
    app_config,
    app_config::get_or_prompt_for_github_token,
    config, prompt,
    prompt::select,
    state,
    state::{RunType, State},
};

const ISSUES_QUERY: &str = r"
query($repo: String!, $owner: String!, $labels: [String!]) {
  repository(name:$repo, owner:$owner) {
    issues(states:OPEN, first: 30, labels: $labels) {
      nodes {
        number,
        title
      }
    }
  }
}
";

#[derive(serde::Deserialize)]
struct ResponseIssue {
    number: usize,
    title: String,
}

pub(crate) fn select_issue(
    labels: Option<&[String]>,
    state: RunType<State>,
) -> Result<RunType<State>, Error> {
    match state {
        RunType::DryRun(mut state) => {
            if state.github_config.is_none() {
                return Err(Error::NotConfigured);
            }
            if let Some(labels) = labels {
                info!(
                    "Would query configured GitHub instance for issues with labels {}",
                    labels.join(", ")
                );
            } else {
                info!("Would query configured GitHub instance for issues with any labels");
            }
            info!("Would prompt user to select an issue and move workflow to IssueSelected state.");
            state.issue = state::Issue::Selected(Issue {
                key: String::from("123"),
                summary: String::from("Test issue"),
            });
            Ok(RunType::DryRun(state))
        }
        RunType::Real(state) => {
            let github_config = state.github_config.as_ref().ok_or(Error::NotConfigured)?;
            let (github, issues) = list_issues(github_config, state.github, labels)?;
            let issue = select(issues, "Select an Issue")?;
            info!("Selected item : {}", &issue);
            Ok(RunType::Real(State {
                github,
                issue: state::Issue::Selected(issue),
                ..state
            }))
        }
    }
}

#[derive(Debug, Diagnostic, thiserror::Error)]
pub(crate) enum Error {
    #[error("GitHub is not configured")]
    #[diagnostic(
        code(issues::github::not_configured),
        help("GitHub must be configured in order to use the SelectGitHubIssue step"),
        url("https://knope.tech/reference/config-file/github/")
    )]
    NotConfigured,
    #[error("Could not communicate with GitHub while {context}: {source}")]
    #[diagnostic(
        code(issues::github::api),
        help("Check your network connection and GitHub configuration"),
        url("https://knope.tech/reference/config-file/github/")
    )]
    Api {
        source: Box<ureq::Error>,
        context: &'static str,
    },
    #[error("I/O error encountered when communicating with GitHub: {0}")]
    #[diagnostic(code(issues::github::api_io), help("Check your network connection"))]
    ApiIo(std::io::Error),
    #[error("Could not deserialize response from GitHub into JSON: {0}")]
    Serde(#[from] serde_json::Error),
    #[error("Received unexpected data from GitHub: {0}")]
    #[diagnostic(
        code(issues::github::unexpected_response),
        help("It's possible GitHub has updated their API, please report this issue")
    )]
    UnexpectedApiResponse(String),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Prompt(#[from] prompt::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    AppConfig(#[from] app_config::Error),
}

fn list_issues(
    github_config: &config::GitHub,
    github_state: state::GitHub,
    labels: Option<&[String]>,
) -> Result<(state::GitHub, Vec<Issue>), Error> {
    let (token, agent) = match github_state {
        state::GitHub::Initialized { token, agent } => (token, agent),
        state::GitHub::New => (
            get_or_prompt_for_github_token()?,
            Agent::new_with_defaults(),
        ),
    };
    let json_value: serde_json::Value = agent
        .post("https://api.github.com/graphql")
        .header("Authorization", &format!("bearer {token}"))
        .send_json(serde_json::json!({
            "query": ISSUES_QUERY,
            "variables": {
                "repo": github_config.repo,
                "owner": github_config.owner,
                "labels": labels
            }
        }))
        .map_err(|source| Error::Api {
            source: Box::new(source),
            context: "loading issues",
        })?
        .body_mut()
        .read_json()
        .map_err(|e| {
            Error::ApiIo(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                e.to_string(),
            ))
        })?;

    let gh_issues = decode_github_response(&json_value)?;

    let issues = gh_issues
        .into_iter()
        .map(|gh_issue| Issue {
            key: gh_issue.number.to_string(),
            summary: gh_issue.title,
        })
        .collect();

    Ok((state::GitHub::Initialized { token, agent }, issues))
}

fn decode_github_response(json_value: &serde_json::Value) -> Result<Vec<ResponseIssue>, Error> {
    let json_issues = json_value.pointer("/data/repository/issues/nodes");
    match json_issues {
        Some(value) => serde_json::from_value(value.clone()).map_err(Error::from),
        None => Err(Error::UnexpectedApiResponse(json_value.to_string())),
    }
}