devboy_github/
liveness.rs1use async_trait::async_trait;
29use devboy_core::{Error, LivenessProbe, LivenessResult, Result, liveness::LivenessStatus};
30use secrecy::{ExposeSecret, SecretString};
31
32use crate::client::GitHubClient;
33
34const TOKEN_EXPIRATION_HEADER: &str = "github-authentication-token-expiration";
35
36#[async_trait]
37impl LivenessProbe for GitHubClient {
38 fn provider_name(&self) -> &str {
39 "github"
40 }
41
42 async fn test(&self, token: &SecretString) -> Result<LivenessResult> {
43 let url = format!("{}/user", self.base_url());
44 let resp = self
45 .http_client()
46 .get(&url)
47 .header("Accept", "application/vnd.github+json")
48 .header("X-GitHub-Api-Version", "2022-11-28")
49 .header("Authorization", format!("Bearer {}", token.expose_secret()))
50 .send()
51 .await
52 .map_err(|e| Error::Http(format!("github liveness GET /user: {e}")))?;
53
54 let expires_at = resp
55 .headers()
56 .get(TOKEN_EXPIRATION_HEADER)
57 .and_then(|v| v.to_str().ok())
58 .map(str::trim)
59 .filter(|s| !s.is_empty())
60 .map(str::to_owned);
61
62 let status = resp.status();
63 match status.as_u16() {
64 200 => {
65 #[derive(serde::Deserialize)]
66 struct User {
67 login: Option<String>,
68 }
69 let body: User = resp.json().await.unwrap_or(User { login: None });
70 let detail = body.login.unwrap_or_else(|| "github user".to_owned());
71 Ok(LivenessResult {
72 status: LivenessStatus::Live,
73 detail: Some(detail),
74 expires_at,
75 })
76 }
77 401 => Ok(LivenessResult::revoked(
78 "github rejected the token (401 Unauthorized)",
79 )),
80 403 => Ok(LivenessResult::revoked(
81 "github refused the token (403 Forbidden) — revoked or insufficient scope",
82 )),
83 429 => {
84 let retry = resp
85 .headers()
86 .get("retry-after")
87 .and_then(|v| v.to_str().ok())
88 .map(str::to_owned)
89 .unwrap_or_else(|| "unknown".to_owned());
90 Ok(LivenessResult::throttled(format!(
91 "github rate limit exceeded; retry-after: {retry}"
92 )))
93 }
94 other => {
95 let body = resp.text().await.unwrap_or_default();
96 Ok(LivenessResult::error(format!(
97 "github GET /user returned {other}: {}",
98 body.trim()
99 )))
100 }
101 }
102 }
103}
104
105#[cfg(test)]
110mod tests {
111 use super::*;
112 use httpmock::prelude::*;
113
114 fn client_against(base_url: &str) -> GitHubClient {
115 GitHubClient::with_base_url(
116 base_url,
117 "owner",
118 "repo",
119 SecretString::from("ignored-self-token".to_owned()),
120 )
121 }
122
123 #[tokio::test]
124 async fn live_token_returns_login_and_optional_expiry() {
125 let server = MockServer::start_async().await;
126 let _m = server
127 .mock_async(|when, then| {
128 when.method(GET)
129 .path("/user")
130 .header("Authorization", "Bearer ghp_fixture");
131 then.status(200)
132 .header(
133 "github-authentication-token-expiration",
134 "2026-08-01 00:00:00 UTC",
135 )
136 .json_body(serde_json::json!({"login": "octocat"}));
137 })
138 .await;
139
140 let client = client_against(&server.base_url());
141 let r = client
142 .test(&SecretString::from("ghp_fixture".to_owned()))
143 .await
144 .unwrap();
145 assert_eq!(r.status, LivenessStatus::Live);
146 assert_eq!(r.detail.as_deref(), Some("octocat"));
147 assert_eq!(
148 r.expires_at.as_deref(),
149 Some("2026-08-01 00:00:00 UTC"),
150 "expiry header must be plumbed through to result"
151 );
152 }
153
154 #[tokio::test]
155 async fn revoked_on_401() {
156 let server = MockServer::start_async().await;
157 let _m = server
158 .mock_async(|when, then| {
159 when.method(GET).path("/user");
160 then.status(401)
161 .json_body(serde_json::json!({"message": "Bad credentials"}));
162 })
163 .await;
164 let client = client_against(&server.base_url());
165 let r = client
166 .test(&SecretString::from("bad".to_owned()))
167 .await
168 .unwrap();
169 assert_eq!(r.status, LivenessStatus::Revoked);
170 }
171
172 #[tokio::test]
173 async fn revoked_on_403() {
174 let server = MockServer::start_async().await;
175 let _m = server
176 .mock_async(|when, then| {
177 when.method(GET).path("/user");
178 then.status(403);
179 })
180 .await;
181 let client = client_against(&server.base_url());
182 let r = client
183 .test(&SecretString::from("revoked".to_owned()))
184 .await
185 .unwrap();
186 assert_eq!(r.status, LivenessStatus::Revoked);
187 }
188
189 #[tokio::test]
190 async fn throttled_on_429_carries_retry_after() {
191 let server = MockServer::start_async().await;
192 let _m = server
193 .mock_async(|when, then| {
194 when.method(GET).path("/user");
195 then.status(429).header("Retry-After", "60");
196 })
197 .await;
198 let client = client_against(&server.base_url());
199 let r = client
200 .test(&SecretString::from("over-limit".to_owned()))
201 .await
202 .unwrap();
203 assert_eq!(r.status, LivenessStatus::Throttled);
204 assert!(r.detail.unwrap().contains("60"));
205 }
206
207 #[tokio::test]
208 async fn unexpected_status_classified_as_error() {
209 let server = MockServer::start_async().await;
210 let _m = server
211 .mock_async(|when, then| {
212 when.method(GET).path("/user");
213 then.status(503).body("upstream down");
214 })
215 .await;
216 let client = client_against(&server.base_url());
217 let r = client
218 .test(&SecretString::from("any".to_owned()))
219 .await
220 .unwrap();
221 assert_eq!(r.status, LivenessStatus::Error);
222 }
223
224 #[test]
225 fn provider_name_is_github() {
226 let client = GitHubClient::new("owner", "repo", SecretString::from("any".to_owned()));
227 assert_eq!(client.provider_name(), "github");
228 }
229}