use super::require_interactive;
use crate::cli::commands::{
GithubAssignCmd, GithubCommentCmd, GithubCommentsCmd, GithubCreateCmd, GithubEditCmd,
GithubLabelsCmd, GithubListCmd, GithubShowCmd, GithubStatusCmd,
};
use crate::cli::ui::Loader;
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::commands::text_util::truncate_chars;
use crate::provider_support::http::extract_github_error_message;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, MultiSelect};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value as JsonValue};
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITHUB_API_VERSION: &str = "2022-11-28";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GithubIssue {
pub number: u64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub html_url: Option<String>,
pub user_login: Option<String>,
pub assignees: Vec<String>,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GithubComment {
pub id: u64,
pub body: String,
pub user_login: Option<String>,
pub created_at: Option<String>,
pub html_url: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListIssuesFilter {
pub state: String,
pub labels: Vec<String>,
pub assignee: Option<String>,
pub query: Option<String>,
pub limit: usize,
}
#[derive(Debug, Clone, Default)]
pub struct CreateIssueInput {
pub title: String,
pub body: Option<String>,
pub labels: Vec<String>,
pub assignees: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateIssueInput {
pub title: Option<String>,
pub body: Option<String>,
pub state: Option<String>,
pub labels: Option<Vec<String>>,
pub assignees: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct IssueDto {
number: u64,
title: String,
#[serde(default)]
body: Option<String>,
state: String,
#[serde(default)]
html_url: Option<String>,
#[serde(default)]
user: Option<UserDto>,
#[serde(default)]
assignees: Vec<UserDto>,
#[serde(default)]
labels: Vec<LabelDto>,
}
#[derive(Debug, Deserialize)]
struct UserDto {
#[serde(default)]
login: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LabelDto {
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CommentDto {
id: u64,
body: String,
#[serde(default)]
user: Option<UserDto>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
html_url: Option<String>,
}
impl From<IssueDto> for GithubIssue {
fn from(dto: IssueDto) -> Self {
Self {
number: dto.number,
title: dto.title,
body: dto.body,
state: dto.state,
html_url: dto.html_url,
user_login: dto.user.and_then(|u| u.login),
assignees: dto.assignees.into_iter().filter_map(|u| u.login).collect(),
labels: dto.labels.into_iter().filter_map(|l| l.name).collect(),
}
}
}
fn auth_headers(token: &str) -> Result<HeaderMap, String> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
"X-GitHub-Api-Version",
HeaderValue::from_static(GITHUB_API_VERSION),
);
let auth = format!("Bearer {}", token.trim());
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth).map_err(|e| format!("Invalid GitHub token header: {e}"))?,
);
Ok(headers)
}
fn client() -> Result<Client, String> {
Client::builder()
.user_agent("xbp")
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
async fn github_json<T: for<'de> Deserialize<'de>>(
token: &str,
method: reqwest::Method,
path: &str,
body: Option<&JsonValue>,
) -> Result<T, String> {
let url = if path.starts_with("http") {
path.to_string()
} else {
format!(
"{}{}",
GITHUB_API_BASE,
if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
)
};
let mut req = client()?
.request(method, &url)
.headers(auth_headers(token)?);
if let Some(body) = body {
req = req.json(body);
}
let response = req
.send()
.await
.map_err(|e| format!("GitHub API request failed: {e}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| format!("Failed to read GitHub response: {e}"))?;
if !status.is_success() {
let detail =
extract_github_error_message(&text).unwrap_or_else(|| format!("HTTP {status}"));
return Err(format!("GitHub API error: {detail}"));
}
if text.trim().is_empty() {
return serde_json::from_value(json!({})).map_err(|e| e.to_string());
}
serde_json::from_str(&text).map_err(|e| format!("Failed to parse GitHub response: {e}"))
}
pub async fn list_issues(
token: &str,
owner: &str,
repo: &str,
filter: ListIssuesFilter,
) -> Result<Vec<GithubIssue>, String> {
let limit = filter.limit.clamp(1, 100);
let state = if filter.state.trim().is_empty() {
"open"
} else {
filter.state.trim()
};
let mut url =
format!("/repos/{owner}/{repo}/issues?state={state}&per_page={limit}&sort=updated");
if !filter.labels.is_empty() {
url.push_str(&format!("&labels={}", filter.labels.join(",")));
}
if let Some(assignee) = filter
.assignee
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
url.push_str(&format!("&assignee={assignee}"));
}
let raw: Vec<JsonValue> = github_json(token, reqwest::Method::GET, &url, None).await?;
let query = filter
.query
.as_deref()
.map(str::trim)
.filter(|q| !q.is_empty())
.map(|q| q.to_ascii_lowercase());
Ok(raw
.into_iter()
.filter(|v| v.get("pull_request").is_none())
.filter_map(|v| serde_json::from_value::<IssueDto>(v).ok())
.map(GithubIssue::from)
.filter(|issue| {
let Some(q) = query.as_deref() else {
return true;
};
issue.title.to_ascii_lowercase().contains(q)
|| issue
.body
.as_deref()
.is_some_and(|b| b.to_ascii_lowercase().contains(q))
|| issue.number.to_string().contains(q)
})
.collect())
}
pub async fn get_issue(
token: &str,
owner: &str,
repo: &str,
number: u64,
) -> Result<GithubIssue, String> {
let path = format!("/repos/{owner}/{repo}/issues/{number}");
let dto: IssueDto = github_json(token, reqwest::Method::GET, &path, None).await?;
Ok(dto.into())
}
pub async fn create_issue(
token: &str,
owner: &str,
repo: &str,
input: CreateIssueInput,
) -> Result<GithubIssue, String> {
let path = format!("/repos/{owner}/{repo}/issues");
let mut body = json!({ "title": input.title });
if let Some(b) = input.body {
body["body"] = json!(b);
}
if !input.labels.is_empty() {
body["labels"] = json!(input.labels);
}
if !input.assignees.is_empty() {
body["assignees"] = json!(input.assignees);
}
let dto: IssueDto = github_json(token, reqwest::Method::POST, &path, Some(&body)).await?;
Ok(dto.into())
}
pub async fn update_issue(
token: &str,
owner: &str,
repo: &str,
number: u64,
input: UpdateIssueInput,
) -> Result<GithubIssue, String> {
let path = format!("/repos/{owner}/{repo}/issues/{number}");
let mut body = serde_json::Map::new();
if let Some(title) = input.title {
body.insert("title".into(), json!(title));
}
if let Some(b) = input.body {
body.insert("body".into(), json!(b));
}
if let Some(state) = input.state {
body.insert("state".into(), json!(state));
}
if let Some(labels) = input.labels {
body.insert("labels".into(), json!(labels));
}
if let Some(assignees) = input.assignees {
body.insert("assignees".into(), json!(assignees));
}
if body.is_empty() {
return get_issue(token, owner, repo, number).await;
}
let dto: IssueDto = github_json(
token,
reqwest::Method::PATCH,
&path,
Some(&JsonValue::Object(body)),
)
.await?;
Ok(dto.into())
}
pub async fn list_comments(
token: &str,
owner: &str,
repo: &str,
number: u64,
) -> Result<Vec<GithubComment>, String> {
let path = format!("/repos/{owner}/{repo}/issues/{number}/comments?per_page=100");
let comments: Vec<CommentDto> = github_json(token, reqwest::Method::GET, &path, None).await?;
Ok(comments
.into_iter()
.map(|c| GithubComment {
id: c.id,
body: c.body,
user_login: c.user.and_then(|u| u.login),
created_at: c.created_at,
html_url: c.html_url,
})
.collect())
}
pub async fn create_comment(
token: &str,
owner: &str,
repo: &str,
number: u64,
body_text: &str,
) -> Result<GithubComment, String> {
let body_text = body_text.trim();
if body_text.is_empty() {
return Err("Comment body cannot be empty.".into());
}
let path = format!("/repos/{owner}/{repo}/issues/{number}/comments");
let body = json!({ "body": body_text });
let c: CommentDto = github_json(token, reqwest::Method::POST, &path, Some(&body)).await?;
Ok(GithubComment {
id: c.id,
body: c.body,
user_login: c.user.and_then(|u| u.login),
created_at: c.created_at,
html_url: c.html_url,
})
}
pub async fn list_labels(token: &str, owner: &str, repo: &str) -> Result<Vec<String>, String> {
let path = format!("/repos/{owner}/{repo}/labels?per_page=100");
let labels: Vec<LabelDto> = github_json(token, reqwest::Method::GET, &path, None).await?;
Ok(labels.into_iter().filter_map(|l| l.name).collect())
}
pub async fn ensure_label(token: &str, owner: &str, repo: &str, name: &str) -> Result<(), String> {
let labels = list_labels(token, owner, repo).await.unwrap_or_default();
if labels.iter().any(|l| l.eq_ignore_ascii_case(name)) {
return Ok(());
}
let path = format!("/repos/{owner}/{repo}/labels");
let body = json!({
"name": name,
"color": "0e8a16",
"description": "Created by xbp issues sync"
});
let _: JsonValue = github_json(token, reqwest::Method::POST, &path, Some(&body))
.await
.unwrap_or(json!({}));
Ok(())
}
pub async fn run_list(
token: &str,
owner: &str,
repo: &str,
args: GithubListCmd,
) -> Result<(), String> {
let loader = Loader::start(&format!("Fetching issues for {owner}/{repo}"));
let issues = match list_issues(
token,
owner,
repo,
ListIssuesFilter {
state: args.state,
labels: args.labels,
assignee: args.assignee,
query: args.query,
limit: args.limit,
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_table(&issues);
Ok(())
}
pub async fn run_show(
token: &str,
owner: &str,
repo: &str,
args: GithubShowCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let loader = Loader::start("Fetching GitHub issue");
let issue = match get_issue(token, owner, repo, number).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_detail(&issue);
Ok(())
}
pub async fn run_create(
token: &str,
owner: &str,
repo: &str,
args: GithubCreateCmd,
) -> Result<(), String> {
let title = match args.title.as_deref() {
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
_ => {
require_interactive("Creating a GitHub issue")?;
Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Title")
.interact_text()
.map_err(|e| e.to_string())?
}
};
let body = match args.body.clone() {
Some(b) => Some(b),
None if args.interactive_body => {
require_interactive("Creating a GitHub issue")?;
let b: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Body (optional)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
if b.trim().is_empty() {
None
} else {
Some(b)
}
}
None => None,
};
let loader = Loader::start("Creating GitHub issue");
let issue = match create_issue(
token,
owner,
repo,
CreateIssueInput {
title,
body,
labels: args.labels,
assignees: args.assignees,
},
)
.await
{
Ok(v) => {
loader.success_with(&format!("created #{}", v.number));
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_detail(&issue);
Ok(())
}
pub async fn run_edit(
token: &str,
owner: &str,
repo: &str,
args: GithubEditCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let mut input = UpdateIssueInput {
title: args.title.clone(),
body: args.body.clone(),
state: None,
labels: if args.labels.is_empty() {
None
} else {
Some(args.labels.clone())
},
assignees: None,
};
if input.title.is_none() && input.body.is_none() && input.labels.is_none() {
require_interactive("Editing a GitHub issue")?;
let issue = get_issue(token, owner, repo, number).await?;
let title: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Title")
.with_initial_text(&issue.title)
.interact_text()
.map_err(|e| e.to_string())?;
input.title = Some(title);
let body: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Body")
.with_initial_text(issue.body.as_deref().unwrap_or(""))
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
input.body = Some(body);
}
let loader = Loader::start("Updating GitHub issue");
let issue = match update_issue(token, owner, repo, number, input).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_detail(&issue);
Ok(())
}
pub async fn run_status(
token: &str,
owner: &str,
repo: &str,
args: GithubStatusCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let state = match args.state.as_deref() {
Some(s) if s.eq_ignore_ascii_case("open") || s.eq_ignore_ascii_case("closed") => {
s.to_ascii_lowercase()
}
Some(s) => {
return Err(format!("Invalid state `{s}`. Use open or closed."));
}
None => {
require_interactive("Changing GitHub issue state")?;
let options = ["open", "closed"];
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("State for #{number}"))
.items(&options)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
options[idx].to_string()
}
};
let loader = Loader::start("Updating state");
let issue = match update_issue(
token,
owner,
repo,
number,
UpdateIssueInput {
state: Some(state),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} #{} → {}",
"OK".bright_green().bold(),
issue.number,
issue.state.bright_cyan()
);
Ok(())
}
pub async fn run_labels(
token: &str,
owner: &str,
repo: &str,
args: GithubLabelsCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let issue = get_issue(token, owner, repo, number).await?;
let labels = if args.clear {
Vec::new()
} else if !args.set.is_empty() {
args.set
} else if !args.add.is_empty() || !args.remove.is_empty() {
let mut current = issue.labels.clone();
for l in &args.add {
if !current.iter().any(|c| c.eq_ignore_ascii_case(l)) {
current.push(l.clone());
}
}
current.retain(|c| !args.remove.iter().any(|r| r.eq_ignore_ascii_case(c)));
current
} else {
require_interactive("Assigning GitHub labels")?;
let known = list_labels(token, owner, repo).await?;
if known.is_empty() {
return Err("No labels found in this repository.".into());
}
let defaults: Vec<bool> = known
.iter()
.map(|l| issue.labels.iter().any(|c| c.eq_ignore_ascii_case(l)))
.collect();
let selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Labels for #{number}"))
.items(&known)
.defaults(&defaults)
.interact()
.map_err(|e| e.to_string())?;
selected.into_iter().map(|i| known[i].clone()).collect()
};
let loader = Loader::start("Updating labels");
let updated = match update_issue(
token,
owner,
repo,
number,
UpdateIssueInput {
labels: Some(labels),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} #{} labels → {}",
"OK".bright_green().bold(),
updated.number,
if updated.labels.is_empty() {
"(none)".to_string()
} else {
updated.labels.join(", ")
}
);
Ok(())
}
pub async fn run_assign(
token: &str,
owner: &str,
repo: &str,
args: GithubAssignCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let assignees = match args.assignee.as_deref() {
Some(a) if a.eq_ignore_ascii_case("none") || a.is_empty() => Vec::new(),
Some(a) => vec![a.trim().to_string()],
None => {
require_interactive("Assigning a GitHub issue")?;
let login: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Assignee GitHub login (empty to unassign)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
if login.trim().is_empty() {
Vec::new()
} else {
vec![login.trim().to_string()]
}
}
};
let loader = Loader::start("Updating assignees");
let issue = match update_issue(
token,
owner,
repo,
number,
UpdateIssueInput {
assignees: Some(assignees),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} #{} assignees → {}",
"OK".bright_green().bold(),
issue.number,
if issue.assignees.is_empty() {
"(none)".to_string()
} else {
issue.assignees.join(", ")
}
);
Ok(())
}
pub async fn run_comments(
token: &str,
owner: &str,
repo: &str,
args: GithubCommentsCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let loader = Loader::start("Fetching comments");
let comments = match list_comments(token, owner, repo, number).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
if comments.is_empty() {
println!("{}", "No comments.".dimmed());
return Ok(());
}
let rows: Vec<Vec<String>> = comments
.iter()
.map(|c| {
vec![
c.user_login.clone().unwrap_or_else(|| "-".into()),
c.created_at.clone().unwrap_or_else(|| "-".into()),
truncate_chars(&c.body.replace('\n', " "), 80),
]
})
.collect();
print!(
"{}",
render_table(&["Author", "When", "Body"], &rows, TableStyle::Pipe, "",)
);
Ok(())
}
pub async fn run_comment(
token: &str,
owner: &str,
repo: &str,
args: GithubCommentCmd,
) -> Result<(), String> {
let number = parse_issue_number(&args.id)?;
let body = match args.body.as_deref() {
Some(b) if !b.trim().is_empty() => b.to_string(),
_ => {
require_interactive("Writing a GitHub comment")?;
Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Comment on #{number}"))
.interact_text()
.map_err(|e| e.to_string())?
}
};
let loader = Loader::start("Posting comment");
let comment = match create_comment(token, owner, repo, number, &body).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} comment on #{} by {}",
"OK".bright_green().bold(),
number,
comment.user_login.as_deref().unwrap_or("you")
);
Ok(())
}
pub fn print_issue_table(issues: &[GithubIssue]) {
if issues.is_empty() {
println!("{}", "No issues found.".dimmed());
return;
}
let rows: Vec<Vec<String>> = issues
.iter()
.map(|i| {
vec![
format!("#{}", i.number).bright_white().bold().to_string(),
i.state.clone().bright_cyan().to_string(),
if i.assignees.is_empty() {
"-".into()
} else {
i.assignees.join(", ")
},
truncate_chars(&i.title, 60),
]
})
.collect();
print!(
"{}",
render_table(
&["#", "State", "Assignees", "Title"],
&rows,
TableStyle::Pipe,
"",
)
);
println!("Total: {} issue(s)", issues.len());
}
pub fn print_issue_detail(issue: &GithubIssue) {
println!();
println!(
"{} {}",
format!("#{}", issue.number).bright_white().bold(),
issue.title.bright_cyan()
);
if let Some(url) = &issue.html_url {
println!("{} {}", "url".dimmed(), url.underline());
}
println!("{} {}", "state".dimmed(), issue.state);
println!(
"{} {}",
"assignees".dimmed(),
if issue.assignees.is_empty() {
"-".to_string()
} else {
issue.assignees.join(", ")
}
);
println!(
"{} {}",
"labels".dimmed(),
if issue.labels.is_empty() {
"-".to_string()
} else {
issue.labels.join(", ")
}
);
if let Some(body) = &issue.body {
if !body.trim().is_empty() {
println!();
println!("{}", body.trim());
}
}
println!();
}
pub fn parse_issue_number(raw: &str) -> Result<u64, String> {
let s = raw.trim().trim_start_matches('#');
if let Some(num) = s.rsplit('/').next() {
if let Ok(n) = num.parse::<u64>() {
return Ok(n);
}
}
s.parse::<u64>()
.map_err(|_| format!("Invalid issue number `{raw}`."))
}