1use std::cell::OnceCell;
2use std::str::FromStr;
3
4use clap::CommandFactory;
5use clap_complete::{generate, Shell};
6
7use crate::api::client::Client;
8use crate::cli::{Cli, Command};
9use crate::config::Config;
10use crate::error::{GiteeError, Result};
11use crate::models::UserBasic;
12use crate::out::Output;
13use crate::repo::Repo;
14
15pub mod api;
16pub mod alias;
17pub mod extension;
18pub mod auth;
19pub mod browse;
20pub mod collaborator;
21pub mod config_cmd;
22pub mod gist;
23pub mod interactive;
24pub mod issue;
25pub mod label;
26pub mod org;
27pub mod pr;
28pub mod milestone;
29pub mod release;
30pub mod search;
31pub mod ssh_key;
32pub mod status;
33pub mod repo;
34pub mod webhook;
35
36pub struct Ctx {
37 pub client: Client,
38 pub out: Output,
39 pub host: String,
40 pub preview: bool,
43 repo_arg: Option<String>,
44 remote_arg: Option<String>,
45 repo: OnceCell<Repo>,
46 me: OnceCell<UserBasic>,
47}
48
49pub fn preview_line(action: &str, details: &[(&str, &str)]) -> String {
52 let mut s = format!("would {action}");
53 if !details.is_empty() {
54 s.push_str(": ");
55 let parts: Vec<String> = details.iter().map(|(k, v)| format!("{k}={v}")).collect();
56 s.push_str(&parts.join(", "));
57 }
58 s
59}
60
61impl Ctx {
62 pub fn repo(&self) -> Result<&Repo> {
63 if let Some(r) = self.repo.get() {
64 return Ok(r);
65 }
66 let r = Repo::resolve(self.repo_arg.as_deref(), self.remote_arg.as_deref())?;
67 let _ = self.repo.set(r);
68 Ok(self.repo.get().expect("repo just initialized"))
69 }
70
71 pub fn repo_arg(&self) -> Option<&str> {
72 self.repo_arg.as_deref()
73 }
74
75 pub fn me(&self) -> Result<&UserBasic> {
77 if let Some(u) = self.me.get() {
78 return Ok(u);
79 }
80 let u = self.client.users().me()?;
81 let _ = self.me.set(u);
82 Ok(self.me.get().expect("user just initialized"))
83 }
84}
85
86pub fn run(cli: Cli) -> Result<()> {
87 match &cli.cmd {
88 Command::Auth(c) => auth::execute(c.clone(), &cli.host),
89 Command::Config(c) => {
90 let ctx = build_inner(&cli, false)?;
91 config_cmd::execute(&ctx, c.clone())
92 }
93 Command::Alias(c) => {
94 let ctx = build_inner(&cli, false)?;
95 alias::execute(&ctx, c.clone())
96 }
97 Command::Browse => {
98 let ctx = build_inner(&cli, false)?;
99 browse::execute(&ctx)
100 }
101 Command::Api(a) => {
102 let client = core(&cli)?;
103 api::execute(&client, a.clone())
104 }
105 Command::Gist(c) => {
106 let ctx = build(&cli)?;
107 gist::execute(&ctx, c.clone())
108 }
109 Command::Pr(c) => {
110 let require_auth = !matches!(c, crate::cli::PrCmd::View { web: true, .. });
111 let ctx = build_inner(&cli, require_auth)?;
112 pr::execute(&ctx, c.clone())
113 }
114 Command::Issue(c) => {
115 let require_auth = !matches!(c, crate::cli::IssueCmd::View { web: true, .. });
116 let ctx = build_inner(&cli, require_auth)?;
117 issue::execute(&ctx, c.clone())
118 }
119 Command::Search(c) => {
120 let ctx = build(&cli)?;
121 search::execute(&ctx, c.clone())
122 }
123 Command::Status { limit } => {
124 let ctx = build(&cli)?;
125 status::execute(&ctx, limit.clone())
126 }
127 Command::Release(c) => {
128 let require_auth = !matches!(c, crate::cli::ReleaseCmd::View { web: true, .. });
129 let ctx = build_inner(&cli, require_auth)?;
130 release::execute(&ctx, c.clone())
131 }
132 Command::Label(c) => {
133 let ctx = build(&cli)?;
134 label::execute(&ctx, c.clone())
135 }
136 Command::Repo(c) => {
137 let require_auth = !matches!(c, crate::cli::RepoCmd::View { web: true, .. });
138 let ctx = build_inner(&cli, require_auth)?;
139 repo::execute(&ctx, c.clone())
140 }
141 Command::Milestone(c) => {
142 let ctx = build(&cli)?;
143 milestone::execute(&ctx, c.clone())
144 }
145 Command::Org(c) => {
146 let ctx = build(&cli)?;
147 org::execute(&ctx, c.clone())
148 }
149 Command::SshKey(c) => {
150 let ctx = build(&cli)?;
151 ssh_key::execute(&ctx, c.clone())
152 }
153 Command::Collaborator(c) => {
154 let ctx = build(&cli)?;
155 collaborator::execute(&ctx, c.clone())
156 }
157 Command::Webhook(c) => {
158 let ctx = build(&cli)?;
159 webhook::execute(&ctx, c.clone())
160 }
161 Command::Extension(c) => {
162 let ctx = build_inner(&cli, false)?;
163 extension::execute(&ctx, c.clone())
164 }
165 Command::External(args) => {
166 let Some(name) = args.first().and_then(|s| s.to_str()) else {
167 return Err(crate::error::GiteeError::Usage(
168 "extension command name required".into(),
169 ));
170 };
171 crate::extension::exec(name, &args[1..], &cli.host)
172 }
173 Command::Completions { shell } => completions(shell.clone()),
174 }
175}
176
177fn core(cli: &Cli) -> Result<Client> {
179 core_inner(cli, true)
180}
181
182fn core_inner(cli: &Cli, require_auth: bool) -> Result<Client> {
183 let token = match Config::token(&cli.host) {
184 Ok(t) => t,
185 Err(GiteeError::NotLoggedIn) if !require_auth => String::new(),
186 Err(e) => return Err(e),
187 };
188 let mut client = Client::for_host(&cli.host, token);
189 client.set_debug(cli.debug);
190 Ok(client)
191}
192
193pub fn confirm(action: &str, yes: bool) -> Result<()> {
197 use std::io::IsTerminal;
198 if yes {
199 return Ok(());
200 }
201 if !std::io::stdin().is_terminal() {
202 return Err(GiteeError::Usage(format!(
203 "{action}: pass --yes to confirm (stdin is not a terminal)"
204 )));
205 }
206 eprintln!("{action}? Type 'yes' to confirm: ");
207 let mut line = String::new();
208 std::io::stdin().read_line(&mut line).ok();
209 if line.trim() == "yes" {
210 Ok(())
211 } else {
212 Err(GiteeError::Usage("aborted".into()))
213 }
214}
215
216pub(crate) fn join_flags(values: &[String]) -> Option<String> {
219 let parts: Vec<&str> = values
220 .iter()
221 .flat_map(|v| v.split(','))
222 .map(str::trim)
223 .filter(|s| !s.is_empty())
224 .collect();
225 (!parts.is_empty()).then(|| parts.join(","))
226}
227
228pub(crate) fn resolve_milestone(ctx: &Ctx, repo: &Repo, id_or_title: &str) -> Result<i64> {
231 if let Ok(n) = id_or_title.trim().parse::<i64>() {
232 return Ok(n);
233 }
234 let list = ctx
235 .client
236 .repos()
237 .list_milestones(&repo.owner, &repo.name)?;
238 crate::models::Milestone::resolve(&list, id_or_title).ok_or_else(|| {
239 let known = list
240 .iter()
241 .map(|m| m.title.as_str())
242 .collect::<Vec<_>>()
243 .join(", ");
244 GiteeError::Usage(format!(
245 "no milestone titled '{id_or_title}' (available: {known})"
246 ))
247 })
248}
249
250pub(crate) fn resolve_milestone_opt(
252 ctx: &Ctx,
253 repo: &Repo,
254 id_or_title: Option<&str>,
255) -> Result<Option<i64>> {
256 match id_or_title {
257 Some(m) => Ok(Some(resolve_milestone(ctx, repo, m)?)),
258 None => Ok(None),
259 }
260}
261
262fn build(cli: &Cli) -> Result<Ctx> {
263 build_inner(cli, true)
264}
265
266fn build_inner(cli: &Cli, require_auth: bool) -> Result<Ctx> {
267 Ok(Ctx {
268 client: core_inner(cli, require_auth)?,
269 out: Output {
270 json: cli.json.clone(),
271 jq: cli.jq.clone(),
272 },
273 host: cli.host.clone(),
274 preview: cli.preview,
275 repo_arg: cli.repo.clone(),
276 remote_arg: cli.remote.clone(),
277 repo: OnceCell::new(),
278 me: OnceCell::new(),
279 })
280}
281
282fn completions(shell: Option<String>) -> Result<()> {
283 let shell = match shell.as_deref() {
284 Some(s) => Shell::from_str(s).map_err(|_| {
285 GiteeError::Usage(format!(
286 "unknown shell '{s}'; use one of: bash, zsh, fish, powershell, elvish"
287 ))
288 })?,
289 None => detect_shell()?,
290 };
291 let mut cmd: clap::Command = crate::cli::Cli::command();
294 let mut buf = Vec::new();
295 generate(shell, &mut cmd, "gitee", &mut buf);
296 use std::io::Write;
297 let mut out = std::io::stdout().lock();
298 match out.write_all(&buf).and_then(|()| out.flush()) {
299 Ok(()) => Ok(()),
300 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
301 Err(e) => Err(e.into()),
302 }
303}
304
305fn detect_shell() -> Result<Shell> {
306 let shell = std::env::var("SHELL").unwrap_or_default();
307 let name = shell.rsplit('/').next().unwrap_or("bash");
308 Shell::from_str(name).map_err(|_| {
309 GiteeError::Usage(format!(
310 "could not detect shell from $SHELL='{shell}'; pass it explicitly (bash|zsh|fish|...)"
311 ))
312 })
313}
314
315#[cfg(test)]
316mod auth_free_tests {
317 use super::*;
318 use crate::cli::Cli;
319
320 #[test]
321 fn builds_without_auth_for_local_commands() {
322 use clap::Parser;
323
324 let dir = std::env::temp_dir().join(format!(
325 "gitee-cli-authfree-{}-{}",
326 std::process::id(),
327 std::time::SystemTime::now()
328 .duration_since(std::time::UNIX_EPOCH)
329 .unwrap()
330 .as_nanos()
331 ));
332 let _ = std::fs::remove_dir_all(&dir);
333 std::fs::create_dir_all(&dir).unwrap();
334 std::fs::write(dir.join("config.json"), "{}
335").unwrap();
336 std::env::set_var("GITEE_CONFIG_DIR", &dir);
337 for args in ["gitee config list", "gitee alias list", "gitee browse"] {
338 let cli = Cli::try_parse_from(args.split_whitespace()).expect("parse");
339 build_inner(&cli, false).expect("build without auth");
340 }
341 std::env::remove_var("GITEE_CONFIG_DIR");
342 let _ = std::fs::remove_dir_all(&dir);
343 }
344}
345
346#[cfg(test)]
347mod flag_tests {
348 #[test]
349 fn join_flags_flattens_repeatable_and_comma_split() {
350 let v = vec!["a,b".to_string(), " c ".to_string()];
351 assert_eq!(super::join_flags(&v).as_deref(), Some("a,b,c"));
352 }
353
354 #[test]
355 fn join_flags_empty_is_none() {
356 assert_eq!(super::join_flags(&[]), None);
357 assert_eq!(super::join_flags(&[" ".to_string()]), None);
358 }
359
360 #[test]
361 fn preview_line_includes_action_and_keyed_details() {
362 let line = super::preview_line("close issue I88", &[("repo", "oschina/gitee-cli")]);
363 assert_eq!(line, "would close issue I88: repo=oschina/gitee-cli");
364 }
365
366 #[test]
367 fn preview_line_omits_details_when_empty() {
368 let line = super::preview_line("delete repo", &[]);
369 assert_eq!(line, "would delete repo");
370 }
371}
372
373
374#[cfg(test)]
375mod create_title_tests {
376 use super::*;
377 use crate::cli::{Cli, Command, IssueCmd, PrCmd};
378 use clap::Parser;
379
380 #[test]
381 fn issue_create_non_tty_missing_title_before_repo() {
382 let _env = crate::config::test_config_env_lock();
383 let prev_token = std::env::var_os("GITEE_TOKEN");
384 std::env::set_var("GITEE_TOKEN", "test-token");
385 let cli = Cli::try_parse_from(["gitee", "issue", "create"]).unwrap();
386 let ctx = build_inner(&cli, true).unwrap();
387 let Command::Issue(cmd) = cli.cmd else {
388 panic!("expected issue command");
389 };
390 let IssueCmd::Create { .. } = cmd else {
391 panic!("expected issue create");
392 };
393 let err = issue::execute(&ctx, cmd).unwrap_err();
394 assert!(err.to_string().contains("issue create needs --title"));
395 if let Some(t) = prev_token {
396 std::env::set_var("GITEE_TOKEN", t);
397 } else {
398 std::env::remove_var("GITEE_TOKEN");
399 }
400 }
401
402 #[test]
403 fn pr_create_non_tty_missing_title_before_repo() {
404 let _env = crate::config::test_config_env_lock();
405 let prev_token = std::env::var_os("GITEE_TOKEN");
406 std::env::set_var("GITEE_TOKEN", "test-token");
407 let cli = Cli::try_parse_from(["gitee", "pr", "create"]).unwrap();
408 let ctx = build_inner(&cli, true).unwrap();
409 let Command::Pr(cmd) = cli.cmd else {
410 panic!("expected pr command");
411 };
412 let PrCmd::Create { .. } = cmd else {
413 panic!("expected pr create");
414 };
415 let err = pr::execute(&ctx, cmd).unwrap_err();
416 assert!(err.to_string().contains("pr create needs --title"));
417 if let Some(t) = prev_token {
418 std::env::set_var("GITEE_TOKEN", t);
419 } else {
420 std::env::remove_var("GITEE_TOKEN");
421 }
422 }
423
424 #[test]
428 fn issue_create_preview_prints_intent_without_http() {
429 let _env = crate::config::test_config_env_lock();
430 let prev_token = std::env::var_os("GITEE_TOKEN");
431 std::env::set_var("GITEE_TOKEN", "test-token");
432 let cli = Cli::try_parse_from([
433 "gitee",
434 "--repo",
435 "oschina/gitee-cli",
436 "--preview",
437 "issue",
438 "create",
439 "--title",
440 "T",
441 ])
442 .unwrap();
443 assert!(cli.preview);
444 let ctx = build_inner(&cli, true).unwrap();
445 let Command::Issue(cmd) = cli.cmd else {
446 panic!("expected issue command");
447 };
448 let IssueCmd::Create { .. } = cmd else {
449 panic!("expected issue create");
450 };
451 issue::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
454 if let Some(t) = prev_token {
455 std::env::set_var("GITEE_TOKEN", t);
456 } else {
457 std::env::remove_var("GITEE_TOKEN");
458 }
459 }
460
461 #[test]
462 fn issue_close_preview_prints_intent_without_http() {
463 let _env = crate::config::test_config_env_lock();
464 let prev_token = std::env::var_os("GITEE_TOKEN");
465 std::env::set_var("GITEE_TOKEN", "test-token");
466 let cli = Cli::try_parse_from([
467 "gitee",
468 "--repo",
469 "oschina/gitee-cli",
470 "--preview",
471 "issue",
472 "close",
473 "I88",
474 ])
475 .unwrap();
476 let ctx = build_inner(&cli, true).unwrap();
477 let Command::Issue(cmd) = cli.cmd else {
478 panic!("expected issue command");
479 };
480 let IssueCmd::Close { .. } = cmd else {
481 panic!("expected issue close");
482 };
483 issue::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
484 if let Some(t) = prev_token {
485 std::env::set_var("GITEE_TOKEN", t);
486 } else {
487 std::env::remove_var("GITEE_TOKEN");
488 }
489 }
490
491 #[test]
492 fn pr_create_preview_prints_intent_without_http() {
493 let _env = crate::config::test_config_env_lock();
494 let prev_token = std::env::var_os("GITEE_TOKEN");
495 std::env::set_var("GITEE_TOKEN", "test-token");
496 let cli = Cli::try_parse_from([
497 "gitee",
498 "--repo",
499 "oschina/gitee-cli",
500 "--preview",
501 "pr",
502 "create",
503 "--title",
504 "T",
505 "--head",
506 "y",
507 ])
508 .unwrap();
509 let ctx = build_inner(&cli, true).unwrap();
510 let Command::Pr(cmd) = cli.cmd else {
511 panic!("expected pr command");
512 };
513 let PrCmd::Create { .. } = cmd else {
514 panic!("expected pr create");
515 };
516 pr::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
517 if let Some(t) = prev_token {
518 std::env::set_var("GITEE_TOKEN", t);
519 } else {
520 std::env::remove_var("GITEE_TOKEN");
521 }
522 }
523
524 #[test]
526 fn issue_comment_create_preview_prints_intent_without_http() {
527 let _env = crate::config::test_config_env_lock();
528 let prev_token = std::env::var_os("GITEE_TOKEN");
529 std::env::set_var("GITEE_TOKEN", "test-token");
530 let cli = Cli::try_parse_from([
531 "gitee",
532 "--repo",
533 "oschina/gitee-cli",
534 "--preview",
535 "issue",
536 "comment",
537 "create",
538 "I88",
539 "-m",
540 "looking into it",
541 ])
542 .unwrap();
543 assert!(cli.preview);
544 let ctx = build_inner(&cli, true).unwrap();
545 let Command::Issue(cmd) = cli.cmd else {
546 panic!("expected issue command");
547 };
548 let IssueCmd::Comment(crate::cli::IssueCommentCmd::Create { .. }) = cmd else {
549 panic!("expected issue comment create");
550 };
551 issue::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
552 if let Some(t) = prev_token {
553 std::env::set_var("GITEE_TOKEN", t);
554 } else {
555 std::env::remove_var("GITEE_TOKEN");
556 }
557 }
558
559 #[test]
561 fn pr_comment_create_preview_prints_intent_without_http() {
562 let _env = crate::config::test_config_env_lock();
563 let prev_token = std::env::var_os("GITEE_TOKEN");
564 std::env::set_var("GITEE_TOKEN", "test-token");
565 let cli = Cli::try_parse_from([
566 "gitee",
567 "--repo",
568 "oschina/gitee-cli",
569 "--preview",
570 "pr",
571 "comment",
572 "create",
573 "42",
574 "-m",
575 "LGTM",
576 ])
577 .unwrap();
578 assert!(cli.preview);
579 let ctx = build_inner(&cli, true).unwrap();
580 let Command::Pr(cmd) = cli.cmd else {
581 panic!("expected pr command");
582 };
583 let PrCmd::Comment(crate::cli::PrCommentCmd::Create { .. }) = cmd else {
584 panic!("expected pr comment create");
585 };
586 pr::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
587 if let Some(t) = prev_token {
588 std::env::set_var("GITEE_TOKEN", t);
589 } else {
590 std::env::remove_var("GITEE_TOKEN");
591 }
592 }
593}