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