knope 0.23.0

A command line tool for automating common development tasks
use knope_versioning::changes::Change;
use miette::Diagnostic;
use tracing::{debug, info, warn};

use crate::{app_config, config, integrations::http, state, state::RunType};

#[derive(serde::Deserialize)]
struct PullRequestInfo {
    number: u64,
    user: Option<PullRequestUser>,
}

#[derive(serde::Deserialize)]
struct PullRequestUser {
    login: String,
}

/// Enrich each [`Change`]'s git info with PR number and author login from the GitHub API.
///
/// Only changes that already have a commit hash are looked up. The GitHub state is returned
/// so the caller can reuse an initialized token/agent for subsequent API calls.
pub(crate) async fn enrich_git_info(
    changes: &mut [Change],
    github_config: &config::GitHub,
    github_state: state::GitHub,
    run_type: RunType<()>,
) -> Result<state::GitHub, Error> {
    if run_type.is_dry_run() {
        info!(
            "Would fetch Pull Request info from GitHub repo {owner}/{repo} for {num_changes} changes",
            owner = github_config.owner,
            repo = github_config.repo,
            num_changes = changes.len(),
        );
        for git_info in changes.iter_mut().filter_map(|change| change.git.as_mut()) {
            git_info.pr_number = Some(1234);
            git_info.pr_author_login = Some("some-user".to_string());
        }
        return Ok(github_state);
    }

    let (token, client) = github_state.maybe_authenticated()?;
    let authorization = token.as_ref().map(|token| format!("Bearer {token}"));

    for git_info in changes.iter_mut().filter_map(|change| change.git.as_mut()) {
        let short_hash = &git_info.hash;
        match fetch_pr_for_commit(&client, authorization.as_deref(), github_config, short_hash)
            .await
        {
            Ok(Some((pr_number, author_login))) => {
                git_info.pr_number = Some(pr_number);
                git_info.pr_author_login = author_login;
            }
            Ok(None) => {
                debug!("No PR found for commit {short_hash}");
            }
            Err(e) => {
                warn!("Failed to fetch PR info for commit {short_hash}: {e}");
            }
        }
    }

    if let Some(token) = token {
        Ok(state::GitHub::Authenticated { token, client })
    } else {
        Ok(state::GitHub::Unauthenticated { client })
    }
}

async fn fetch_pr_for_commit(
    client: &http::Client,
    authorization: Option<&str>,
    config: &config::GitHub,
    commit_sha: &str,
) -> Result<Option<(u64, Option<String>)>, Error> {
    let url = format!(
        "https://api.github.com/repos/{owner}/{repo}/commits/{commit_sha}/pulls",
        owner = config.owner,
        repo = config.repo,
    );

    let mut request = client
        .get(&url)
        .header("Accept", "application/vnd.github+json")
        .header("X-GitHub-Api-Version", "2026-03-10");
    if let Some(authorization) = authorization {
        request = request.header("Authorization", authorization);
    }
    let response = request.send().await;

    let response = http::handle_response(
        response,
        "GitHub".to_string(),
        format!("fetching PRs for commit {commit_sha}"),
    )
    .await?;

    let pulls: Vec<PullRequestInfo> =
        response.json().await.map_err(|source| Error::ApiResponse {
            message: source.to_string(),
            activity: format!("reading PR info for commit {commit_sha}"),
        })?;

    Ok(pulls.into_iter().next().map(|pr| {
        let login = pr.user.map(|u| u.login);
        (pr.number, login)
    }))
}

#[derive(Debug, Diagnostic, thiserror::Error)]
pub(crate) enum Error {
    #[error(transparent)]
    #[diagnostic(transparent)]
    AppConfig(#[from] app_config::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    ApiRequest(#[from] http::ApiRequestError),
    #[error("Trouble decoding the response from GitHub while {activity}: {message}")]
    #[diagnostic(
        code(github::api_response_error),
        help(
            "Failure to decode a response from GitHub is probably a bug. Please report it at https://github.com/knope-dev/knope"
        )
    )]
    ApiResponse { message: String, activity: String },
}