1use super::require_interactive;
4use crate::cli::commands::{
5 GithubAssignCmd, GithubCommentCmd, GithubCommentsCmd, GithubCreateCmd, GithubEditCmd,
6 GithubLabelsCmd, GithubListCmd, GithubShowCmd, GithubStatusCmd,
7};
8use crate::cli::ui::Loader;
9use crate::commands::terminal_table::{render_table, TableStyle};
10use crate::commands::text_util::truncate_chars;
11use crate::provider_support::http::extract_github_error_message;
12use colored::Colorize;
13use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, MultiSelect};
14use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
15use reqwest::Client;
16use serde::Deserialize;
17use serde_json::{json, Value as JsonValue};
18
19const GITHUB_API_BASE: &str = "https://api.github.com";
20const GITHUB_API_VERSION: &str = "2022-11-28";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct GithubIssue {
24 pub number: u64,
25 pub title: String,
26 pub body: Option<String>,
27 pub state: String,
28 pub html_url: Option<String>,
29 pub user_login: Option<String>,
30 pub assignees: Vec<String>,
31 pub labels: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GithubComment {
36 pub id: u64,
37 pub body: String,
38 pub user_login: Option<String>,
39 pub created_at: Option<String>,
40 pub html_url: Option<String>,
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct ListIssuesFilter {
45 pub state: String,
46 pub labels: Vec<String>,
47 pub assignee: Option<String>,
48 pub query: Option<String>,
49 pub limit: usize,
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct CreateIssueInput {
54 pub title: String,
55 pub body: Option<String>,
56 pub labels: Vec<String>,
57 pub assignees: Vec<String>,
58}
59
60#[derive(Debug, Clone, Default)]
61pub struct UpdateIssueInput {
62 pub title: Option<String>,
63 pub body: Option<String>,
64 pub state: Option<String>,
65 pub labels: Option<Vec<String>>,
66 pub assignees: Option<Vec<String>>,
67}
68
69#[derive(Debug, Deserialize)]
70struct IssueDto {
71 number: u64,
72 title: String,
73 #[serde(default)]
74 body: Option<String>,
75 state: String,
76 #[serde(default)]
77 html_url: Option<String>,
78 #[serde(default)]
79 user: Option<UserDto>,
80 #[serde(default)]
81 assignees: Vec<UserDto>,
82 #[serde(default)]
83 labels: Vec<LabelDto>,
84}
85
86#[derive(Debug, Deserialize)]
87struct UserDto {
88 #[serde(default)]
89 login: Option<String>,
90}
91
92#[derive(Debug, Deserialize)]
93struct LabelDto {
94 #[serde(default)]
95 name: Option<String>,
96}
97
98#[derive(Debug, Deserialize)]
99struct CommentDto {
100 id: u64,
101 body: String,
102 #[serde(default)]
103 user: Option<UserDto>,
104 #[serde(default)]
105 created_at: Option<String>,
106 #[serde(default)]
107 html_url: Option<String>,
108}
109
110impl From<IssueDto> for GithubIssue {
111 fn from(dto: IssueDto) -> Self {
112 Self {
113 number: dto.number,
114 title: dto.title,
115 body: dto.body,
116 state: dto.state,
117 html_url: dto.html_url,
118 user_login: dto.user.and_then(|u| u.login),
119 assignees: dto.assignees.into_iter().filter_map(|u| u.login).collect(),
120 labels: dto.labels.into_iter().filter_map(|l| l.name).collect(),
121 }
122 }
123}
124
125fn auth_headers(token: &str) -> Result<HeaderMap, String> {
126 let mut headers = HeaderMap::new();
127 headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
128 headers.insert(
129 ACCEPT,
130 HeaderValue::from_static("application/vnd.github+json"),
131 );
132 headers.insert(
133 "X-GitHub-Api-Version",
134 HeaderValue::from_static(GITHUB_API_VERSION),
135 );
136 let auth = format!("Bearer {}", token.trim());
137 headers.insert(
138 AUTHORIZATION,
139 HeaderValue::from_str(&auth).map_err(|e| format!("Invalid GitHub token header: {e}"))?,
140 );
141 Ok(headers)
142}
143
144fn client() -> Result<Client, String> {
145 Client::builder()
146 .user_agent("xbp")
147 .build()
148 .map_err(|e| format!("Failed to build HTTP client: {e}"))
149}
150
151async fn github_json<T: for<'de> Deserialize<'de>>(
152 token: &str,
153 method: reqwest::Method,
154 path: &str,
155 body: Option<&JsonValue>,
156) -> Result<T, String> {
157 let url = if path.starts_with("http") {
158 path.to_string()
159 } else {
160 format!(
161 "{}{}",
162 GITHUB_API_BASE,
163 if path.starts_with('/') {
164 path.to_string()
165 } else {
166 format!("/{path}")
167 }
168 )
169 };
170 let mut req = client()?
171 .request(method, &url)
172 .headers(auth_headers(token)?);
173 if let Some(body) = body {
174 req = req.json(body);
175 }
176 let response = req
177 .send()
178 .await
179 .map_err(|e| format!("GitHub API request failed: {e}"))?;
180 let status = response.status();
181 let text = response
182 .text()
183 .await
184 .map_err(|e| format!("Failed to read GitHub response: {e}"))?;
185 if !status.is_success() {
186 let detail =
187 extract_github_error_message(&text).unwrap_or_else(|| format!("HTTP {status}"));
188 return Err(format!("GitHub API error: {detail}"));
189 }
190 if text.trim().is_empty() {
191 return serde_json::from_value(json!({})).map_err(|e| e.to_string());
192 }
193 serde_json::from_str(&text).map_err(|e| format!("Failed to parse GitHub response: {e}"))
194}
195
196pub async fn list_issues(
197 token: &str,
198 owner: &str,
199 repo: &str,
200 filter: ListIssuesFilter,
201) -> Result<Vec<GithubIssue>, String> {
202 let limit = filter.limit.clamp(1, 100);
203 let state = if filter.state.trim().is_empty() {
204 "open"
205 } else {
206 filter.state.trim()
207 };
208 let mut url =
209 format!("/repos/{owner}/{repo}/issues?state={state}&per_page={limit}&sort=updated");
210 if !filter.labels.is_empty() {
211 url.push_str(&format!("&labels={}", filter.labels.join(",")));
212 }
213 if let Some(assignee) = filter
214 .assignee
215 .as_deref()
216 .map(str::trim)
217 .filter(|s| !s.is_empty())
218 {
219 url.push_str(&format!("&assignee={assignee}"));
220 }
221 let raw: Vec<JsonValue> = github_json(token, reqwest::Method::GET, &url, None).await?;
222 let query = filter
223 .query
224 .as_deref()
225 .map(str::trim)
226 .filter(|q| !q.is_empty())
227 .map(|q| q.to_ascii_lowercase());
228
229 Ok(raw
230 .into_iter()
231 .filter(|v| v.get("pull_request").is_none())
232 .filter_map(|v| serde_json::from_value::<IssueDto>(v).ok())
233 .map(GithubIssue::from)
234 .filter(|issue| {
235 let Some(q) = query.as_deref() else {
236 return true;
237 };
238 issue.title.to_ascii_lowercase().contains(q)
239 || issue
240 .body
241 .as_deref()
242 .is_some_and(|b| b.to_ascii_lowercase().contains(q))
243 || issue.number.to_string().contains(q)
244 })
245 .collect())
246}
247
248pub async fn get_issue(
249 token: &str,
250 owner: &str,
251 repo: &str,
252 number: u64,
253) -> Result<GithubIssue, String> {
254 let path = format!("/repos/{owner}/{repo}/issues/{number}");
255 let dto: IssueDto = github_json(token, reqwest::Method::GET, &path, None).await?;
256 Ok(dto.into())
257}
258
259pub async fn create_issue(
260 token: &str,
261 owner: &str,
262 repo: &str,
263 input: CreateIssueInput,
264) -> Result<GithubIssue, String> {
265 let path = format!("/repos/{owner}/{repo}/issues");
266 let mut body = json!({ "title": input.title });
267 if let Some(b) = input.body {
268 body["body"] = json!(b);
269 }
270 if !input.labels.is_empty() {
271 body["labels"] = json!(input.labels);
272 }
273 if !input.assignees.is_empty() {
274 body["assignees"] = json!(input.assignees);
275 }
276 let dto: IssueDto = github_json(token, reqwest::Method::POST, &path, Some(&body)).await?;
277 Ok(dto.into())
278}
279
280pub async fn update_issue(
281 token: &str,
282 owner: &str,
283 repo: &str,
284 number: u64,
285 input: UpdateIssueInput,
286) -> Result<GithubIssue, String> {
287 let path = format!("/repos/{owner}/{repo}/issues/{number}");
288 let mut body = serde_json::Map::new();
289 if let Some(title) = input.title {
290 body.insert("title".into(), json!(title));
291 }
292 if let Some(b) = input.body {
293 body.insert("body".into(), json!(b));
294 }
295 if let Some(state) = input.state {
296 body.insert("state".into(), json!(state));
297 }
298 if let Some(labels) = input.labels {
299 body.insert("labels".into(), json!(labels));
300 }
301 if let Some(assignees) = input.assignees {
302 body.insert("assignees".into(), json!(assignees));
303 }
304 if body.is_empty() {
305 return get_issue(token, owner, repo, number).await;
306 }
307 let dto: IssueDto = github_json(
308 token,
309 reqwest::Method::PATCH,
310 &path,
311 Some(&JsonValue::Object(body)),
312 )
313 .await?;
314 Ok(dto.into())
315}
316
317pub async fn list_comments(
318 token: &str,
319 owner: &str,
320 repo: &str,
321 number: u64,
322) -> Result<Vec<GithubComment>, String> {
323 let path = format!("/repos/{owner}/{repo}/issues/{number}/comments?per_page=100");
324 let comments: Vec<CommentDto> = github_json(token, reqwest::Method::GET, &path, None).await?;
325 Ok(comments
326 .into_iter()
327 .map(|c| GithubComment {
328 id: c.id,
329 body: c.body,
330 user_login: c.user.and_then(|u| u.login),
331 created_at: c.created_at,
332 html_url: c.html_url,
333 })
334 .collect())
335}
336
337pub async fn create_comment(
338 token: &str,
339 owner: &str,
340 repo: &str,
341 number: u64,
342 body_text: &str,
343) -> Result<GithubComment, String> {
344 let body_text = body_text.trim();
345 if body_text.is_empty() {
346 return Err("Comment body cannot be empty.".into());
347 }
348 let path = format!("/repos/{owner}/{repo}/issues/{number}/comments");
349 let body = json!({ "body": body_text });
350 let c: CommentDto = github_json(token, reqwest::Method::POST, &path, Some(&body)).await?;
351 Ok(GithubComment {
352 id: c.id,
353 body: c.body,
354 user_login: c.user.and_then(|u| u.login),
355 created_at: c.created_at,
356 html_url: c.html_url,
357 })
358}
359
360pub async fn list_labels(token: &str, owner: &str, repo: &str) -> Result<Vec<String>, String> {
361 let path = format!("/repos/{owner}/{repo}/labels?per_page=100");
362 let labels: Vec<LabelDto> = github_json(token, reqwest::Method::GET, &path, None).await?;
363 Ok(labels.into_iter().filter_map(|l| l.name).collect())
364}
365
366pub async fn ensure_label(token: &str, owner: &str, repo: &str, name: &str) -> Result<(), String> {
367 let labels = list_labels(token, owner, repo).await.unwrap_or_default();
368 if labels.iter().any(|l| l.eq_ignore_ascii_case(name)) {
369 return Ok(());
370 }
371 let path = format!("/repos/{owner}/{repo}/labels");
372 let body = json!({
373 "name": name,
374 "color": "0e8a16",
375 "description": "Created by xbp issues sync"
376 });
377 let _: JsonValue = github_json(token, reqwest::Method::POST, &path, Some(&body))
378 .await
379 .unwrap_or(json!({}));
380 Ok(())
381}
382
383pub async fn run_list(
384 token: &str,
385 owner: &str,
386 repo: &str,
387 args: GithubListCmd,
388) -> Result<(), String> {
389 let loader = Loader::start(&format!("Fetching issues for {owner}/{repo}"));
390 let issues = match list_issues(
391 token,
392 owner,
393 repo,
394 ListIssuesFilter {
395 state: args.state,
396 labels: args.labels,
397 assignee: args.assignee,
398 query: args.query,
399 limit: args.limit,
400 },
401 )
402 .await
403 {
404 Ok(v) => {
405 loader.success();
406 v
407 }
408 Err(e) => {
409 loader.fail(&e);
410 return Err(e);
411 }
412 };
413 print_issue_table(&issues);
414 Ok(())
415}
416
417pub async fn run_show(
418 token: &str,
419 owner: &str,
420 repo: &str,
421 args: GithubShowCmd,
422) -> Result<(), String> {
423 let number = parse_issue_number(&args.id)?;
424 let loader = Loader::start("Fetching GitHub issue");
425 let issue = match get_issue(token, owner, repo, number).await {
426 Ok(v) => {
427 loader.success();
428 v
429 }
430 Err(e) => {
431 loader.fail(&e);
432 return Err(e);
433 }
434 };
435 print_issue_detail(&issue);
436 Ok(())
437}
438
439pub async fn run_create(
440 token: &str,
441 owner: &str,
442 repo: &str,
443 args: GithubCreateCmd,
444) -> Result<(), String> {
445 let title = match args.title.as_deref() {
446 Some(t) if !t.trim().is_empty() => t.trim().to_string(),
447 _ => {
448 require_interactive("Creating a GitHub issue")?;
449 Input::<String>::with_theme(&ColorfulTheme::default())
450 .with_prompt("Title")
451 .interact_text()
452 .map_err(|e| e.to_string())?
453 }
454 };
455 let body = match args.body.clone() {
456 Some(b) => Some(b),
457 None if args.interactive_body => {
458 require_interactive("Creating a GitHub issue")?;
459 let b: String = Input::with_theme(&ColorfulTheme::default())
460 .with_prompt("Body (optional)")
461 .allow_empty(true)
462 .interact_text()
463 .map_err(|e| e.to_string())?;
464 if b.trim().is_empty() {
465 None
466 } else {
467 Some(b)
468 }
469 }
470 None => None,
471 };
472 let loader = Loader::start("Creating GitHub issue");
473 let issue = match create_issue(
474 token,
475 owner,
476 repo,
477 CreateIssueInput {
478 title,
479 body,
480 labels: args.labels,
481 assignees: args.assignees,
482 },
483 )
484 .await
485 {
486 Ok(v) => {
487 loader.success_with(&format!("created #{}", v.number));
488 v
489 }
490 Err(e) => {
491 loader.fail(&e);
492 return Err(e);
493 }
494 };
495 print_issue_detail(&issue);
496 Ok(())
497}
498
499pub async fn run_edit(
500 token: &str,
501 owner: &str,
502 repo: &str,
503 args: GithubEditCmd,
504) -> Result<(), String> {
505 let number = parse_issue_number(&args.id)?;
506 let mut input = UpdateIssueInput {
507 title: args.title.clone(),
508 body: args.body.clone(),
509 state: None,
510 labels: if args.labels.is_empty() {
511 None
512 } else {
513 Some(args.labels.clone())
514 },
515 assignees: None,
516 };
517 if input.title.is_none() && input.body.is_none() && input.labels.is_none() {
518 require_interactive("Editing a GitHub issue")?;
519 let issue = get_issue(token, owner, repo, number).await?;
520 let title: String = Input::with_theme(&ColorfulTheme::default())
521 .with_prompt("Title")
522 .with_initial_text(&issue.title)
523 .interact_text()
524 .map_err(|e| e.to_string())?;
525 input.title = Some(title);
526 let body: String = Input::with_theme(&ColorfulTheme::default())
527 .with_prompt("Body")
528 .with_initial_text(issue.body.as_deref().unwrap_or(""))
529 .allow_empty(true)
530 .interact_text()
531 .map_err(|e| e.to_string())?;
532 input.body = Some(body);
533 }
534 let loader = Loader::start("Updating GitHub issue");
535 let issue = match update_issue(token, owner, repo, number, input).await {
536 Ok(v) => {
537 loader.success();
538 v
539 }
540 Err(e) => {
541 loader.fail(&e);
542 return Err(e);
543 }
544 };
545 print_issue_detail(&issue);
546 Ok(())
547}
548
549pub async fn run_status(
550 token: &str,
551 owner: &str,
552 repo: &str,
553 args: GithubStatusCmd,
554) -> Result<(), String> {
555 let number = parse_issue_number(&args.id)?;
556 let state = match args.state.as_deref() {
557 Some(s) if s.eq_ignore_ascii_case("open") || s.eq_ignore_ascii_case("closed") => {
558 s.to_ascii_lowercase()
559 }
560 Some(s) => {
561 return Err(format!("Invalid state `{s}`. Use open or closed."));
562 }
563 None => {
564 require_interactive("Changing GitHub issue state")?;
565 let options = ["open", "closed"];
566 let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
567 .with_prompt(format!("State for #{number}"))
568 .items(&options)
569 .default(0)
570 .interact()
571 .map_err(|e| e.to_string())?;
572 options[idx].to_string()
573 }
574 };
575 let loader = Loader::start("Updating state");
576 let issue = match update_issue(
577 token,
578 owner,
579 repo,
580 number,
581 UpdateIssueInput {
582 state: Some(state),
583 ..Default::default()
584 },
585 )
586 .await
587 {
588 Ok(v) => {
589 loader.success();
590 v
591 }
592 Err(e) => {
593 loader.fail(&e);
594 return Err(e);
595 }
596 };
597 println!(
598 "{} #{} → {}",
599 "OK".bright_green().bold(),
600 issue.number,
601 issue.state.bright_cyan()
602 );
603 Ok(())
604}
605
606pub async fn run_labels(
607 token: &str,
608 owner: &str,
609 repo: &str,
610 args: GithubLabelsCmd,
611) -> Result<(), String> {
612 let number = parse_issue_number(&args.id)?;
613 let issue = get_issue(token, owner, repo, number).await?;
614 let labels = if args.clear {
615 Vec::new()
616 } else if !args.set.is_empty() {
617 args.set
618 } else if !args.add.is_empty() || !args.remove.is_empty() {
619 let mut current = issue.labels.clone();
620 for l in &args.add {
621 if !current.iter().any(|c| c.eq_ignore_ascii_case(l)) {
622 current.push(l.clone());
623 }
624 }
625 current.retain(|c| !args.remove.iter().any(|r| r.eq_ignore_ascii_case(c)));
626 current
627 } else {
628 require_interactive("Assigning GitHub labels")?;
629 let known = list_labels(token, owner, repo).await?;
630 if known.is_empty() {
631 return Err("No labels found in this repository.".into());
632 }
633 let defaults: Vec<bool> = known
634 .iter()
635 .map(|l| issue.labels.iter().any(|c| c.eq_ignore_ascii_case(l)))
636 .collect();
637 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
638 .with_prompt(format!("Labels for #{number}"))
639 .items(&known)
640 .defaults(&defaults)
641 .interact()
642 .map_err(|e| e.to_string())?;
643 selected.into_iter().map(|i| known[i].clone()).collect()
644 };
645 let loader = Loader::start("Updating labels");
646 let updated = match update_issue(
647 token,
648 owner,
649 repo,
650 number,
651 UpdateIssueInput {
652 labels: Some(labels),
653 ..Default::default()
654 },
655 )
656 .await
657 {
658 Ok(v) => {
659 loader.success();
660 v
661 }
662 Err(e) => {
663 loader.fail(&e);
664 return Err(e);
665 }
666 };
667 println!(
668 "{} #{} labels → {}",
669 "OK".bright_green().bold(),
670 updated.number,
671 if updated.labels.is_empty() {
672 "(none)".to_string()
673 } else {
674 updated.labels.join(", ")
675 }
676 );
677 Ok(())
678}
679
680pub async fn run_assign(
681 token: &str,
682 owner: &str,
683 repo: &str,
684 args: GithubAssignCmd,
685) -> Result<(), String> {
686 let number = parse_issue_number(&args.id)?;
687 let assignees = match args.assignee.as_deref() {
688 Some(a) if a.eq_ignore_ascii_case("none") || a.is_empty() => Vec::new(),
689 Some(a) => vec![a.trim().to_string()],
690 None => {
691 require_interactive("Assigning a GitHub issue")?;
692 let login: String = Input::with_theme(&ColorfulTheme::default())
693 .with_prompt("Assignee GitHub login (empty to unassign)")
694 .allow_empty(true)
695 .interact_text()
696 .map_err(|e| e.to_string())?;
697 if login.trim().is_empty() {
698 Vec::new()
699 } else {
700 vec![login.trim().to_string()]
701 }
702 }
703 };
704 let loader = Loader::start("Updating assignees");
705 let issue = match update_issue(
706 token,
707 owner,
708 repo,
709 number,
710 UpdateIssueInput {
711 assignees: Some(assignees),
712 ..Default::default()
713 },
714 )
715 .await
716 {
717 Ok(v) => {
718 loader.success();
719 v
720 }
721 Err(e) => {
722 loader.fail(&e);
723 return Err(e);
724 }
725 };
726 println!(
727 "{} #{} assignees → {}",
728 "OK".bright_green().bold(),
729 issue.number,
730 if issue.assignees.is_empty() {
731 "(none)".to_string()
732 } else {
733 issue.assignees.join(", ")
734 }
735 );
736 Ok(())
737}
738
739pub async fn run_comments(
740 token: &str,
741 owner: &str,
742 repo: &str,
743 args: GithubCommentsCmd,
744) -> Result<(), String> {
745 let number = parse_issue_number(&args.id)?;
746 let loader = Loader::start("Fetching comments");
747 let comments = match list_comments(token, owner, repo, number).await {
748 Ok(v) => {
749 loader.success();
750 v
751 }
752 Err(e) => {
753 loader.fail(&e);
754 return Err(e);
755 }
756 };
757 if comments.is_empty() {
758 println!("{}", "No comments.".dimmed());
759 return Ok(());
760 }
761 let rows: Vec<Vec<String>> = comments
762 .iter()
763 .map(|c| {
764 vec![
765 c.user_login.clone().unwrap_or_else(|| "-".into()),
766 c.created_at.clone().unwrap_or_else(|| "-".into()),
767 truncate_chars(&c.body.replace('\n', " "), 80),
768 ]
769 })
770 .collect();
771 print!(
772 "{}",
773 render_table(&["Author", "When", "Body"], &rows, TableStyle::Pipe, "",)
774 );
775 Ok(())
776}
777
778pub async fn run_comment(
779 token: &str,
780 owner: &str,
781 repo: &str,
782 args: GithubCommentCmd,
783) -> Result<(), String> {
784 let number = parse_issue_number(&args.id)?;
785 let body = match args.body.as_deref() {
786 Some(b) if !b.trim().is_empty() => b.to_string(),
787 _ => {
788 require_interactive("Writing a GitHub comment")?;
789 Input::<String>::with_theme(&ColorfulTheme::default())
790 .with_prompt(format!("Comment on #{number}"))
791 .interact_text()
792 .map_err(|e| e.to_string())?
793 }
794 };
795 let loader = Loader::start("Posting comment");
796 let comment = match create_comment(token, owner, repo, number, &body).await {
797 Ok(v) => {
798 loader.success();
799 v
800 }
801 Err(e) => {
802 loader.fail(&e);
803 return Err(e);
804 }
805 };
806 println!(
807 "{} comment on #{} by {}",
808 "OK".bright_green().bold(),
809 number,
810 comment.user_login.as_deref().unwrap_or("you")
811 );
812 Ok(())
813}
814
815pub fn print_issue_table(issues: &[GithubIssue]) {
816 if issues.is_empty() {
817 println!("{}", "No issues found.".dimmed());
818 return;
819 }
820 let rows: Vec<Vec<String>> = issues
821 .iter()
822 .map(|i| {
823 vec![
824 format!("#{}", i.number).bright_white().bold().to_string(),
825 i.state.clone().bright_cyan().to_string(),
826 if i.assignees.is_empty() {
827 "-".into()
828 } else {
829 i.assignees.join(", ")
830 },
831 truncate_chars(&i.title, 60),
832 ]
833 })
834 .collect();
835 print!(
836 "{}",
837 render_table(
838 &["#", "State", "Assignees", "Title"],
839 &rows,
840 TableStyle::Pipe,
841 "",
842 )
843 );
844 println!("Total: {} issue(s)", issues.len());
845}
846
847pub fn print_issue_detail(issue: &GithubIssue) {
848 println!();
849 println!(
850 "{} {}",
851 format!("#{}", issue.number).bright_white().bold(),
852 issue.title.bright_cyan()
853 );
854 if let Some(url) = &issue.html_url {
855 println!("{} {}", "url".dimmed(), url.underline());
856 }
857 println!("{} {}", "state".dimmed(), issue.state);
858 println!(
859 "{} {}",
860 "assignees".dimmed(),
861 if issue.assignees.is_empty() {
862 "-".to_string()
863 } else {
864 issue.assignees.join(", ")
865 }
866 );
867 println!(
868 "{} {}",
869 "labels".dimmed(),
870 if issue.labels.is_empty() {
871 "-".to_string()
872 } else {
873 issue.labels.join(", ")
874 }
875 );
876 if let Some(body) = &issue.body {
877 if !body.trim().is_empty() {
878 println!();
879 println!("{}", body.trim());
880 }
881 }
882 println!();
883}
884
885pub fn parse_issue_number(raw: &str) -> Result<u64, String> {
886 let s = raw.trim().trim_start_matches('#');
887 if let Some(num) = s.rsplit('/').next() {
888 if let Ok(n) = num.parse::<u64>() {
889 return Ok(n);
890 }
891 }
892 s.parse::<u64>()
893 .map_err(|_| format!("Invalid issue number `{raw}`."))
894}