1#![allow(missing_docs)]
2
3use std::fmt;
4use thiserror::Error;
5
6pub type Result<T> = std::result::Result<T, GitHubError>;
7
8#[derive(Debug, Error)]
9pub enum GitHubError {
10 #[error("GitHub client not initialized - call start() first")]
11 ClientNotInitialized,
12
13 #[error("Configuration error: {0}")]
14 ConfigError(String),
15
16 #[error("Missing required setting: {0}")]
17 MissingSetting(String),
18
19 #[error("Invalid argument: {0}")]
20 InvalidArgument(String),
21
22 #[error("Repository not found: {owner}/{repo}")]
23 RepositoryNotFound { owner: String, repo: String },
24
25 #[error("Branch not found: {branch} in {owner}/{repo}")]
26 BranchNotFound {
27 branch: String,
28 owner: String,
29 repo: String,
30 },
31
32 #[error("File not found: {path} in {owner}/{repo}")]
33 FileNotFound {
34 path: String,
35 owner: String,
36 repo: String,
37 },
38
39 #[error("Issue #{issue_number} not found in {owner}/{repo}")]
40 IssueNotFound {
41 issue_number: u64,
42 owner: String,
43 repo: String,
44 },
45
46 #[error("Pull request #{pull_number} not found in {owner}/{repo}")]
47 PullRequestNotFound {
48 pull_number: u64,
49 owner: String,
50 repo: String,
51 },
52
53 #[error("Permission denied: {0}")]
54 PermissionDenied(String),
55
56 #[error("Rate limited by GitHub API, retry after {retry_after_ms}ms")]
57 RateLimited {
58 retry_after_ms: u64,
59 remaining: u32,
60 reset_at: chrono::DateTime<chrono::Utc>,
61 },
62
63 #[error("Secondary rate limit hit, retry after {retry_after_ms}ms")]
64 SecondaryRateLimit { retry_after_ms: u64 },
65
66 #[error("Merge conflict in pull request #{pull_number} in {owner}/{repo}")]
67 MergeConflict {
68 pull_number: u64,
69 owner: String,
70 repo: String,
71 },
72
73 #[error("Branch already exists: {branch} in {owner}/{repo}")]
74 BranchExists {
75 branch: String,
76 owner: String,
77 repo: String,
78 },
79
80 #[error("Validation failed for {field}: {reason}")]
81 ValidationFailed { field: String, reason: String },
82
83 #[error("GitHub API error ({status}): {message}")]
84 ApiError {
85 status: u16,
86 message: String,
87 code: Option<String>,
88 documentation_url: Option<String>,
89 },
90
91 #[error("Network error: {0}")]
92 NetworkError(String),
93
94 #[error("Operation timed out after {timeout_ms}ms: {operation}")]
95 Timeout { timeout_ms: u64, operation: String },
96
97 #[error("Git operation failed ({operation}): {reason}")]
98 GitOperation { operation: String, reason: String },
99
100 #[error("Webhook verification failed: {0}")]
101 WebhookVerification(String),
102
103 #[error("Serialization error: {0}")]
104 SerializationError(String),
105
106 #[error("Internal error: {0}")]
107 Internal(String),
108
109 #[cfg(feature = "native")]
110 #[error("GitHub API error: {0}")]
111 OctocrabError(#[from] octocrab::Error),
112}
113
114impl GitHubError {
115 pub fn is_retryable(&self) -> bool {
116 matches!(
117 self,
118 GitHubError::RateLimited { .. }
119 | GitHubError::SecondaryRateLimit { .. }
120 | GitHubError::Timeout { .. }
121 | GitHubError::NetworkError(_)
122 )
123 }
124
125 pub fn retry_after_ms(&self) -> Option<u64> {
126 match self {
127 GitHubError::RateLimited { retry_after_ms, .. } => Some(*retry_after_ms),
128 GitHubError::SecondaryRateLimit { retry_after_ms } => Some(*retry_after_ms),
129 GitHubError::Timeout { timeout_ms, .. } => Some(*timeout_ms / 2),
130 _ => None,
131 }
132 }
133}
134
135impl From<serde_json::Error> for GitHubError {
136 fn from(err: serde_json::Error) -> Self {
137 GitHubError::SerializationError(err.to_string())
138 }
139}
140
141impl From<std::io::Error> for GitHubError {
142 fn from(err: std::io::Error) -> Self {
143 GitHubError::Internal(format!("I/O error: {}", err))
144 }
145}
146
147impl From<reqwest::Error> for GitHubError {
148 fn from(err: reqwest::Error) -> Self {
149 if err.is_timeout() {
150 GitHubError::Timeout {
151 timeout_ms: 30000,
152 operation: "HTTP request".to_string(),
153 }
154 } else {
155 GitHubError::NetworkError(err.to_string())
156 }
157 }
158}
159
160#[derive(Debug)]
161pub struct ErrorContext<E: fmt::Display> {
162 error: E,
163 context: String,
164}
165
166impl<E: fmt::Display> fmt::Display for ErrorContext<E> {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 write!(f, "{}: {}", self.context, self.error)
169 }
170}
171
172impl<E: fmt::Display + fmt::Debug> std::error::Error for ErrorContext<E> {}
173
174pub trait WithContext<T, E: fmt::Display> {
175 fn with_context<F: FnOnce() -> String>(self, f: F) -> std::result::Result<T, ErrorContext<E>>;
176}
177
178impl<T, E: fmt::Display> WithContext<T, E> for std::result::Result<T, E> {
179 fn with_context<F: FnOnce() -> String>(self, f: F) -> std::result::Result<T, ErrorContext<E>> {
180 self.map_err(|e| ErrorContext {
181 error: e,
182 context: f(),
183 })
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_error_display() {
193 let err = GitHubError::MissingSetting("GITHUB_API_TOKEN".to_string());
194 assert!(err.to_string().contains("GITHUB_API_TOKEN"));
195 }
196
197 #[test]
198 fn test_error_retryable() {
199 assert!(GitHubError::RateLimited {
200 retry_after_ms: 1000,
201 remaining: 0,
202 reset_at: chrono::Utc::now(),
203 }
204 .is_retryable());
205 assert!(GitHubError::Timeout {
206 timeout_ms: 5000,
207 operation: "test".to_string(),
208 }
209 .is_retryable());
210 assert!(!GitHubError::ClientNotInitialized.is_retryable());
211 }
212
213 #[test]
214 fn test_retry_after() {
215 let err = GitHubError::RateLimited {
216 retry_after_ms: 1000,
217 remaining: 0,
218 reset_at: chrono::Utc::now(),
219 };
220 assert_eq!(err.retry_after_ms(), Some(1000));
221
222 let err = GitHubError::ClientNotInitialized;
223 assert_eq!(err.retry_after_ms(), None);
224 }
225}