Skip to main content

rootle_github/client/
history.rs

1//! Commit history and commit-detail retrieval.
2
3use 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    /// Page history at one pinned revision; stream each bounded page to the UI.
31    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        // Resolve the requested tip before paging. The first path-filtered
46        // commit may be on one side of a merge and cannot stand in for HEAD.
47        // A single-page request already has an atomic snapshot and needs no probe.
48        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    /// v1.6 (plans/0028 M1): one commit's detail — message, parents,
77    /// changed files with their unified hunks. The wire model is
78    /// `types::CommitResponse`; the seam mapping lives in lib.rs.
79    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        // Pagination is file-only. Pin subsequent pages to the resolved
98        // content ID, even if a caller supplied a resolvable revision.
99        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    // Keep page size constant: changing it between numbered pages skips entries.
138    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    // Short pages with a next link still consume a finite request budget.
186    // Never claim completeness when the upstream page chain remains open.
187    Ok(true)
188}