Skip to main content

objectiveai_sdk/http/
github.rs

1//! GitHub retrieval for agents, swarms, functions, and profiles.
2//!
3//! Extends [`HttpClient`] with methods that fetch `agent.json` / `swarm.json`
4//! / `function.json` / `profile.json` for a `(owner, repository, commit)`
5//! straight from GitHub, plus latest-commit resolution.
6//!
7//! The fetch strategy mirrors the server: try `raw.githubusercontent.com`
8//! first, fall back to the GitHub Contents API, all wrapped in exponential
9//! backoff. Authorization uses the client's [`HttpClient::x_github_authorization`]
10//! as the GitHub `Bearer` token (the GitHub token), never the ObjectiveAI API
11//! key in `authorization`.
12
13use super::{HttpClient, HttpError};
14
15impl HttpClient {
16    /// Resolves the latest commit SHA for a repository.
17    ///
18    /// Returns `Ok(None)` if the repository (or its commit list) cannot be
19    /// found.
20    pub async fn github_resolve_commit(
21        &self,
22        owner: &str,
23        repository: &str,
24    ) -> Result<Option<String>, HttpError> {
25        #[derive(serde::Deserialize)]
26        struct Commit {
27            sha: String,
28        }
29        let request = self.github_request_headers(
30            self.http_client
31                .get(format!(
32                    "https://api.github.com/repos/{}/{}/commits",
33                    owner, repository,
34                ))
35                .header("accept", "application/vnd.github+json"),
36        );
37        backoff::future::retry(backoff::ExponentialBackoff::default(), || async {
38            let response = request
39                .try_clone()
40                .unwrap()
41                .send()
42                .await
43                .map_err(HttpError::HttpError)?;
44            let code = response.status();
45            if code.is_success() {
46                let text =
47                    response.text().await.map_err(HttpError::HttpError)?;
48                let mut de = serde_json::Deserializer::from_str(&text);
49                match serde_path_to_error::deserialize::<_, Vec<Commit>>(
50                    &mut de,
51                ) {
52                    Ok(commits) => Ok(commits.first().map(|c| c.sha.clone())),
53                    Err(e) => Err(backoff::Error::transient(
54                        HttpError::DeserializationError(e),
55                    )),
56                }
57            } else if code == reqwest::StatusCode::NOT_FOUND {
58                Ok(None)
59            } else {
60                Err(backoff::Error::transient(
61                    github_bad_status(response).await,
62                ))
63            }
64        })
65        .await
66    }
67
68    /// Fetches an Agent definition (`agent.json`) from GitHub.
69    ///
70    /// If `commit` is `None`, the latest commit is resolved first. Returns
71    /// `Ok(None)` if the repository, commit, or file does not exist.
72    pub async fn github_get_agent(
73        &self,
74        owner: &str,
75        repository: &str,
76        commit: Option<&str>,
77    ) -> Result<Option<crate::agent::RemoteAgentBaseWithFallbacks>, HttpError>
78    {
79        self.github_get_entity(owner, repository, commit, "agent.json")
80            .await
81    }
82
83    /// Fetches a Swarm definition (`swarm.json`) from GitHub.
84    ///
85    /// If `commit` is `None`, the latest commit is resolved first. Returns
86    /// `Ok(None)` if the repository, commit, or file does not exist.
87    pub async fn github_get_swarm(
88        &self,
89        owner: &str,
90        repository: &str,
91        commit: Option<&str>,
92    ) -> Result<Option<crate::swarm::RemoteSwarmBase>, HttpError> {
93        self.github_get_entity(owner, repository, commit, "swarm.json")
94            .await
95    }
96
97    /// Fetches a Function definition (`function.json`) from GitHub.
98    ///
99    /// If `commit` is `None`, the latest commit is resolved first. Returns
100    /// `Ok(None)` if the repository, commit, or file does not exist.
101    pub async fn github_get_function(
102        &self,
103        owner: &str,
104        repository: &str,
105        commit: Option<&str>,
106    ) -> Result<Option<crate::functions::FullRemoteFunction>, HttpError> {
107        self.github_get_entity(owner, repository, commit, "function.json")
108            .await
109    }
110
111    /// Fetches a Profile definition (`profile.json`) from GitHub.
112    ///
113    /// If `commit` is `None`, the latest commit is resolved first. Returns
114    /// `Ok(None)` if the repository, commit, or file does not exist.
115    pub async fn github_get_profile(
116        &self,
117        owner: &str,
118        repository: &str,
119        commit: Option<&str>,
120    ) -> Result<Option<crate::functions::RemoteProfile>, HttpError> {
121        self.github_get_entity(owner, repository, commit, "profile.json")
122            .await
123    }
124
125    /// Resolves the commit (if not provided) and reads + deserializes the
126    /// entity's JSON file.
127    async fn github_get_entity<T: serde::de::DeserializeOwned>(
128        &self,
129        owner: &str,
130        repository: &str,
131        commit: Option<&str>,
132        path: &str,
133    ) -> Result<Option<T>, HttpError> {
134        let resolved;
135        let commit = match commit {
136            Some(commit) => commit,
137            None => match self.github_resolve_commit(owner, repository).await? {
138                Some(commit) => {
139                    resolved = commit;
140                    resolved.as_str()
141                }
142                None => return Ok(None),
143            },
144        };
145        self.github_read_json(owner, repository, commit, path).await
146    }
147
148    /// Reads a JSON file from a GitHub repository and deserializes it.
149    async fn github_read_json<T: serde::de::DeserializeOwned>(
150        &self,
151        owner: &str,
152        repository: &str,
153        commit: &str,
154        path: &str,
155    ) -> Result<Option<T>, HttpError> {
156        match self.github_read_file(owner, repository, commit, path).await? {
157            Some(text) => {
158                let mut de = serde_json::Deserializer::from_str(&text);
159                match serde_path_to_error::deserialize::<_, T>(&mut de) {
160                    Ok(value) => Ok(Some(value)),
161                    Err(e) => Err(HttpError::DeserializationError(e)),
162                }
163            }
164            None => Ok(None),
165        }
166    }
167
168    /// Reads a file's raw text content from a GitHub repository.
169    ///
170    /// Tries `raw.githubusercontent.com` first, falls back to the Contents
171    /// API, wrapped in exponential backoff. `Ok(None)` on `404`.
172    async fn github_read_file(
173        &self,
174        owner: &str,
175        repository: &str,
176        commit: &str,
177        path: &str,
178    ) -> Result<Option<String>, HttpError> {
179        backoff::future::retry(backoff::ExponentialBackoff::default(), || async {
180            match self
181                .github_fetch_file_raw(owner, repository, commit, path)
182                .await
183            {
184                Ok(opt) => Ok(opt),
185                Err(e1) => match self
186                    .github_fetch_file_api(owner, repository, commit, path)
187                    .await
188                {
189                    Ok(opt) => Ok(opt),
190                    Err(e2) => Err(backoff::Error::transient(
191                        HttpError::MultipleErrors(Box::new(e1), Box::new(e2)),
192                    )),
193                },
194            }
195        })
196        .await
197    }
198
199    /// Fetches a file via `raw.githubusercontent.com`.
200    async fn github_fetch_file_raw(
201        &self,
202        owner: &str,
203        repository: &str,
204        commit: &str,
205        path: &str,
206    ) -> Result<Option<String>, HttpError> {
207        let response = self
208            .github_request_headers(self.http_client.get(format!(
209                "https://raw.githubusercontent.com/{}/{}/{}/{}",
210                owner, repository, commit, path,
211            )))
212            .send()
213            .await
214            .map_err(HttpError::HttpError)?;
215        let code = response.status();
216        if code.is_success() {
217            let text = response.text().await.map_err(HttpError::HttpError)?;
218            Ok(Some(text))
219        } else if code == reqwest::StatusCode::NOT_FOUND {
220            Ok(None)
221        } else {
222            Err(github_bad_status(response).await)
223        }
224    }
225
226    /// Fetches a file via the GitHub Contents API (`?ref={commit}`).
227    async fn github_fetch_file_api(
228        &self,
229        owner: &str,
230        repository: &str,
231        commit: &str,
232        path: &str,
233    ) -> Result<Option<String>, HttpError> {
234        let response = self
235            .github_request_headers(
236                self.http_client
237                    .get(format!(
238                        "https://api.github.com/repos/{}/{}/contents/{}?ref={}",
239                        owner, repository, path, commit,
240                    ))
241                    .header("accept", "application/vnd.github.raw+json"),
242            )
243            .send()
244            .await
245            .map_err(HttpError::HttpError)?;
246        let code = response.status();
247        if code.is_success() {
248            let text = response.text().await.map_err(HttpError::HttpError)?;
249            Ok(Some(text))
250        } else if code == reqwest::StatusCode::NOT_FOUND {
251            Ok(None)
252        } else {
253            Err(github_bad_status(response).await)
254        }
255    }
256
257    /// Adds the GitHub `Bearer` token (from `x_github_authorization`) plus the
258    /// standard `user-agent` / `x-title` / `referer` headers to a request.
259    fn github_request_headers(
260        &self,
261        mut request: reqwest::RequestBuilder,
262    ) -> reqwest::RequestBuilder {
263        if let Some(token) = &self.x_github_authorization {
264            let key = token.strip_prefix("Bearer ").unwrap_or(token.as_str());
265            request =
266                request.header("authorization", format!("Bearer {}", key));
267        }
268        // GitHub requires a User-Agent; fall back to a default when unset.
269        request = request.header(
270            "user-agent",
271            self.user_agent.as_deref().unwrap_or("objectiveai-sdk"),
272        );
273        if let Some(x_title) = &self.x_title {
274            request = request.header("x-title", x_title);
275        }
276        if let Some(http_referer) = &self.http_referer {
277            request = request
278                .header("referer", http_referer)
279                .header("http-referer", http_referer);
280        }
281        request
282    }
283}
284
285/// Builds a `BadStatus` error from a non-success response.
286async fn github_bad_status(response: reqwest::Response) -> HttpError {
287    let code = response.status();
288    match response.text().await {
289        Ok(text) => HttpError::BadStatus {
290            code,
291            body: serde_json::from_str::<serde_json::Value>(&text)
292                .unwrap_or(serde_json::Value::String(text)),
293        },
294        Err(_) => HttpError::BadStatus {
295            code,
296            body: serde_json::Value::Null,
297        },
298    }
299}