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.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}