Skip to main content

github_bot_sdk/client/
commit.rs

1// Commit operations for GitHub API
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::client::issue::IssueUser;
7use crate::client::InstallationClient;
8use crate::error::ApiError;
9
10// ============================================================================
11// Types
12// ============================================================================
13
14/// A complete GitHub commit with all metadata.
15///
16/// Named `FullCommit` to distinguish it from the minimal [`Commit`] struct in
17/// `repository.rs` which only carries a `sha` and `url`.
18///
19/// # Examples
20///
21/// ```no_run
22/// # use github_bot_sdk::client::InstallationClient;
23/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
24/// let commit = client.get_commit("owner", "repo", "main").await?;
25/// println!("Latest commit: {} by {}",
26///     commit.sha,
27///     commit.commit.author.name);
28/// # Ok(())
29/// # }
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct FullCommit {
33    /// Commit SHA (40-character hexadecimal hash).
34    pub sha: String,
35
36    /// Node ID for GraphQL API.
37    pub node_id: String,
38
39    /// Git-level commit details (message, author, tree, etc.).
40    pub commit: CommitDetails,
41
42    /// GitHub user who authored the commit.
43    ///
44    /// `None` when the author email does not match any GitHub account.
45    pub author: Option<IssueUser>,
46
47    /// GitHub user who committed the change.
48    ///
49    /// `None` when the committer email does not match any GitHub account.
50    pub committer: Option<IssueUser>,
51
52    /// Parent commits. Empty for the initial commit; two entries for merge commits.
53    pub parents: Vec<CommitReference>,
54
55    /// API URL for this commit.
56    pub url: String,
57
58    /// Web interface URL for this commit.
59    pub html_url: String,
60
61    /// Number of comments on this commit (GitHub-level, at the commit envelope).
62    ///
63    /// The GitHub API returns this field at both the `FullCommit` level and inside
64    /// [`CommitDetails`]. Both values are preserved here because the API response
65    /// contains the count at each level of the object.
66    pub comment_count: u32,
67}
68
69/// Git-level commit metadata.
70///
71/// Contains the information stored in the Git object itself, as opposed to
72/// the GitHub-specific metadata on [`FullCommit`].
73///
74/// # Examples
75///
76/// ```no_run
77/// # use github_bot_sdk::client::InstallationClient;
78/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
79/// let commit = client.get_commit("owner", "repo", "abc123").await?;
80/// let details = &commit.commit;
81/// println!("Author: {} <{}>", details.author.name, details.author.email);
82/// println!("Message: {}", details.message.lines().next().unwrap_or(""));
83/// # Ok(())
84/// # }
85/// ```
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CommitDetails {
88    /// Commit author identity from Git config.
89    pub author: GitSignature,
90
91    /// Commit committer identity from Git config.
92    pub committer: GitSignature,
93
94    /// Full commit message (subject + optional body).
95    pub message: String,
96
97    /// Reference to the Git tree object for this commit.
98    pub tree: CommitReference,
99
100    /// GPG / SSH signature verification status.
101    ///
102    /// `None` when the GitHub API does not include verification data.
103    pub verification: Option<Verification>,
104
105    /// Number of comments on this commit (Git-object level).
106    ///
107    /// The GitHub API returns this field at both the [`CommitDetails`] level and on
108    /// the outer [`FullCommit`] envelope. Both values are preserved because the
109    /// API response contains the count at each level of the object.
110    pub comment_count: u32,
111}
112
113/// An identity record stored in a Git commit object.
114///
115/// Corresponds to Git's `user.name` and `user.email` configuration values
116/// together with the timestamp of the action (authoring or committing).
117///
118/// # Examples
119///
120/// ```no_run
121/// # use github_bot_sdk::client::InstallationClient;
122/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
123/// let commit = client.get_commit("owner", "repo", "abc123").await?;
124/// let sig = &commit.commit.author;
125/// println!("{} <{}> at {}", sig.name, sig.email, sig.date);
126/// # Ok(())
127/// # }
128/// ```
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct GitSignature {
131    /// Name from Git config (`user.name`).
132    pub name: String,
133
134    /// Email from Git config (`user.email`).
135    pub email: String,
136
137    /// Timestamp of the action (authoring or committing).
138    pub date: DateTime<Utc>,
139}
140
141/// A minimal reference to a Git object containing only its SHA and API URL.
142///
143/// Used for parent commits, tree references, and related object links.
144///
145/// # Examples
146///
147/// ```no_run
148/// # use github_bot_sdk::client::InstallationClient;
149/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
150/// let commit = client.get_commit("owner", "repo", "abc123").await?;
151/// for parent in &commit.parents {
152///     println!("Parent SHA: {}", parent.sha);
153/// }
154/// # Ok(())
155/// # }
156/// ```
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct CommitReference {
159    /// Git object SHA (40-character hexadecimal hash).
160    pub sha: String,
161
162    /// API URL for the referenced object.
163    pub url: String,
164}
165
166/// GPG or SSH signature verification status for a commit.
167///
168/// GitHub verifies signatures against known public keys associated with GitHub
169/// accounts. The `reason` field explains the outcome of that verification.
170///
171/// # Examples
172///
173/// ```no_run
174/// # use github_bot_sdk::client::InstallationClient;
175/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
176/// let commit = client.get_commit("owner", "repo", "abc123").await?;
177/// if let Some(v) = &commit.commit.verification {
178///     if v.verified {
179///         println!("Commit is signed and verified");
180///     } else {
181///         println!("Signature issue: {}", v.reason);
182///     }
183/// }
184/// # Ok(())
185/// # }
186/// ```
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Verification {
189    /// Whether the signature is cryptographically valid and the key is trusted.
190    pub verified: bool,
191
192    /// Human-readable reason for the verification status.
193    ///
194    /// Common values: `"valid"`, `"invalid"`, `"expired_key"`, `"unknown_key"`,
195    /// `"unsigned"`, `"no_user"`.
196    pub reason: String,
197
198    /// Raw GPG signature payload, if present.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub signature: Option<String>,
201
202    /// Signed content (the data that was signed), if present.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub payload: Option<String>,
205}
206
207/// The result of comparing two Git refs (commits, branches, or tags).
208///
209/// Contains the full list of commits between the two refs and every file that
210/// changed, together with statistics useful for generating changelogs and
211/// release notes.
212///
213/// # Examples
214///
215/// ```no_run
216/// # use github_bot_sdk::client::InstallationClient;
217/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
218/// let cmp = client.compare_commits("owner", "repo", "v1.0.0", "v1.1.0").await?;
219/// println!("Status: {} ({} ahead, {} behind)",
220///     cmp.status, cmp.ahead_by, cmp.behind_by);
221/// println!("{} commits, {} files changed",
222///     cmp.total_commits, cmp.files.len());
223/// # Ok(())
224/// # }
225/// ```
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Comparison {
228    /// The base commit (starting point of the comparison).
229    pub base_commit: FullCommit,
230
231    /// The common ancestor (merge-base) of the two refs.
232    pub merge_base_commit: FullCommit,
233
234    /// The head commit (ending point of the comparison).
235    pub head_commit: FullCommit,
236
237    /// Relationship between base and head.
238    ///
239    /// Possible values: `"ahead"`, `"behind"`, `"identical"`, `"diverged"`.
240    pub status: String,
241
242    /// Number of commits the head is ahead of the base.
243    pub ahead_by: u32,
244
245    /// Number of commits the head is behind the base.
246    pub behind_by: u32,
247
248    /// Total number of commits between base and head.
249    pub total_commits: u32,
250
251    /// All commits from base to head in chronological order (oldest first).
252    ///
253    /// GitHub limits this to 250 commits.
254    pub commits: Vec<FullCommit>,
255
256    /// All files that changed between base and head.
257    pub files: Vec<FileChange>,
258
259    /// Web interface URL for this comparison.
260    pub html_url: String,
261
262    /// Permalink URL for this comparison.
263    pub permalink_url: String,
264
265    /// URL for the diff view.
266    pub diff_url: String,
267
268    /// URL for the patch view.
269    pub patch_url: String,
270
271    /// API URL for this comparison (machine-readable endpoint).
272    pub url: String,
273}
274
275/// A single file change within a [`Comparison`].
276///
277/// Carries per-file statistics (additions, deletions) and the unified diff
278/// patch when available.
279///
280/// # Examples
281///
282/// ```no_run
283/// # use github_bot_sdk::client::InstallationClient;
284/// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
285/// let cmp = client.compare_commits("owner", "repo", "v1.0.0", "v1.1.0").await?;
286/// for file in &cmp.files {
287///     println!("{} [{}]: +{} -{}", file.filename, file.status,
288///         file.additions, file.deletions);
289///     if let Some(prev) = &file.previous_filename {
290///         println!("  (renamed from {})", prev);
291///     }
292/// }
293/// # Ok(())
294/// # }
295/// ```
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct FileChange {
298    /// Current file path in the repository.
299    pub filename: String,
300
301    /// Change status for this file.
302    ///
303    /// Possible values: `"added"`, `"removed"`, `"modified"`, `"renamed"`,
304    /// `"copied"`, `"changed"`, `"unchanged"`.
305    pub status: String,
306
307    /// Number of lines added.
308    pub additions: u32,
309
310    /// Number of lines deleted.
311    pub deletions: u32,
312
313    /// Total lines changed (`additions + deletions`).
314    pub changes: u32,
315
316    /// URL for the blob view of this file.
317    pub blob_url: String,
318
319    /// URL for the raw file content.
320    pub raw_url: String,
321
322    /// GitHub Contents API URL for this file.
323    pub contents_url: String,
324
325    /// Unified diff patch for this file.
326    ///
327    /// May be `None` for binary files or when the diff exceeds GitHub's size
328    /// limits.
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub patch: Option<String>,
331
332    /// Previous file path, present only when `status` is `"renamed"` or
333    /// `"copied"`.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub previous_filename: Option<String>,
336}
337
338// ============================================================================
339// Operations
340// ============================================================================
341
342impl InstallationClient {
343    // ------------------------------------------------------------------------
344    // Commit Operations
345    // ------------------------------------------------------------------------
346
347    /// Get a single commit by SHA, branch name, or tag name.
348    ///
349    /// Retrieves complete commit details including the author, committer,
350    /// commit message, parent references, and GPG verification status.
351    ///
352    /// # Arguments
353    ///
354    /// * `owner` - Repository owner login
355    /// * `repo` - Repository name
356    /// * `ref_name` - Commit SHA (full or abbreviated), branch name, or tag name
357    ///
358    /// # Returns
359    ///
360    /// Returns a [`FullCommit`] with complete metadata.
361    ///
362    /// # Errors
363    ///
364    /// * [`ApiError::NotFound`] - Commit or ref doesn't exist, or repository not found
365    /// * [`ApiError::InvalidRequest`] - Invalid ref syntax or empty repository (GitHub returns 422)
366    /// * [`ApiError::AuthorizationFailed`] - Missing `contents:read` permission
367    /// * [`ApiError::AuthenticationFailed`] - Token expired or invalid
368    /// * [`ApiError::HttpError`] - Unexpected API response
369    ///
370    /// # Examples
371    ///
372    /// ```no_run
373    /// # use github_bot_sdk::client::InstallationClient;
374    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
375    /// // Get by SHA
376    /// let commit = client.get_commit("owner", "repo", "abc123def456").await?;
377    /// println!("Message: {}", commit.commit.message);
378    ///
379    /// // Get by branch name
380    /// let head = client.get_commit("owner", "repo", "main").await?;
381    /// println!("HEAD: {}", head.sha);
382    ///
383    /// // Get by tag
384    /// let release = client.get_commit("owner", "repo", "v1.0.0").await?;
385    /// println!("Release commit: {}", release.sha);
386    /// # Ok(())
387    /// # }
388    /// ```
389    ///
390    /// # GitHub API
391    ///
392    /// `GET /repos/{owner}/{repo}/commits/{ref}`
393    ///
394    /// See <https://docs.github.com/en/rest/commits/commits#get-a-commit>
395    pub async fn get_commit(
396        &self,
397        owner: &str,
398        repo: &str,
399        ref_name: &str,
400    ) -> Result<FullCommit, ApiError> {
401        let path = format!(
402            "/repos/{}/{}/commits/{}",
403            owner,
404            repo,
405            urlencoding::encode(ref_name)
406        );
407        let response = self.get(&path).await?;
408        response.json().await.map_err(ApiError::from)
409    }
410
411    /// List commits in a repository with optional filtering.
412    ///
413    /// Returns commits in reverse chronological order (newest first). All
414    /// filter arguments are combined with AND logic.
415    ///
416    /// # Arguments
417    ///
418    /// * `owner` - Repository owner login
419    /// * `repo` - Repository name
420    /// * `sha` - SHA or branch name to start listing from (default: repository default branch)
421    /// * `path` - Only return commits that modified this file path or directory
422    /// * `author` - Filter by GitHub username or commit author email address
423    /// * `since` - Only include commits after this timestamp (inclusive)
424    /// * `until` - Only include commits before this timestamp (inclusive)
425    /// * `per_page` - Number of results per page; clamped to the range 1..=100
426    /// * `page` - Page number for pagination (1-based, default: 1)
427    ///
428    /// # Returns
429    ///
430    /// Returns a [`Vec<FullCommit>`] in reverse chronological order.
431    ///
432    /// # Errors
433    ///
434    /// * [`ApiError::NotFound`] - Repository not found
435    /// * [`ApiError::InvalidRequest`] - Repository is empty (GitHub returns 422)
436    /// * [`ApiError::AuthorizationFailed`] - Missing `contents:read` permission
437    /// * [`ApiError::AuthenticationFailed`] - Token expired or invalid
438    /// * [`ApiError::HttpError`] - Unexpected API response
439    ///
440    /// # Examples
441    ///
442    /// ```no_run
443    /// # use github_bot_sdk::client::InstallationClient;
444    /// # use chrono::{DateTime, Utc};
445    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
446    /// // List recent commits on the default branch
447    /// let commits = client.list_commits(
448    ///     "owner", "repo",
449    ///     None, None, None, None, None, None, None
450    /// ).await?;
451    ///
452    /// // List commits on a feature branch
453    /// let feature_commits = client.list_commits(
454    ///     "owner", "repo",
455    ///     Some("feature-branch"), None, None, None, None, None, None
456    /// ).await?;
457    ///
458    /// // List commits affecting a specific file
459    /// let readme_commits = client.list_commits(
460    ///     "owner", "repo",
461    ///     None, Some("README.md"), None, None, None, Some(50), None
462    /// ).await?;
463    ///
464    /// // List commits by author in a date range
465    /// let since = "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>()?;
466    /// let until = "2026-01-31T23:59:59Z".parse::<DateTime<Utc>>()?;
467    /// let author_commits = client.list_commits(
468    ///     "owner", "repo",
469    ///     None, None, Some("alice"), Some(since), Some(until), None, None
470    /// ).await?;
471    /// # Ok(())
472    /// # }
473    /// ```
474    ///
475    /// # Notes
476    ///
477    /// - `per_page` is silently clamped to the range 1..=100 (GitHub API maximum is 100; 0 is raised to 1).
478    /// - Empty repositories cause GitHub to return 422, mapped to [`ApiError::InvalidRequest`].
479    ///
480    /// # GitHub API
481    ///
482    /// `GET /repos/{owner}/{repo}/commits`
483    ///
484    /// See <https://docs.github.com/en/rest/commits/commits#list-commits>
485    #[allow(clippy::too_many_arguments)]
486    pub async fn list_commits(
487        &self,
488        owner: &str,
489        repo: &str,
490        sha: Option<&str>,
491        path: Option<&str>,
492        author: Option<&str>,
493        since: Option<DateTime<Utc>>,
494        until: Option<DateTime<Utc>>,
495        per_page: Option<u32>,
496        page: Option<u32>,
497    ) -> Result<Vec<FullCommit>, ApiError> {
498        let base = format!("/repos/{}/{}/commits", owner, repo);
499        let mut query_params: Vec<String> = Vec::new();
500
501        if let Some(s) = sha {
502            query_params.push(format!("sha={}", urlencoding::encode(s)));
503        }
504        if let Some(p) = path {
505            query_params.push(format!("path={}", urlencoding::encode(p)));
506        }
507        if let Some(a) = author {
508            query_params.push(format!("author={}", urlencoding::encode(a)));
509        }
510        if let Some(s) = since {
511            query_params.push(format!("since={}", urlencoding::encode(&s.to_rfc3339())));
512        }
513        if let Some(u) = until {
514            query_params.push(format!("until={}", urlencoding::encode(&u.to_rfc3339())));
515        }
516        if let Some(pp) = per_page {
517            let clamped = pp.clamp(1, 100);
518            query_params.push(format!("per_page={}", clamped));
519        }
520        if let Some(pg) = page {
521            query_params.push(format!("page={}", pg));
522        }
523
524        let request_path = if query_params.is_empty() {
525            base
526        } else {
527            format!("{}?{}", base, query_params.join("&"))
528        };
529
530        let response = self.get(&request_path).await?;
531        response.json().await.map_err(ApiError::from)
532    }
533
534    /// Compare two commits, branches, or tags.
535    ///
536    /// Returns a complete comparison including the list of commits between the
537    /// two refs and every file that changed, with per-file statistics. Useful
538    /// for changelog generation and release notes automation.
539    ///
540    /// # Arguments
541    ///
542    /// * `owner` - Repository owner login
543    /// * `repo` - Repository name
544    /// * `base` - Base ref (SHA, branch, or tag) — the starting point
545    /// * `head` - Head ref (SHA, branch, or tag) — the ending point
546    ///
547    /// # Returns
548    ///
549    /// Returns a [`Comparison`] with commits, file changes, and statistics.
550    ///
551    /// # Errors
552    ///
553    /// * [`ApiError::NotFound`] - Base or head ref not found, or repository not found
554    /// * [`ApiError::AuthorizationFailed`] - Missing `contents:read` permission
555    /// * [`ApiError::AuthenticationFailed`] - Token expired or invalid
556    /// * [`ApiError::HttpError`] - Unexpected API response
557    ///
558    /// # Examples
559    ///
560    /// ```no_run
561    /// # use github_bot_sdk::client::InstallationClient;
562    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
563    /// // Compare two tags for release notes
564    /// let cmp = client.compare_commits("owner", "repo", "v1.0.0", "v1.1.0").await?;
565    ///
566    /// println!("Status: {}", cmp.status);
567    /// println!("Commits: {}", cmp.total_commits);
568    /// println!("Files changed: {}", cmp.files.len());
569    ///
570    /// for commit in &cmp.commits {
571    ///     let subject = commit.commit.message.lines().next().unwrap_or("");
572    ///     println!("- {}", subject);
573    /// }
574    ///
575    /// // Compare a feature branch to main
576    /// let branch_diff = client.compare_commits("owner", "repo", "main", "feature-x").await?;
577    /// match branch_diff.status.as_str() {
578    ///     "ahead"    => println!("Feature is {} commits ahead", branch_diff.ahead_by),
579    ///     "behind"   => println!("Feature is {} commits behind", branch_diff.behind_by),
580    ///     "identical" => println!("Branches are identical"),
581    ///     "diverged" => println!("Branches have diverged"),
582    ///     _ => {}
583    /// }
584    /// # Ok(())
585    /// # }
586    /// ```
587    ///
588    /// # Comparison Status Values
589    ///
590    /// | Status | Meaning |
591    /// |--------|---------|
592    /// | `"ahead"` | Head has commits not in base |
593    /// | `"behind"` | Base has commits not in head |
594    /// | `"identical"` | Both refs point to the same commit |
595    /// | `"diverged"` | Refs have different histories |
596    ///
597    /// # Notes
598    ///
599    /// - Commits are returned in chronological order (oldest to newest).
600    /// - GitHub limits comparisons to 250 commits.
601    /// - File patches may be absent for binary files or very large diffs.
602    ///
603    /// # GitHub API
604    ///
605    /// `GET /repos/{owner}/{repo}/compare/{base}...{head}`
606    ///
607    /// See <https://docs.github.com/en/rest/commits/commits#compare-two-commits>
608    pub async fn compare_commits(
609        &self,
610        owner: &str,
611        repo: &str,
612        base: &str,
613        head: &str,
614    ) -> Result<Comparison, ApiError> {
615        let path = format!(
616            "/repos/{}/{}/compare/{}...{}",
617            owner,
618            repo,
619            urlencoding::encode(base),
620            urlencoding::encode(head)
621        );
622        let response = self.get(&path).await?;
623        response.json().await.map_err(ApiError::from)
624    }
625}
626
627#[cfg(test)]
628#[path = "commit_tests.rs"]
629mod tests;