rootle_github/client/
history.rs1use super::transport::Page;
4use super::{API, GitHubClient};
5use rootle_provider::{LogEntry, ProviderError, ProviderResult, RepoId};
6use std::collections::HashSet;
7
8#[cfg(test)]
9mod tests;
10
11#[derive(serde::Deserialize)]
12struct HistoryCommit {
13 sha: String,
14 commit: HistoryMetadata,
15}
16
17#[derive(serde::Deserialize)]
18struct HistoryMetadata {
19 message: String,
20 author: HistoryAuthor,
21}
22
23#[derive(serde::Deserialize)]
24struct HistoryAuthor {
25 name: String,
26 date: String,
27}
28
29impl GitHubClient {
30 pub fn log(
32 &self,
33 repository: &RepoId,
34 path: Option<&str>,
35 revision: Option<&str>,
36 limit: Option<usize>,
37 on_entries: &mut dyn FnMut(Vec<LogEntry>),
38 ) -> ProviderResult<bool> {
39 let (owner, repository_name) = crate::split_repo(repository)?;
40 let mut url = reqwest::Url::parse(API).expect("GitHub API URL");
41 url.path_segments_mut()
42 .expect("HTTP URL supports path segments")
43 .extend(["repos", owner, repository_name, "commits"]);
44 let limit = limit.unwrap_or(50).min(rootle_provider::RENDER_BUDGET);
45 let snapshot = if limit > 100 {
49 #[derive(serde::Deserialize)]
50 struct ResolvedCommit {
51 sha: rootle_provider::Sha,
52 }
53 let mut reference_url = url.clone();
54 reference_url
55 .path_segments_mut()
56 .expect("HTTP URL supports path segments")
57 .push(revision.unwrap_or("HEAD"));
58 reference_url.query_pairs_mut().append_pair("per_page", "1");
59 Some(self.get::<ResolvedCommit>(reference_url.as_str())?.sha)
60 } else {
61 None
62 };
63 stream_history(
64 url,
65 path,
66 snapshot
67 .as_ref()
68 .map(rootle_provider::Sha::as_str)
69 .or(revision),
70 limit,
71 on_entries,
72 |url| self.get_page(url),
73 )
74 }
75
76 pub fn commit(
80 &self,
81 owner: &str,
82 repo: &str,
83 sha: &str,
84 ) -> ProviderResult<crate::types::CommitResponse> {
85 const FILES_PER_PAGE: usize = 100;
86 const FILE_BUDGET: usize = rootle_provider::RENDER_BUDGET;
87 let mut url = reqwest::Url::parse(API).expect("GitHub API URL");
88 url.path_segments_mut()
89 .expect("HTTP URL supports path segments")
90 .extend(["repos", owner, repo, "commits", sha]);
91 url.query_pairs_mut()
92 .append_pair("per_page", &FILES_PER_PAGE.to_string());
93 let first = self.get_page::<crate::types::CommitResponse>(url.as_str())?;
94 let mut response = first.body;
95 let mut has_next = first.has_next;
96 let mut page = 1u32;
97 url.path_segments_mut()
100 .expect("HTTP URL supports path segments")
101 .pop()
102 .push(&response.sha);
103 while has_next && response.files.len() < FILE_BUDGET {
104 page += 1;
105 url.query_pairs_mut()
106 .clear()
107 .append_pair("per_page", &FILES_PER_PAGE.to_string())
108 .append_pair("page", &page.to_string());
109 let next = self.get_page::<crate::types::CommitResponse>(url.as_str())?;
110 if next.body.files.is_empty() && next.has_next {
111 return Err(ProviderError::other(
112 "commit file pagination made no progress",
113 ));
114 }
115 if next.body.sha != response.sha {
116 return Err(rootle_provider::ProviderError::other(
117 "commit changed across file pages",
118 ));
119 }
120 response.files.extend(next.body.files);
121 has_next = next.has_next;
122 }
123 response.truncated = has_next || response.files.len() > FILE_BUDGET;
124 response.files.truncate(FILE_BUDGET);
125 Ok(response)
126 }
127}
128
129fn stream_history(
130 mut url: reqwest::Url,
131 path: Option<&str>,
132 revision: Option<&str>,
133 limit: usize,
134 on_entries: &mut dyn FnMut(Vec<LogEntry>),
135 mut fetch: impl FnMut(&str) -> ProviderResult<Page<Vec<HistoryCommit>>>,
136) -> ProviderResult<bool> {
137 let page_size = limit.clamp(1, 100);
139 let mut retained = 0usize;
140 let mut seen = HashSet::new();
141 for page_number in 1..=limit.div_ceil(page_size).max(1) {
142 let mut query = url.query_pairs_mut();
143 query
144 .clear()
145 .append_pair("per_page", &page_size.to_string())
146 .append_pair("page", &page_number.to_string());
147 if let Some(path) = path {
148 query.append_pair("path", path);
149 }
150 if let Some(revision) = revision {
151 query.append_pair("sha", revision);
152 }
153 drop(query);
154 let page = fetch(url.as_str())?;
155 if page.body.is_empty() && page.has_next {
156 return Err(ProviderError::other("history pagination made no progress"));
157 }
158 let clipped = page.body.len() > limit - retained;
159 let mut entries = Vec::with_capacity(page.body.len().min(limit - retained));
160 for commit in page.body.into_iter().take(limit - retained) {
161 if !seen.insert(commit.sha.clone()) {
162 return Err(ProviderError::other("history pagination repeated a commit"));
163 }
164 entries.push(LogEntry {
165 sha: commit.sha,
166 subject: commit
167 .commit
168 .message
169 .lines()
170 .next()
171 .unwrap_or("")
172 .to_owned(),
173 author: commit.commit.author.name,
174 date: commit.commit.author.date,
175 });
176 }
177 retained += entries.len();
178 if !entries.is_empty() {
179 on_entries(entries);
180 }
181 if !page.has_next || retained == limit {
182 return Ok(page.has_next || clipped);
183 }
184 }
185 Ok(true)
188}