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);
}
}