1use regex::Regex;
15use serde::Serialize;
16use serde_json::{json, Map, Value};
17use std::fmt;
18
19const GITHUB_API: &str = "https://api.github.com";
20const API_VERSION: &str = "2022-11-28";
21const JSON_ACCEPT: &str = "application/vnd.github+json";
22const DIFF_ACCEPT: &str = "application/vnd.github.diff";
23const USER_AGENT: &str = concat!("NoMoreIDE/", env!("CARGO_PKG_VERSION"));
27
28pub(crate) fn loopback_override(variable: &str, fallback: &str) -> String {
36 let Ok(override_value) = std::env::var(variable) else {
37 return fallback.to_string();
38 };
39 let override_value = override_value.trim();
40 let rest = match override_value
41 .strip_prefix("http://")
42 .or_else(|| override_value.strip_prefix("https://"))
43 {
44 Some(rest) => rest,
45 None => return fallback.to_string(),
46 };
47 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
48 let host = match authority.rsplit_once(':') {
49 Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host,
50 _ => authority,
51 };
52 if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
53 return fallback.to_string();
54 }
55 override_value.trim_end_matches('/').to_string()
56}
57
58pub fn api_base() -> String {
60 loopback_override("NOMOREIDE_GITHUB_API_BASE", GITHUB_API)
61}
62
63#[derive(Debug, Clone)]
64pub struct GithubApiError {
65 pub message: String,
66 pub status: u16,
67 pub path: String,
68}
69
70impl fmt::Display for GithubApiError {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(formatter, "{}", self.message)
73 }
74}
75
76impl std::error::Error for GithubApiError {}
77
78impl GithubApiError {
79 fn transport(error: reqwest::Error, path: &str) -> Self {
80 Self {
81 message: error.to_string(),
82 status: 0,
83 path: path.to_string(),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize)]
92pub struct GithubPr {
93 pub number: i64,
94 pub title: String,
95 pub state: String,
96 pub body: Value,
97 pub html_url: String,
98 pub head: Value,
99 pub base: Value,
100 pub user: Value,
101 pub created_at: String,
102 pub updated_at: String,
103 pub merged_at: Value,
104 pub draft: bool,
105 pub mergeable: Value,
106}
107
108#[derive(Debug, Clone, Serialize)]
121#[serde(rename_all = "camelCase")]
122pub struct GithubCompareSummary {
123 pub status: Option<Value>,
127 pub ahead_by: Option<Value>,
128 pub head_sha: Option<String>,
129 pub commits: Vec<Value>,
130 pub files: Vec<Value>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct CommitCiStatus {
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub sha: Option<String>,
138 pub state: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub total_count: Option<i64>,
141 pub runs: Vec<Value>,
142}
143
144pub struct GithubManager {
145 token: String,
146 owner: String,
147 repo: String,
148 base_url: String,
149}
150
151impl GithubManager {
152 pub fn new(
153 token: impl Into<String>,
154 owner: impl Into<String>,
155 repo: impl Into<String>,
156 ) -> Self {
157 Self {
158 token: token.into(),
159 owner: owner.into(),
160 repo: repo.into(),
161 base_url: api_base(),
162 }
163 }
164
165 pub fn parse_remote_url(remote_url: &str) -> Option<(String, String)> {
169 let trimmed = remote_url.trim();
170 let https = Regex::new(r"^https?://(?:[^@]+@)?github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$")
171 .expect("static regex");
172 if let Some(captures) = https.captures(trimmed) {
173 return Some((captures[1].to_string(), captures[2].to_string()));
174 }
175 let ssh =
176 Regex::new(r"^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$").expect("static regex");
177 ssh.captures(trimmed)
178 .map(|captures| (captures[1].to_string(), captures[2].to_string()))
179 }
180
181 fn repo_path(&self, suffix: &str) -> String {
182 format!("/repos/{}/{}{suffix}", self.owner, self.repo)
183 }
184
185 pub async fn viewer(&self) -> Result<Value, GithubApiError> {
189 self.get("/user").await
190 }
191
192 pub async fn repo_info(&self) -> Result<Value, GithubApiError> {
194 self.get(&self.repo_path("")).await
195 }
196
197 pub async fn list_branches(&self) -> Result<Vec<Value>, GithubApiError> {
206 let path = self.repo_path("/branches?per_page=100");
207 let data = self.get(&path).await?;
208 array(&data)
209 .iter()
210 .map(|branch| {
211 let commit = branch.get("commit").ok_or_else(|| GithubApiError {
212 message: "Cannot read properties of undefined (reading 'sha')".into(),
213 status: 0,
214 path: path.clone(),
215 })?;
216 let mut sha = Map::new();
217 if let Some(value) = commit.get("sha") {
218 sha.insert("sha".into(), value.clone());
219 }
220 let mut out = Map::new();
221 copy(&mut out, branch, "name");
222 copy(&mut out, branch, "protected");
223 out.insert("commit".into(), Value::Object(sha));
224 Ok(Value::Object(out))
225 })
226 .collect()
227 }
228
229 pub async fn list_pr_files(&self, number: i64) -> Result<Vec<Value>, GithubApiError> {
234 let data = self
235 .get(&self.repo_path(&format!("/pulls/{number}/files?per_page=100")))
236 .await?;
237 Ok(array(&data)
238 .iter()
239 .map(|file| {
240 let mut out = Map::new();
241 if let Some(value) = file.get("filename") {
242 out.insert("path".into(), value.clone());
243 }
244 for key in [
245 "status",
246 "additions",
247 "deletions",
248 "changes",
249 "patch",
250 "blob_url",
251 ] {
252 copy(&mut out, file, key);
253 }
254 Value::Object(out)
255 })
256 .collect())
257 }
258
259 pub async fn list_pr_reviews(&self, number: i64) -> Result<Vec<Value>, GithubApiError> {
260 let data = self
261 .get(&self.repo_path(&format!("/pulls/{number}/reviews?per_page=100")))
262 .await?;
263 Ok(array(&data).to_vec())
264 }
265
266 pub async fn list_workflow_run_jobs(
269 &self,
270 run_id: i64,
271 ) -> Result<Option<Value>, GithubApiError> {
272 let data = self
273 .get(&self.repo_path(&format!("/actions/runs/{run_id}/jobs?per_page=100")))
274 .await?;
275 Ok(data.get("jobs").cloned())
276 }
277
278 pub async fn compare_branches(
288 &self,
289 base: &str,
290 head: &str,
291 ) -> Result<GithubCompareSummary, GithubApiError> {
292 let path = self.repo_path(&format!(
293 "/compare/{}...{}",
294 encode_uri_component(base),
295 encode_uri_component(head)
296 ));
297 let data = self.get(&path).await?;
298 let commits = array(data.get("commits").unwrap_or(&Value::Null)).to_vec();
299 Ok(GithubCompareSummary {
300 status: data.get("status").cloned(),
301 ahead_by: data.get("ahead_by").cloned(),
302 head_sha: commits
303 .last()
304 .and_then(|commit| commit.get("sha"))
305 .and_then(Value::as_str)
306 .map(str::to_string),
307 commits: commits
308 .iter()
309 .map(|commit| {
310 let mut out = Map::new();
311 copy(&mut out, commit, "sha");
312 out.insert(
313 "message".into(),
314 Value::String(first_line(
315 commit
316 .get("commit")
317 .and_then(|inner| inner.get("message"))
318 .and_then(Value::as_str)
319 .unwrap_or_default(),
320 )),
321 );
322 Value::Object(out)
323 })
324 .collect(),
325 files: array(data.get("files").unwrap_or(&Value::Null))
328 .iter()
329 .map(|file| {
330 let mut out = Map::new();
331 if let Some(value) = file.get("filename") {
332 out.insert("path".into(), value.clone());
333 }
334 for key in ["status", "additions", "deletions", "changes"] {
335 copy(&mut out, file, key);
336 }
337 Value::Object(out)
338 })
339 .collect(),
340 })
341 }
342
343 pub async fn list_prs(&self, state: &str, page: &str) -> Result<Vec<GithubPr>, GithubApiError> {
344 let path = self.repo_path(&format!("/pulls?state={state}&per_page=30&page={page}"));
345 let data = self.get(&path).await?;
346 Ok(array(&data).iter().map(normalize_pr).collect())
347 }
348
349 pub async fn get_pr(&self, number: i64) -> Result<GithubPr, GithubApiError> {
350 let data = self
351 .get(&self.repo_path(&format!("/pulls/{number}")))
352 .await?;
353 Ok(normalize_pr(&data))
354 }
355
356 pub async fn pr_diff(&self, number: i64) -> Result<String, GithubApiError> {
357 self.text(&self.repo_path(&format!("/pulls/{number}")))
358 .await
359 }
360
361 pub async fn create_pr(
362 &self,
363 title: &str,
364 body: Option<&str>,
365 head: &str,
366 base: &str,
367 draft: bool,
368 ) -> Result<GithubPr, GithubApiError> {
369 let mut payload = Map::new();
370 payload.insert("title".into(), json!(title));
371 if let Some(body) = body {
374 payload.insert("body".into(), json!(body));
375 }
376 payload.insert("head".into(), json!(head));
377 payload.insert("base".into(), json!(base));
378 payload.insert("draft".into(), json!(draft));
379 let data = self
380 .send(
381 "POST",
382 &self.repo_path("/pulls"),
383 Some(Value::Object(payload)),
384 )
385 .await?;
386 Ok(normalize_pr(&data))
387 }
388
389 pub async fn merge_pr(
393 &self,
394 number: i64,
395 method: &str,
396 commit_title: Option<&str>,
397 commit_message: Option<&str>,
398 ) -> Result<Value, GithubApiError> {
399 let mut payload = Map::new();
400 payload.insert("merge_method".into(), json!(method));
401 if let Some(title) = commit_title.filter(|value| !value.is_empty()) {
402 payload.insert("commit_title".into(), json!(title));
403 }
404 if let Some(message) = commit_message.filter(|value| !value.is_empty()) {
405 payload.insert("commit_message".into(), json!(message));
406 }
407 self.send(
408 "PUT",
409 &self.repo_path(&format!("/pulls/{number}/merge")),
410 Some(Value::Object(payload)),
411 )
412 .await
413 }
414
415 pub async fn list_issues(&self, state: &str, page: &str) -> Result<Vec<Value>, GithubApiError> {
418 let path = self.repo_path(&format!("/issues?state={state}&per_page=30&page={page}"));
419 let data = self.get(&path).await?;
420 Ok(array(&data)
421 .iter()
422 .filter(|issue| !has_content(issue.get("pull_request")))
423 .cloned()
424 .collect())
425 }
426
427 pub async fn get_issue(&self, number: i64) -> Result<Value, GithubApiError> {
428 self.get(&self.repo_path(&format!("/issues/{number}")))
429 .await
430 }
431
432 pub async fn create_issue(
433 &self,
434 title: &str,
435 body: Option<&str>,
436 ) -> Result<Value, GithubApiError> {
437 let mut payload = Map::new();
438 payload.insert("title".into(), json!(title));
439 if let Some(body) = body {
440 payload.insert("body".into(), json!(body));
441 }
442 self.send(
443 "POST",
444 &self.repo_path("/issues"),
445 Some(Value::Object(payload)),
446 )
447 .await
448 }
449
450 pub async fn list_issue_comments(&self, number: i64) -> Result<Vec<Value>, GithubApiError> {
451 let path = self.repo_path(&format!("/issues/{number}/comments?per_page=100"));
452 Ok(array(&self.get(&path).await?).to_vec())
453 }
454
455 pub async fn add_issue_comment(
456 &self,
457 number: i64,
458 body: &str,
459 ) -> Result<Value, GithubApiError> {
460 self.send(
461 "POST",
462 &self.repo_path(&format!("/issues/{number}/comments")),
463 Some(json!({ "body": body })),
464 )
465 .await
466 }
467
468 pub async fn commit_checks(&self, sha: Option<&str>) -> Result<CommitCiStatus, GithubApiError> {
479 let path = self.repo_path(&format!(
480 "/commits/{}/check-runs?per_page=100",
481 sha.unwrap_or("undefined")
482 ));
483 match self.get(&path).await {
484 Ok(data) => {
485 let runs = data
489 .get("check_runs")
490 .and_then(Value::as_array)
491 .ok_or_else(|| GithubApiError {
492 message: "Cannot read properties of undefined (reading 'length')".into(),
493 status: 0,
494 path: path.clone(),
495 })?
496 .clone();
497 Ok(CommitCiStatus {
498 sha: sha.map(str::to_string),
499 state: derive_state(&runs).to_string(),
500 total_count: data.get("total_count").and_then(Value::as_i64),
501 runs,
502 })
503 }
504 Err(error) if error.status == 404 => Ok(CommitCiStatus {
505 sha: sha.map(str::to_string),
506 state: "unknown".to_string(),
507 total_count: Some(0),
508 runs: Vec::new(),
509 }),
510 Err(error) => Err(error),
511 }
512 }
513
514 pub async fn list_workflow_runs(
521 &self,
522 branch: Option<&str>,
523 page: &str,
524 ) -> Result<Option<Value>, GithubApiError> {
525 let mut query = format!("per_page=30&page={page}");
528 if let Some(branch) = branch {
529 query.push_str(&format!("&branch={branch}"));
530 }
531 let data = self
532 .get(&self.repo_path(&format!("/actions/runs?{query}")))
533 .await?;
534 Ok(data.get("workflow_runs").cloned())
535 }
536
537 async fn get(&self, path: &str) -> Result<Value, GithubApiError> {
538 self.send("GET", path, None).await
539 }
540
541 async fn send(
542 &self,
543 method: &str,
544 path: &str,
545 body: Option<Value>,
546 ) -> Result<Value, GithubApiError> {
547 let response = self.dispatch(method, path, body, JSON_ACCEPT).await?;
548 let status = response.status();
549 let reason = status.canonical_reason().unwrap_or("").to_string();
550 let text = response
551 .text()
552 .await
553 .map_err(|error| GithubApiError::transport(error, path))?;
554 if !status.is_success() {
555 let message = serde_json::from_str::<Value>(&text)
558 .ok()
559 .and_then(|value| {
560 value
561 .get("message")
562 .and_then(Value::as_str)
563 .map(str::to_string)
564 })
565 .unwrap_or(reason);
566 return Err(GithubApiError {
567 message,
568 status: status.as_u16(),
569 path: path.to_string(),
570 });
571 }
572 serde_json::from_str(&text).map_err(|error| GithubApiError {
573 message: error.to_string(),
574 status: status.as_u16(),
575 path: path.to_string(),
576 })
577 }
578
579 async fn text(&self, path: &str) -> Result<String, GithubApiError> {
582 let response = self.dispatch("GET", path, None, DIFF_ACCEPT).await?;
583 let status = response.status();
584 let reason = status.canonical_reason().unwrap_or("").to_string();
585 let text = response.text().await;
586 if !status.is_success() {
587 return Err(GithubApiError {
588 message: text.unwrap_or(reason),
589 status: status.as_u16(),
590 path: path.to_string(),
591 });
592 }
593 text.map_err(|error| GithubApiError::transport(error, path))
594 }
595
596 async fn dispatch(
597 &self,
598 method: &str,
599 path: &str,
600 body: Option<Value>,
601 accept: &str,
602 ) -> Result<reqwest::Response, GithubApiError> {
603 let url = format!("{}{path}", self.base_url);
604 let client = reqwest::Client::new();
605 let mut request = client
606 .request(
607 reqwest::Method::from_bytes(method.as_bytes()).expect("static method"),
608 &url,
609 )
610 .header("Authorization", format!("Bearer {}", self.token))
611 .header("Accept", accept)
612 .header("User-Agent", USER_AGENT)
613 .header("X-GitHub-Api-Version", API_VERSION);
614 if let Some(body) = body {
615 request = request.json(&body);
616 }
617 request
618 .send()
619 .await
620 .map_err(|error| GithubApiError::transport(error, path))
621 }
622}
623
624fn first_line(message: &str) -> String {
627 message
628 .split(['\r', '\n'])
629 .next()
630 .unwrap_or_default()
631 .trim()
632 .to_string()
633}
634
635fn encode_uri_component(value: &str) -> String {
639 let mut out = String::with_capacity(value.len());
640 for byte in value.bytes() {
641 match byte {
642 b'A'..=b'Z'
643 | b'a'..=b'z'
644 | b'0'..=b'9'
645 | b'-'
646 | b'_'
647 | b'.'
648 | b'!'
649 | b'~'
650 | b'*'
651 | b'\''
652 | b'('
653 | b')' => out.push(byte as char),
654 other => out.push_str(&format!("%{other:02X}")),
655 }
656 }
657 out
658}
659
660fn array(value: &Value) -> &[Value] {
661 value.as_array().map(Vec::as_slice).unwrap_or_default()
662}
663
664fn copy(out: &mut Map<String, Value>, source: &Value, key: &str) {
669 if let Some(value) = source.get(key) {
670 out.insert(key.to_string(), value.clone());
671 }
672}
673
674fn has_content(value: Option<&Value>) -> bool {
677 !matches!(value, None | Some(Value::Null))
678}
679
680fn normalize_pr(pr: &Value) -> GithubPr {
681 let merged_at = pr.get("merged_at").cloned().unwrap_or(Value::Null);
682 let state = if has_content(Some(&merged_at)) {
685 "merged".to_string()
686 } else {
687 pr.get("state")
688 .and_then(Value::as_str)
689 .unwrap_or_default()
690 .to_string()
691 };
692 GithubPr {
693 number: pr.get("number").and_then(Value::as_i64).unwrap_or_default(),
694 title: string_at(pr, "title"),
695 state,
696 body: pr.get("body").cloned().unwrap_or(Value::Null),
697 html_url: string_at(pr, "html_url"),
698 head: pr.get("head").cloned().unwrap_or(Value::Null),
699 base: pr.get("base").cloned().unwrap_or(Value::Null),
700 user: pr.get("user").cloned().unwrap_or(Value::Null),
701 created_at: string_at(pr, "created_at"),
702 updated_at: string_at(pr, "updated_at"),
703 merged_at,
704 draft: pr.get("draft").and_then(Value::as_bool).unwrap_or(false),
706 mergeable: pr.get("mergeable").cloned().unwrap_or(Value::Null),
709 }
710}
711
712fn string_at(value: &Value, key: &str) -> String {
713 value
714 .get(key)
715 .and_then(Value::as_str)
716 .unwrap_or_default()
717 .to_string()
718}
719
720fn derive_state(runs: &[Value]) -> &'static str {
723 if runs.is_empty() {
724 return "unknown";
725 }
726 fn status(run: &Value) -> &str {
727 run.get("status").and_then(Value::as_str).unwrap_or("")
728 }
729 fn conclusion(run: &Value) -> &str {
730 run.get("conclusion").and_then(Value::as_str).unwrap_or("")
731 }
732 if runs
733 .iter()
734 .any(|run| matches!(status(run), "in_progress" | "queued"))
735 {
736 return "pending";
737 }
738 if runs
739 .iter()
740 .all(|run| matches!(conclusion(run), "success" | "skipped" | "neutral"))
741 {
742 return "success";
743 }
744 if runs
745 .iter()
746 .any(|run| matches!(conclusion(run), "failure" | "timed_out"))
747 {
748 return "failure";
749 }
750 "error"
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use std::io::{Read, Write};
757 use std::net::TcpListener;
758
759 #[test]
760 fn both_spellings_of_a_github_remote_name_the_same_repository() {
761 let expected = Some(("owner".to_string(), "repo".to_string()));
762 for remote in [
763 "https://github.com/owner/repo.git",
764 "https://github.com/owner/repo",
765 "https://github.com/owner/repo/",
766 "http://user@github.com/owner/repo.git",
767 "git@github.com:owner/repo.git",
768 "git@github.com:owner/repo",
769 ] {
770 assert_eq!(
771 GithubManager::parse_remote_url(remote),
772 expected,
773 "{remote}"
774 );
775 }
776 }
777
778 #[tokio::test]
779 async fn native_requests_identify_nomoreide_to_github() {
780 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
781 let address = listener.local_addr().unwrap();
782 let server = std::thread::spawn(move || {
783 let (mut stream, _) = listener.accept().unwrap();
784 let mut request = Vec::new();
785 let mut chunk = [0_u8; 1024];
786 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
787 let count = stream.read(&mut chunk).unwrap();
788 if count == 0 {
789 break;
790 }
791 request.extend_from_slice(&chunk[..count]);
792 }
793 let body = r#"{"login":"octocat"}"#;
794 write!(
795 stream,
796 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
797 body.len(),
798 body,
799 )
800 .unwrap();
801 String::from_utf8(request).unwrap()
802 });
803 let manager = GithubManager {
804 token: "valid-token".into(),
805 owner: String::new(),
806 repo: String::new(),
807 base_url: format!("http://{address}"),
808 };
809
810 assert_eq!(manager.viewer().await.unwrap()["login"], "octocat");
811 let request = server.join().unwrap().to_ascii_lowercase();
812 assert!(request.contains(&format!("user-agent: {}", USER_AGENT.to_ascii_lowercase())));
813 }
814
815 #[test]
818 fn a_remote_that_is_not_github_is_not_guessed_at() {
819 for remote in [
820 "https://gitlab.com/owner/repo.git",
821 "git@gitlab.com:owner/repo.git",
822 "https://github.com.evil.example/owner/repo.git",
823 "https://github.com/owner",
824 "",
825 ] {
826 assert_eq!(GithubManager::parse_remote_url(remote), None, "{remote}");
827 }
828 }
829
830 #[test]
831 fn a_merged_pull_request_is_named_merged_rather_than_closed() {
832 let merged =
833 normalize_pr(&json!({ "state": "closed", "merged_at": "2026-01-03T00:00:00Z" }));
834 assert_eq!(merged.state, "merged");
835 let closed = normalize_pr(&json!({ "state": "closed", "merged_at": null }));
836 assert_eq!(closed.state, "closed");
837 }
838
839 #[test]
842 fn the_two_fields_a_list_response_omits_get_different_defaults() {
843 let pr = normalize_pr(&json!({ "state": "open" }));
844 assert!(!pr.draft);
845 assert_eq!(pr.mergeable, Value::Null);
846 }
847
848 #[test]
849 fn a_check_suite_reports_the_state_that_is_not_final_first() {
850 let run =
851 |status: &str, conclusion: Value| json!({ "status": status, "conclusion": conclusion });
852 assert_eq!(derive_state(&[]), "unknown");
853 assert_eq!(
854 derive_state(&[
855 run("completed", json!("failure")),
856 run("queued", Value::Null)
857 ]),
858 "pending"
859 );
860 assert_eq!(
861 derive_state(&[
862 run("completed", json!("success")),
863 run("completed", json!("skipped"))
864 ]),
865 "success"
866 );
867 assert_eq!(
868 derive_state(&[
869 run("completed", json!("success")),
870 run("completed", json!("timed_out"))
871 ]),
872 "failure"
873 );
874 assert_eq!(
875 derive_state(&[run("completed", json!("cancelled"))]),
876 "error"
877 );
878 }
879
880 #[test]
884 fn only_a_loopback_api_base_is_honoured() {
885 let cases = [
886 ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),
887 ("http://localhost:1/", "http://localhost:1"),
888 ("http://attacker.example", GITHUB_API),
889 ("http://127.0.0.1.evil.example", GITHUB_API),
890 ("ftp://127.0.0.1", GITHUB_API),
891 ("not a url", GITHUB_API),
892 ("", GITHUB_API),
893 ];
894 for (value, expected) in cases {
895 std::env::set_var("NOMOREIDE_GITHUB_API_BASE", value);
896 assert_eq!(api_base(), expected, "{value}");
897 }
898 std::env::remove_var("NOMOREIDE_GITHUB_API_BASE");
899 assert_eq!(api_base(), GITHUB_API);
900 }
901}