AkurAI Build
Menu

Public repository / main

AkurAI-Build / src/server/insights_api.rs

Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson

//! Repository insights (OLI-276): contributors and activity, as JSON and a page.

use axum::{
    Json,
    extract::{Path, Query, State},
    http::HeaderMap,
    response::{Html, IntoResponse, Redirect, Response},
};
use minijinja::context;
use serde::Deserialize;

use super::access_api::{identify, role_on};
use super::{
    ApiError, AppState, Envelope, encode_code_segment, ok, repository_view, unix_time,
    valid_session,
};
use crate::{
    access::Role,
    db::Repository,
    hosted_git,
    insights::{self, Insights},
};

const DEFAULT_DAYS: u32 = 90;

#[derive(Deserialize)]
pub(super) struct Window {
    days: Option<u32>,
}

fn readable(
    headers: &HeaderMap,
    state: &AppState,
    repository: &Repository,
) -> Result<(), ApiError> {
    if repository.visibility == "public" {
        return Ok(());
    }
    let identity = identify(headers, state)?;
    match role_on(&identity, state, &repository.name)? {
        Some(role) if role.allows(Role::Read) => Ok(()),
        _ => Err(ApiError::forbidden(format!(
            "no access to {}",
            repository.name
        ))),
    }
}

async fn compute(
    state: &AppState,
    repository: &Repository,
    days: u32,
) -> Result<Insights, ApiError> {
    let path =
        hosted_git::repository_path(&state.hosted_root, &repository.name).map_err(ApiError::bad)?;
    if !path.is_dir() {
        return Err(ApiError::not_found(format!(
            "{} is not hosted on this forge",
            repository.name
        )));
    }
    let branch = repository.default_branch.clone();
    let now = unix_time()? as i64;
    state
        .blocking_git
        .run(move || insights::insights(&path, &branch, days, now).map_err(ApiError::bad))
        .await
}

pub(super) async fn api(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Query(window): Query<Window>,
) -> Result<Json<Envelope<Insights>>, ApiError> {
    let repository = state
        .database
        .repository(&repository)
        .map_err(|_| ApiError::not_found("unknown repository"))?;
    readable(&headers, &state, &repository)?;
    let report = compute(&state, &repository, window.days.unwrap_or(DEFAULT_DAYS)).await?;
    Ok(ok(report))
}

pub(super) async fn page(
    State(state): State<AppState>,
    headers: HeaderMap,
    uri: axum::http::Uri,
    Path(repository_name): Path<String>,
    Query(window): Query<Window>,
) -> Result<Response, ApiError> {
    let public = !uri.path().starts_with("/app/");
    if !public && !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let repository = state
        .database
        .repository(&repository_name)
        .map_err(|_| ApiError::not_found(format!("unknown repository {repository_name}")))?;
    if public && repository.visibility != "public" {
        return Err(ApiError::not_found("repository not found"));
    }
    let days = window
        .days
        .unwrap_or(DEFAULT_DAYS)
        .clamp(1, insights::MAX_DAYS);
    let report = compute(&state, &repository, days).await?;
    let prefix = if public {
        "/repos"
    } else {
        "/app/repositories"
    };
    let encoded = encode_code_segment(&repository_name);
    let max_week = report
        .weeks
        .iter()
        .map(|w| w.commits)
        .max()
        .unwrap_or(0)
        .max(1);
    let max_commits = report
        .contributors
        .first()
        .map(|c| c.commits)
        .unwrap_or(0)
        .max(1);
    let weeks: Vec<_> = report
        .weeks
        .iter()
        .map(|w| context! { start => w.start, commits => w.commits, additions => w.additions, deletions => w.deletions, percent => w.commits * 100 / max_week })
        .collect();
    let contributors: Vec<_> = report
        .contributors
        .iter()
        .map(|c| context! { name => c.name, email => c.email, commits => c.commits, additions => c.additions, deletions => c.deletions, last_at => c.last_at, percent => c.commits * 100 / max_commits })
        .collect();
    let view = repository_view(repository, &state.hosted_root, &state.public_origin);
    let rendered = state
        .templates
        .get_template("repository-insights.html")
        .map_err(ApiError::internal)?
        .render(context! {
            version => env!("CARGO_PKG_VERSION"),
            page => context! {
                repository => view, public => public, repo_href => format!("{prefix}/{encoded}"),
                self_href => format!("{prefix}/{encoded}/insights"),
                days => days, since => report.since, commits => report.commits,
                additions => report.additions, deletions => report.deletions,
                weeks => weeks, contributors => contributors, files => report.files,
                now => unix_time()?,
            }
        })
        .map_err(ApiError::internal)?;
    Ok(Html(rendered).into_response())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        body::{Body, to_bytes},
        http::{Request, StatusCode, header},
        routing::get,
    };
    use std::process::Command;
    use tower::ServiceExt;

    fn git(dir: &std::path::Path, args: &[&str]) {
        let status = Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .status()
            .expect("git");
        assert!(status.success(), "git {args:?}");
    }

    #[tokio::test]
    async fn insights_are_public_for_public_repositories_and_gated_otherwise() {
        let state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        let root = state.hosted_root.clone();
        let work = root.join("work");
        std::fs::create_dir_all(&work).expect("work");
        git(&work, &["init", "-q", "-b", "main"]);
        git(&work, &["config", "user.name", "Ada"]);
        git(&work, &["config", "user.email", "ada@example"]);
        std::fs::write(work.join("README.md"), "one\n").expect("write");
        git(&work, &["add", "."]);
        git(&work, &["commit", "-q", "-m", "first"]);
        let hosted = crate::hosted_git::host(&root, "demo", &work).expect("host");
        state
            .database
            .add_repository("demo", hosted.to_str().expect("utf8"), "main")
            .expect("add");
        let app = Router::new()
            .route("/api/repos/{repository}/insights", get(api))
            .route("/repos/{repository}/insights", get(page))
            .route("/app/repositories/{repository}/insights", get(page))
            .with_state(state.clone());

        let get_json = |uri: &str, auth: Option<&str>| {
            let app = app.clone();
            let uri = uri.to_string();
            let auth = auth.map(str::to_string);
            async move {
                let mut request = Request::builder().uri(uri);
                if let Some(auth) = auth {
                    request = request.header(header::AUTHORIZATION, auth);
                }
                let response = app
                    .oneshot(request.body(Body::empty()).expect("request"))
                    .await
                    .expect("response");
                let status = response.status();
                let bytes = to_bytes(response.into_body(), usize::MAX)
                    .await
                    .expect("body");
                (status, String::from_utf8_lossy(&bytes).into_owned())
            }
        };

        let (status, _) = get_json("/api/repos/demo/insights", None).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
        let (status, body) = get_json(
            "/api/repos/demo/insights?days=30",
            Some("Bearer api-token-123"),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{body}");
        let json: serde_json::Value = serde_json::from_str(&body).expect("json");
        assert_eq!(json["data"]["commits"], 1);
        assert_eq!(json["data"]["contributors"][0]["email"], "ada@example");
        assert_eq!(json["data"]["days"], 30);
        let (status, _) = get_json("/repos/demo/insights", None).await;
        assert_eq!(status, StatusCode::NOT_FOUND, "private: no public page");
        let (status, _) = get_json("/app/repositories/demo/insights", None).await;
        assert_eq!(status, StatusCode::SEE_OTHER, "no session: login redirect");

        state
            .database
            .set_repository_visibility("demo", "public")
            .expect("public");
        let (status, body) = get_json("/api/repos/demo/insights", None).await;
        assert_eq!(status, StatusCode::OK, "{body}");
        let (status, html) = get_json("/repos/demo/insights?days=7", None).await;
        assert_eq!(status, StatusCode::OK);
        assert!(
            html.contains("Ada") && html.contains("Insights") && html.contains("README.md"),
            "{html}"
        );
        let (status, _) = get_json(
            "/api/repos/demo/insights?days=0",
            Some("Bearer api-token-123"),
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        let (status, _) = get_json("/api/repos/nope/insights", Some("Bearer api-token-123")).await;
        assert_eq!(status, StatusCode::NOT_FOUND);
    }
}