Skip to main content

xbp_cli/commands/github_cmd/
client.rs

1//! GitHub Issues REST client and command handlers.
2
3use 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