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;