Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
894 linesCodeBlameRaw
1//! The g1t binary against a mock API: what it asks for, what it prints, and
2//! the exit codes it gives.
3
4use serde_json::{Value, json};
5use std::io::{BufRead, BufReader, Read, Write};
6use std::net::TcpListener;
7use std::path::PathBuf;
8use std::process::{Command, Output, Stdio};
9use std::sync::{Arc, Mutex};
10
11#[derive(Clone, Debug)]
12struct Request {
13 method: String,
14 /// The path and query, as sent.
15 target: String,
16 headers: Vec<(String, String)>,
17 body: String,
18}
19
20impl Request {
21 fn path(&self) -> &str {
22 self.target.split('?').next().unwrap_or("")
23 }
24 fn json(&self) -> Value {
25 serde_json::from_str(&self.body).unwrap_or(Value::Null)
26 }
27 fn header(&self, name: &str) -> Option<&str> {
28 self.headers
29 .iter()
30 .find(|(k, _)| k.eq_ignore_ascii_case(name))
31 .map(|(_, v)| v.as_str())
32 }
33}
34
35type Handler = dyn Fn(&Request) -> (u16, Value) + Send + Sync;
36
37struct Mock {
38 url: String,
39 seen: Arc<Mutex<Vec<Request>>>,
40}
41
42impl Mock {
43 fn start(handler: impl Fn(&Request) -> (u16, Value) + Send + Sync + 'static) -> Mock {
44 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
45 let url = format!("http://{}", listener.local_addr().unwrap());
46 let seen = Arc::new(Mutex::new(Vec::new()));
47 let handler: Arc<Handler> = Arc::new(handler);
48 let log = seen.clone();
49 std::thread::spawn(move || {
50 for stream in listener.incoming() {
51 let Ok(mut stream) = stream else { continue };
52 let mut reader = BufReader::new(stream.try_clone().unwrap());
53 let mut line = String::new();
54 if reader.read_line(&mut line).unwrap_or(0) == 0 {
55 continue;
56 }
57 let mut parts = line.split_whitespace();
58 let method = parts.next().unwrap_or("").to_owned();
59 let target = parts.next().unwrap_or("").to_owned();
60 let mut headers = Vec::new();
61 loop {
62 let mut h = String::new();
63 reader.read_line(&mut h).unwrap();
64 let h = h.trim_end().to_owned();
65 if h.is_empty() {
66 break;
67 }
68 if let Some((k, v)) = h.split_once(':') {
69 headers.push((k.trim().to_owned(), v.trim().to_owned()));
70 }
71 }
72 let length: usize = headers
73 .iter()
74 .find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
75 .and_then(|(_, v)| v.parse().ok())
76 .unwrap_or(0);
77 let mut body = vec![0; length];
78 reader.read_exact(&mut body).unwrap();
79 let request = Request {
80 method,
81 target,
82 headers,
83 body: String::from_utf8_lossy(&body).into_owned(),
84 };
85 let (status, reply) = handler(&request);
86 log.lock().unwrap().push(request);
87 let text = serde_json::to_string(&reply).unwrap();
88 let _ = write!(
89 stream,
90 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}",
91 text.len()
92 );
93 }
94 });
95 Mock { url, seen }
96 }
97
98 fn requests(&self) -> Vec<Request> {
99 self.seen.lock().unwrap().clone()
100 }
101}
102
103fn temp_dir(name: &str) -> PathBuf {
104 let nanos = std::time::SystemTime::now()
105 .duration_since(std::time::UNIX_EPOCH)
106 .unwrap()
107 .as_nanos();
108 let dir = std::env::temp_dir().join(format!("g1t-test-{name}-{}-{nanos}", std::process::id()));
109 std::fs::create_dir_all(&dir).unwrap();
110 dir
111}
112
113struct Run {
114 code: i32,
115 stdout: String,
116 stderr: String,
117}
118
119fn g1t(
120 mock: &Mock,
121 config: &PathBuf,
122 args: &[&str],
123 stdin: Option<&str>,
124 token: Option<&str>,
125) -> Run {
126 let mut command = Command::new(env!("CARGO_BIN_EXE_g1t"));
127 command
128 .args(args)
129 .current_dir(config)
130 .env("G1T_API_URL", &mock.url)
131 .env("G1T_CONFIG_DIR", config)
132 .env("G1T_KEYRING", "off")
133 .env("G1T_PROMPT_DISABLED", "1")
134 .env("NO_COLOR", "1")
135 .env("GIT_CEILING_DIRECTORIES", std::env::temp_dir())
136 .env_remove("G1T_REPO")
137 .env_remove("G1T_HOST")
138 .env_remove("G1T_TOKEN")
139 .stdin(Stdio::piped())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::piped());
142 if let Some(t) = token {
143 command.env("G1T_TOKEN", t);
144 }
145 let mut child = command.spawn().unwrap();
146 {
147 let mut input = child.stdin.take().unwrap();
148 if let Some(text) = stdin {
149 input.write_all(text.as_bytes()).unwrap();
150 }
151 }
152 let Output {
153 status,
154 stdout,
155 stderr,
156 } = child.wait_with_output().unwrap();
157 Run {
158 code: status.code().unwrap_or(-1),
159 stdout: String::from_utf8_lossy(&stdout).into_owned(),
160 stderr: String::from_utf8_lossy(&stderr).into_owned(),
161 }
162}
163
164fn run(mock: &Mock, args: &[&str]) -> Run {
165 let dir = temp_dir("run");
166 g1t(mock, &dir, args, None, Some("g1t_test"))
167}
168
169fn issue(number: u64, title: &str, labels: &[&str]) -> Value {
170 json!({
171 "number": number, "title": title, "state": "open", "labels": labels, "body": "b",
172 "author": { "username": "ana" }, "assignees": [], "updated_at": "2026-10-01T18:04:11.482Z",
173 })
174}
175
176#[test]
177fn issue_list_asks_for_open_issues_and_prints_json_fields() {
178 let mock = Mock::start(|_| {
179 (
180 200,
181 json!([issue(12, "Greeting", &["feature"]), issue(11, "Docs", &[])]),
182 )
183 });
184 let out = run(
185 &mock,
186 &["issue", "list", "-R", "acme/web", "--json", "number,title"],
187 );
188 assert_eq!(out.code, 0, "{}", out.stderr);
189 assert_eq!(
190 out.stdout,
191 "[{\"number\":12,\"title\":\"Greeting\"},{\"number\":11,\"title\":\"Docs\"}]\n"
192 );
193 let seen = mock.requests();
194 assert_eq!(seen[0].method, "GET");
195 assert_eq!(seen[0].target, "/repos/acme/web/issues?state=open");
196 assert_eq!(seen[0].header("authorization"), Some("Bearer g1t_test"));
197 assert!(
198 seen[0]
199 .header("user-agent")
200 .unwrap()
201 .starts_with("g1t-cli/")
202 );
203}
204
205#[test]
206fn piped_lists_are_tab_separated_without_a_header() {
207 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &["feature", "p1"])])));
208 let out = run(&mock, &["issue", "list", "-R", "acme/web", "--limit", "5"]);
209 assert_eq!(out.code, 0, "{}", out.stderr);
210 let line = out.stdout.lines().next().unwrap();
211 let cells: Vec<&str> = line.split('\t').collect();
212 assert_eq!(&cells[..3], &["#12", "Greeting", "feature, p1"]);
213 assert_eq!(out.stdout.lines().count(), 1);
214}
215
216#[test]
217fn limits_keep_lists_short() {
218 let many: Vec<Value> = (1..=50).map(|n| issue(n, "x", &[])).collect();
219 let mock = Mock::start(move |_| (200, Value::Array(many.clone())));
220 let out = run(
221 &mock,
222 &["issue", "list", "-R", "acme/web", "--json", "number"],
223 );
224 let list: Vec<Value> = serde_json::from_str(&out.stdout).unwrap();
225 assert_eq!(list.len(), 20, "the default page is small");
226}
227
228#[test]
229fn jq_selects_from_the_json() {
230 let mock = Mock::start(|_| {
231 (
232 200,
233 json!([issue(12, "Greeting", &[]), issue(11, "Docs", &[])]),
234 )
235 });
236 let out = run(
237 &mock,
238 &["issue", "list", "-R", "acme/web", "--jq", ".[].title"],
239 );
240 assert_eq!(out.stdout, "Greeting\nDocs\n");
241}
242
243#[test]
244fn an_unknown_json_field_is_a_usage_error() {
245 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &[])])));
246 let out = run(
247 &mock,
248 &["issue", "list", "-R", "acme/web", "--json", "nope"],
249 );
250 assert_eq!(out.code, 2);
251 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
252 assert!(out.stderr.contains("Available:"), "{}", out.stderr);
253}
254
255#[test]
256fn issue_create_sends_the_title_and_body() {
257 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
258 ("POST", "/repos/acme/web/issues") => (200, issue(13, "Broken link", &["docs"])),
259 _ => (
260 404,
261 json!({ "error": { "code": "not_found", "message": "No." } }),
262 ),
263 });
264 let out = run(
265 &mock,
266 &[
267 "issue",
268 "create",
269 "-R",
270 "acme/web",
271 "-t",
272 "Broken link",
273 "-b",
274 "On the home page.",
275 "-l",
276 "docs",
277 ],
278 );
279 assert_eq!(out.code, 0, "{}", out.stderr);
280 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/web/issues/13");
281 let sent = mock.requests()[0].json();
282 assert_eq!(
283 sent,
284 json!({ "title": "Broken link", "body": "On the home page.", "labels": ["docs"] })
285 );
286}
287
288#[test]
289fn a_missing_title_without_a_terminal_names_the_flag() {
290 let mock = Mock::start(|_| (200, json!({})));
291 let out = run(&mock, &["issue", "create", "-R", "acme/web"]);
292 assert_eq!(out.code, 2);
293 assert!(out.stderr.contains("--title"), "{}", out.stderr);
294 assert!(mock.requests().is_empty());
295}
296
297#[test]
298fn a_refused_token_exits_4_with_one_line() {
299 let mock = Mock::start(|_| {
300 (
301 401,
302 json!({ "error": { "code": "unauthenticated", "message": "The token is not valid." } }),
303 )
304 });
305 let out = run(&mock, &["issue", "view", "12", "-R", "acme/web"]);
306 assert_eq!(out.code, 4);
307 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
308 assert!(
309 out.stderr.starts_with("g1t: The token is not valid."),
310 "{}",
311 out.stderr
312 );
313}
314
315#[test]
316fn a_missing_scope_is_named() {
317 let mock = Mock::start(|_| {
318 (
319 403,
320 json!({ "error": { "code": "forbidden", "message": "Not allowed.", "needed_scope": "issues:write" } }),
321 )
322 });
323 let out = run(&mock, &["issue", "close", "12", "-R", "acme/web"]);
324 assert_eq!(out.code, 1);
325 assert!(out.stderr.contains("`issues:write`"), "{}", out.stderr);
326}
327
328#[test]
329fn usage_mistakes_exit_2_on_one_line() {
330 let mock = Mock::start(|_| (200, json!({})));
331 let out = run(&mock, &["issue", "list", "--state", "sideways"]);
332 assert_eq!(out.code, 2);
333 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
334 let out = run(&mock, &["frobnicate"]);
335 assert_eq!(out.code, 2);
336}
337
338fn pull(check_status: &str) -> Value {
339 json!({ "pull": { "number": 14, "title": "Greet", "status": "open", "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", "check_status": check_status, "branch": "greet", "base": "main" }, "comments": [] })
340}
341
342fn checks_mock(runs: Value) -> Mock {
343 Mock::start(move |r| match r.path() {
344 "/repos/acme/web/pulls/14" => (200, pull("pending")),
345 p if p.ends_with("/check-runs") => (200, runs.clone()),
346 p if p.ends_with("/status") => (200, json!({ "state": "success", "statuses": [] })),
347 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
348 })
349}
350
351#[test]
352fn pr_checks_exit_codes_say_how_they_went() {
353 let passing = json!({ "check_runs": [{ "name": "test", "status": "completed", "conclusion": "success" }] });
354 let failing = json!({ "check_runs": [{ "name": "test", "status": "completed", "conclusion": "failure" }] });
355 let pending =
356 json!({ "check_runs": [{ "name": "test", "status": "in_progress", "conclusion": null }] });
357 assert_eq!(
358 run(
359 &checks_mock(passing),
360 &["pr", "checks", "14", "-R", "acme/web"]
361 )
362 .code,
363 0
364 );
365 assert_eq!(
366 run(
367 &checks_mock(failing),
368 &["pr", "checks", "14", "-R", "acme/web"]
369 )
370 .code,
371 1
372 );
373 let mock = checks_mock(pending);
374 let out = run(
375 &mock,
376 &[
377 "pr",
378 "checks",
379 "14",
380 "-R",
381 "acme/web",
382 "--json",
383 "name,bucket",
384 ],
385 );
386 assert_eq!(out.code, 8);
387 assert_eq!(out.stdout, "[{\"bucket\":\"pending\",\"name\":\"test\"}]\n");
388 let refs: Vec<String> = mock.requests().iter().map(|r| r.target.clone()).collect();
389 assert!(
390 refs.iter().any(|t| t.starts_with(
391 "/repos/acme/web/commits/9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13/check-runs"
392 )),
393 "{refs:?}"
394 );
395}
396
397#[test]
398fn pr_checks_watch_fail_fast_stops_at_the_first_failure() {
399 let mixed = json!({ "check_runs": [
400 { "name": "lint", "status": "completed", "conclusion": "failure", "html_url": "https://g1t.sh/x" },
401 { "name": "test", "status": "in_progress", "conclusion": null },
402 ]});
403 let mock = checks_mock(mixed);
404 let started = std::time::Instant::now();
405 let out = run(
406 &mock,
407 &[
408 "pr",
409 "checks",
410 "14",
411 "-R",
412 "acme/web",
413 "--watch",
414 "--fail-fast",
415 "--interval",
416 "1",
417 ],
418 );
419 assert_eq!(out.code, 1, "{}", out.stderr);
420 assert!(started.elapsed() < std::time::Duration::from_secs(10));
421 assert!(out.stdout.contains("lint\tfailure"), "{}", out.stdout);
422}
423
424#[test]
425fn run_logs_failed_prints_only_the_failing_steps_tail() {
426 let mock = Mock::start(|r| match r.path() {
427 "/repos/acme/web/actions/runs/run_1" => (
428 200,
429 json!({ "run": { "id": "run_1", "name": "CI", "number": 3, "status": "completed", "conclusion": "failure" }, "jobs": [
430 { "id": "job_1", "name": "test", "status": "completed", "conclusion": "failure", "started_at": "2026-10-01T18:00:00Z",
431 "steps": [
432 { "number": 1, "name": "checkout", "status": "completed", "conclusion": "success" },
433 { "number": 2, "name": "cargo test", "status": "completed", "conclusion": "failure" }
434 ], "annotations": [] },
435 { "id": "job_2", "name": "lint", "status": "completed", "conclusion": "success", "started_at": "2026-10-01T18:00:00Z", "steps": [] }
436 ]}),
437 ),
438 "/repos/acme/web/actions/jobs/job_1/logs" => (
439 200,
440 json!({ "chunks": [
441 { "seq": 1, "step": 1, "text": "cloned\n" },
442 { "seq": 2, "step": 2, "text": "running 3 tests\nline a\nline b\ntest greet ... FAILED\n" }
443 ], "done": true }),
444 ),
445 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
446 });
447 let out = run(
448 &mock,
449 &[
450 "run", "logs", "run_1", "--failed", "-n", "2", "-R", "acme/web",
451 ],
452 );
453 assert_eq!(out.code, 0, "{}", out.stderr);
454 assert!(out.stdout.contains("test › 2 cargo test"), "{}", out.stdout);
455 assert!(
456 out.stdout.contains("line b\ntest greet ... FAILED"),
457 "{}",
458 out.stdout
459 );
460 assert!(!out.stdout.contains("cloned"), "{}", out.stdout);
461 assert!(!out.stdout.contains("line a"), "{}", out.stdout);
462 // The passing job's log is never fetched.
463 assert!(mock.requests().iter().all(|r| !r.path().contains("job_2")));
464}
465
466#[test]
467fn api_calls_operations_by_name() {
468 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &[])])));
469 let out = run(
470 &mock,
471 &[
472 "api",
473 "list_issues",
474 "-f",
475 "state=closed",
476 "-R",
477 "acme/web",
478 "--jq",
479 ".[0].title",
480 ],
481 );
482 assert_eq!(out.code, 0, "{}", out.stderr);
483 assert_eq!(out.stdout, "Greeting\n");
484 assert_eq!(
485 mock.requests()[0].target,
486 "/repos/acme/web/issues?state=closed"
487 );
488}
489
490#[test]
491fn api_posts_typed_fields() {
492 let mock = Mock::start(|_| (200, json!({ "number": 13 })));
493 let out = run(
494 &mock,
495 &[
496 "api",
497 "create_issue",
498 "-R",
499 "acme/web",
500 "-f",
501 "title=Hi",
502 "-f",
503 "labels[]=docs",
504 "-F",
505 "milestone=3",
506 ],
507 );
508 assert_eq!(out.code, 0, "{}", out.stderr);
509 let r = &mock.requests()[0];
510 assert_eq!(
511 (r.method.as_str(), r.path()),
512 ("POST", "/repos/acme/web/issues")
513 );
514 assert_eq!(
515 r.json(),
516 json!({ "title": "Hi", "labels": ["docs"], "milestone": 3 })
517 );
518}
519
520#[test]
521fn api_paginates_by_cursor_and_slurps() {
522 let mock = Mock::start(|r| {
523 if r.target.contains("cursor=c2") {
524 (200, json!({ "items": [{ "id": 3 }], "next": null }))
525 } else {
526 (
527 200,
528 json!({ "items": [{ "id": 1 }, { "id": 2 }], "next": "c2" }),
529 )
530 }
531 });
532 let out = run(
533 &mock,
534 &[
535 "api",
536 "notifications",
537 "--paginate",
538 "--slurp",
539 "--jq",
540 "map(.id)",
541 ],
542 );
543 assert_eq!(out.code, 0, "{}", out.stderr);
544 assert_eq!(out.stdout.trim(), "[1,2,3]");
545 assert_eq!(mock.requests().len(), 2);
546}
547
548#[test]
549fn api_errors_print_the_body_and_exit_1() {
550 let mock = Mock::start(|_| {
551 (
552 404,
553 json!({ "error": { "code": "not_found", "message": "No such endpoint." } }),
554 )
555 });
556 let out = run(&mock, &["api", "/nowhere"]);
557 assert_eq!(out.code, 1);
558 assert!(out.stdout.contains("not_found"));
559 assert_eq!(out.stderr.trim(), "g1t: No such endpoint.");
560}
561
562#[test]
563fn signing_in_with_a_token_keeps_it_for_status_token_and_git() {
564 let mock = Mock::start(|r| match r.header("authorization") {
565 Some("Bearer g1t_good") => (
566 200,
567 json!({ "username": "ana", "token": { "scopes": ["repo:read"] } }),
568 ),
569 _ => (
570 401,
571 json!({ "error": { "message": "The token is not valid." } }),
572 ),
573 });
574 let dir = temp_dir("auth");
575 let out = g1t(
576 &mock,
577 &dir,
578 &["auth", "login", "--with-token"],
579 Some("g1t_good\n"),
580 None,
581 );
582 assert_eq!(out.code, 0, "{}", out.stderr);
583 assert!(
584 out.stderr.contains("Signed in to g1t.sh as ana"),
585 "{}",
586 out.stderr
587 );
588 let config: Value =
589 serde_json::from_str(&std::fs::read_to_string(dir.join("config.json")).unwrap()).unwrap();
590 assert_eq!(config["hosts"]["g1t.sh"]["user"], "ana");
591
592 let out = g1t(&mock, &dir, &["auth", "token"], None, None);
593 assert_eq!(out.stdout, "g1t_good\n");
594
595 let out = g1t(
596 &mock,
597 &dir,
598 &["auth", "status", "--json", "host,user,valid,active"],
599 None,
600 None,
601 );
602 assert_eq!(out.code, 0, "{}", out.stderr);
603 assert_eq!(
604 out.stdout.trim(),
605 r#"[{"active":true,"host":"g1t.sh","user":"ana","valid":true}]"#
606 );
607
608 let out = g1t(
609 &mock,
610 &dir,
611 &["auth", "git-credential", "get"],
612 Some("protocol=https\nhost=g1t.sh\n\n"),
613 None,
614 );
615 assert_eq!(
616 out.stdout,
617 "protocol=https\nhost=g1t.sh\nusername=ana\npassword=g1t_good\n"
618 );
619 let out = g1t(
620 &mock,
621 &dir,
622 &["auth", "git-credential", "get"],
623 Some("protocol=https\nhost=example.com\n\n"),
624 None,
625 );
626 assert_eq!(out.stdout, "");
627
628 let out = g1t(&mock, &dir, &["auth", "logout"], None, None);
629 assert_eq!(out.code, 0, "{}", out.stderr);
630 let out = g1t(&mock, &dir, &["auth", "status"], None, None);
631 assert_eq!(out.code, 4);
632}
633
634#[test]
635fn a_bad_token_is_not_kept() {
636 let mock = Mock::start(|_| {
637 (
638 401,
639 json!({ "error": { "message": "The token is not valid." } }),
640 )
641 });
642 let dir = temp_dir("bad");
643 let out = g1t(
644 &mock,
645 &dir,
646 &["auth", "login", "--with-token"],
647 Some("nope"),
648 None,
649 );
650 assert_eq!(out.code, 4);
651 assert!(!dir.join("config.json").exists());
652}
653
654#[test]
655fn secrets_are_read_from_stdin() {
656 let mock = Mock::start(|_| (200, json!({ "name": "NPM_TOKEN", "kind": "secret" })));
657 let dir = temp_dir("secret");
658 let out = g1t(
659 &mock,
660 &dir,
661 &[
662 "secret",
663 "set",
664 "npm_token",
665 "-R",
666 "acme/web",
667 "--env",
668 "production",
669 ],
670 Some("s3cret\n"),
671 Some("g1t_test"),
672 );
673 assert_eq!(out.code, 0, "{}", out.stderr);
674 let r = &mock.requests()[0];
675 assert_eq!(
676 (r.method.as_str(), r.path()),
677 ("PUT", "/repos/acme/web/actions/secrets/NPM_TOKEN")
678 );
679 assert_eq!(
680 r.json(),
681 json!({ "value": "s3cret", "environments": ["production"] })
682 );
683 assert!(!out.stdout.contains("s3cret") && !out.stderr.contains("s3cret"));
684}
685
686#[test]
687fn aliases_expand_before_the_command_runs() {
688 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &["bug"])])));
689 let dir = temp_dir("alias");
690 let out = g1t(
691 &mock,
692 &dir,
693 &["alias", "set", "bugs", "issue list --label bug"],
694 None,
695 None,
696 );
697 assert_eq!(out.code, 0, "{}", out.stderr);
698 let out = g1t(
699 &mock,
700 &dir,
701 &["bugs", "-R", "acme/web", "--json", "number"],
702 None,
703 Some("g1t_test"),
704 );
705 assert_eq!(out.code, 0, "{}", out.stderr);
706 assert_eq!(out.stdout.trim(), r#"[{"number":12}]"#);
707 assert_eq!(
708 mock.requests()[0].target,
709 "/repos/acme/web/issues?state=open&label=bug"
710 );
711 let out = g1t(
712 &mock,
713 &dir,
714 &["alias", "set", "pr", "issue list"],
715 None,
716 None,
717 );
718 assert_eq!(out.code, 2, "a command's name cannot be an alias");
719}
720
721#[test]
722fn pr_merge_and_review_send_what_was_asked() {
723 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
724 ("POST", "/repos/acme/web/pulls/14/merge") => {
725 (200, json!({ "number": 14, "status": "merged" }))
726 }
727 ("POST", "/repos/acme/web/pulls/14/reviews") => {
728 (200, json!({ "id": "cmt_1", "verdict": "approve" }))
729 }
730 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
731 });
732 let out = run(
733 &mock,
734 &[
735 "pr",
736 "review",
737 "14",
738 "--approve",
739 "-b",
740 "Looks right.",
741 "-R",
742 "acme/web",
743 ],
744 );
745 assert_eq!(out.code, 0, "{}", out.stderr);
746 let out = run(
747 &mock,
748 &["pr", "merge", "14", "--keep-issue-open", "-R", "acme/web"],
749 );
750 assert_eq!(out.code, 0, "{}", out.stderr);
751 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/web/pull/14");
752 let seen = mock.requests();
753 assert_eq!(
754 seen[0].json(),
755 json!({ "verdict": "approve", "body": "Looks right." })
756 );
757 assert_eq!(seen[1].json(), json!({ "keep_issue_open": true }));
758}
759
760#[test]
761fn status_gathers_what_needs_you() {
762 let mock = Mock::start(|r| match r.path() {
763 "/user" => (200, json!({ "username": "ana" })),
764 "/search" => (
765 200,
766 json!({ "hits": [{ "repo": "acme/web", "number": 14, "title": "Greet" }] }),
767 ),
768 "/repos/acme/web/pulls/14" => (200, pull("failed")),
769 "/notifications" if r.target.contains("review_requested") => (
770 200,
771 json!({ "items": [{ "repo": "acme/api", "number": 3, "title": "ada asked you to review acme/api#3" }] }),
772 ),
773 "/notifications" => (200, json!({ "items": [] })),
774 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
775 });
776 let out = run(
777 &mock,
778 &["status", "--json", "pulls,review_requests,failing_checks"],
779 );
780 assert_eq!(out.code, 0, "{}", out.stderr);
781 let v: Value = serde_json::from_str(&out.stdout).unwrap();
782 assert_eq!(v["pulls"][0]["number"], 14);
783 assert_eq!(v["review_requests"][0]["number"], 3);
784 assert_eq!(v["failing_checks"][0]["check_status"], "failed");
785 let search = mock
786 .requests()
787 .into_iter()
788 .find(|r| r.path() == "/search")
789 .unwrap();
790 assert!(search.target.contains("author%3Aana"), "{}", search.target);
791}
792
793#[test]
794fn help_and_version_succeed() {
795 let mock = Mock::start(|_| (200, json!({})));
796 let out = run(&mock, &["--version"]);
797 assert_eq!(out.code, 0);
798 assert!(out.stdout.starts_with("g1t "));
799 let out = run(&mock, &["pr", "--help"]);
800 assert_eq!(out.code, 0);
801 assert!(out.stdout.contains("checks"));
802 let out = run(&mock, &["completion", "bash"]);
803 assert_eq!(out.code, 0);
804 assert!(out.stdout.contains("_g1t"));
805}
806
807#[test]
808fn login_approves_a_code_in_the_browser() {
809 let polls = Arc::new(Mutex::new(0));
810 let counter = polls.clone();
811 let mock = Mock::start(move |r| match r.path() {
812 "/device/code" => (
813 200,
814 json!({ "device_code": "dev_1", "user_code": "WDJB-MJHT", "verification_uri": "https://g1t.sh/device",
815 "verification_uri_complete": "https://g1t.sh/device?code=WDJB-MJHT", "expires_in": 60, "interval": 1 }),
816 ),
817 "/device/token" => {
818 let mut n = counter.lock().unwrap();
819 *n += 1;
820 if *n < 2 {
821 (200, json!({ "status": "pending" }))
822 } else {
823 (
824 200,
825 json!({ "status": "approved", "token": "g1t_device", "username": "ana" }),
826 )
827 }
828 }
829 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
830 });
831 let dir = temp_dir("device");
832 let out = g1t(&mock, &dir, &["auth", "login", "--no-browser"], None, None);
833 assert_eq!(out.code, 0, "{}", out.stderr);
834 assert!(out.stderr.contains("WDJB-MJHT"), "{}", out.stderr);
835 assert!(
836 out.stderr.contains("https://g1t.sh/device?code=WDJB-MJHT"),
837 "{}",
838 out.stderr
839 );
840 let seen = mock.requests();
841 assert!(
842 seen[0].json()["client_name"]
843 .as_str()
844 .unwrap()
845 .starts_with("g1t CLI on ")
846 );
847 assert!(
848 seen.iter()
849 .filter(|r| r.path() == "/device/token")
850 .all(|r| r.json() == json!({ "device_code": "dev_1" }))
851 );
852 let out = g1t(&mock, &dir, &["auth", "token"], None, None);
853 assert_eq!(out.stdout, "g1t_device\n");
854}
855
856#[test]
857fn the_repository_comes_from_the_git_remote() {
858 let mock = Mock::start(|_| (200, json!([])));
859 let dir = temp_dir("remote");
860 let git = |args: &[&str]| {
861 let ok = Command::new("git")
862 .args(args)
863 .current_dir(&dir)
864 .output()
865 .unwrap()
866 .status
867 .success();
868 assert!(ok, "git {args:?}");
869 };
870 git(&["init", "--quiet"]);
871 git(&["remote", "add", "mine", "https://g1t.sh/ana/web.git"]);
872 git(&["remote", "add", "origin", "git@g1t.sh:acme/web.git"]);
873 git(&[
874 "remote",
875 "add",
876 "elsewhere",
877 "https://example.com/acme/other.git",
878 ]);
879 let out = g1t(
880 &mock,
881 &dir,
882 &["issue", "list", "--json"],
883 None,
884 Some("g1t_test"),
885 );
886 assert_eq!(out.code, 0, "{}", out.stderr);
887 assert_eq!(out.stdout, "[]\n");
888 assert_eq!(mock.requests()[0].path(), "/repos/acme/web/issues");
889 // Outside any repository, and without --repo, it says what to do.
890 let bare = temp_dir("bare");
891 let out = g1t(&mock, &bare, &["issue", "list"], None, Some("g1t_test"));
892 assert_eq!(out.code, 2);
893 assert!(out.stderr.contains("--repo"), "{}", out.stderr);
894}