use super::transport::Page;
use super::{API, GitHubClient};
use rootle_provider::{LogEntry, ProviderError, ProviderResult, RepoId};
use std::collections::HashSet;
#[cfg(test)]
mod tests;
#[derive(serde::Deserialize)]
struct HistoryCommit {
sha: String,
commit: HistoryMetadata,
}
#[derive(serde::Deserialize)]
struct HistoryMetadata {
message: String,
author: HistoryAuthor,
}
#[derive(serde::Deserialize)]
struct HistoryAuthor {
name: String,
date: String,
}
impl GitHubClient {
pub fn log(
&self,
repository: &RepoId,
path: Option<&str>,
revision: Option<&str>,
limit: Option<usize>,
on_entries: &mut dyn FnMut(Vec<LogEntry>),
) -> ProviderResult<bool> {
let (owner, repository_name) = crate::split_repo(repository)?;
let mut url = reqwest::Url::parse(API).expect("GitHub API URL");
url.path_segments_mut()
.expect("HTTP URL supports path segments")
.extend(["repos", owner, repository_name, "commits"]);
let limit = limit.unwrap_or(50).min(rootle_provider::RENDER_BUDGET);
let snapshot = if limit > 100 {
#[derive(serde::Deserialize)]
struct ResolvedCommit {
sha: rootle_provider::Sha,
}
let mut reference_url = url.clone();
reference_url
.path_segments_mut()
.expect("HTTP URL supports path segments")
.push(revision.unwrap_or("HEAD"));
reference_url.query_pairs_mut().append_pair("per_page", "1");
Some(self.get::<ResolvedCommit>(reference_url.as_str())?.sha)
} else {
None
};
stream_history(
url,
path,
snapshot
.as_ref()
.map(rootle_provider::Sha::as_str)
.or(revision),
limit,
on_entries,
|url| self.get_page(url),
)
}
pub fn commit(
&self,
owner: &str,
repo: &str,
sha: &str,
) -> ProviderResult<crate::types::CommitResponse> {
const FILES_PER_PAGE: usize = 100;
const FILE_BUDGET: usize = rootle_provider::RENDER_BUDGET;
let mut url = reqwest::Url::parse(API).expect("GitHub API URL");
url.path_segments_mut()
.expect("HTTP URL supports path segments")
.extend(["repos", owner, repo, "commits", sha]);
url.query_pairs_mut()
.append_pair("per_page", &FILES_PER_PAGE.to_string());
let first = self.get_page::<crate::types::CommitResponse>(url.as_str())?;
let mut response = first.body;
let mut has_next = first.has_next;
let mut page = 1u32;
url.path_segments_mut()
.expect("HTTP URL supports path segments")
.pop()
.push(&response.sha);
while has_next && response.files.len() < FILE_BUDGET {
page += 1;
url.query_pairs_mut()
.clear()
.append_pair("per_page", &FILES_PER_PAGE.to_string())
.append_pair("page", &page.to_string());
let next = self.get_page::<crate::types::CommitResponse>(url.as_str())?;
if next.body.files.is_empty() && next.has_next {
return Err(ProviderError::other(
"commit file pagination made no progress",
));
}
if next.body.sha != response.sha {
return Err(rootle_provider::ProviderError::other(
"commit changed across file pages",
));
}
response.files.extend(next.body.files);
has_next = next.has_next;
}
response.truncated = has_next || response.files.len() > FILE_BUDGET;
response.files.truncate(FILE_BUDGET);
Ok(response)
}
}
fn stream_history(
mut url: reqwest::Url,
path: Option<&str>,
revision: Option<&str>,
limit: usize,
on_entries: &mut dyn FnMut(Vec<LogEntry>),
mut fetch: impl FnMut(&str) -> ProviderResult<Page<Vec<HistoryCommit>>>,
) -> ProviderResult<bool> {
let page_size = limit.clamp(1, 100);
let mut retained = 0usize;
let mut seen = HashSet::new();
for page_number in 1..=limit.div_ceil(page_size).max(1) {
let mut query = url.query_pairs_mut();
query
.clear()
.append_pair("per_page", &page_size.to_string())
.append_pair("page", &page_number.to_string());
if let Some(path) = path {
query.append_pair("path", path);
}
if let Some(revision) = revision {
query.append_pair("sha", revision);
}
drop(query);
let page = fetch(url.as_str())?;
if page.body.is_empty() && page.has_next {
return Err(ProviderError::other("history pagination made no progress"));
}
let clipped = page.body.len() > limit - retained;
let mut entries = Vec::with_capacity(page.body.len().min(limit - retained));
for commit in page.body.into_iter().take(limit - retained) {
if !seen.insert(commit.sha.clone()) {
return Err(ProviderError::other("history pagination repeated a commit"));
}
entries.push(LogEntry {
sha: commit.sha,
subject: commit
.commit
.message
.lines()
.next()
.unwrap_or("")
.to_owned(),
author: commit.commit.author.name,
date: commit.commit.author.date,
});
}
retained += entries.len();
if !entries.is_empty() {
on_entries(entries);
}
if !page.has_next || retained == limit {
return Ok(page.has_next || clipped);
}
}
Ok(true)
}