581 lines
17 KiB
Rust
581 lines
17 KiB
Rust
fn format_date(ms: i64) -> String {
|
|
render::format_date(ms)
|
|
}
|
|
|
|
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
|
render::wrap_indent(text, width, indent)
|
|
}
|
|
|
|
pub fn print_show_issue(issue: &IssueDetail) {
|
|
// Title line
|
|
println!(
|
|
" Issue #{}: {}",
|
|
issue.iid,
|
|
Theme::bold().render(&issue.title),
|
|
);
|
|
|
|
// Details section
|
|
println!("{}", render::section_divider("Details"));
|
|
|
|
println!(
|
|
" Ref {}",
|
|
Theme::muted().render(&issue.references_full)
|
|
);
|
|
println!(
|
|
" Project {}",
|
|
Theme::info().render(&issue.project_path)
|
|
);
|
|
|
|
let (icon, state_style) = if issue.state == "opened" {
|
|
(Icons::issue_opened(), Theme::success())
|
|
} else {
|
|
(Icons::issue_closed(), Theme::dim())
|
|
};
|
|
println!(
|
|
" State {}",
|
|
state_style.render(&format!("{icon} {}", issue.state))
|
|
);
|
|
|
|
if let Some(status) = &issue.status_name {
|
|
println!(
|
|
" Status {}",
|
|
render::style_with_hex(status, issue.status_color.as_deref())
|
|
);
|
|
}
|
|
|
|
if issue.confidential {
|
|
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
|
}
|
|
|
|
println!(" Author @{}", issue.author_username);
|
|
|
|
if !issue.assignees.is_empty() {
|
|
let label = if issue.assignees.len() > 1 {
|
|
"Assignees"
|
|
} else {
|
|
"Assignee"
|
|
};
|
|
println!(
|
|
" {}{} {}",
|
|
label,
|
|
" ".repeat(12 - label.len()),
|
|
issue
|
|
.assignees
|
|
.iter()
|
|
.map(|a| format!("@{a}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
println!(
|
|
" Created {} ({})",
|
|
format_date(issue.created_at),
|
|
render::format_relative_time_compact(issue.created_at),
|
|
);
|
|
println!(
|
|
" Updated {} ({})",
|
|
format_date(issue.updated_at),
|
|
render::format_relative_time_compact(issue.updated_at),
|
|
);
|
|
|
|
if let Some(closed_at) = &issue.closed_at {
|
|
println!(" Closed {closed_at}");
|
|
}
|
|
|
|
if let Some(due) = &issue.due_date {
|
|
println!(" Due {due}");
|
|
}
|
|
|
|
if let Some(ms) = &issue.milestone {
|
|
println!(" Milestone {ms}");
|
|
}
|
|
|
|
if !issue.labels.is_empty() {
|
|
println!(
|
|
" Labels {}",
|
|
render::format_labels_bare(&issue.labels, issue.labels.len())
|
|
);
|
|
}
|
|
|
|
if let Some(url) = &issue.web_url {
|
|
println!(" URL {}", Theme::muted().render(url));
|
|
}
|
|
|
|
// Development section
|
|
if !issue.closing_merge_requests.is_empty() {
|
|
println!("{}", render::section_divider("Development"));
|
|
for mr in &issue.closing_merge_requests {
|
|
let (mr_icon, mr_style) = match mr.state.as_str() {
|
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
|
_ => (Icons::mr_opened(), Theme::dim()),
|
|
};
|
|
println!(
|
|
" {} !{} {} {}",
|
|
mr_style.render(mr_icon),
|
|
mr.iid,
|
|
mr.title,
|
|
mr_style.render(&mr.state),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Description section
|
|
println!("{}", render::section_divider("Description"));
|
|
if let Some(desc) = &issue.description {
|
|
let wrapped = wrap_text(desc, 72, " ");
|
|
println!(" {wrapped}");
|
|
} else {
|
|
println!(" {}", Theme::muted().render("(no description)"));
|
|
}
|
|
|
|
// Discussions section
|
|
let user_discussions: Vec<&DiscussionDetail> = issue
|
|
.discussions
|
|
.iter()
|
|
.filter(|d| d.notes.iter().any(|n| !n.is_system))
|
|
.collect();
|
|
|
|
if user_discussions.is_empty() {
|
|
println!("\n {}", Theme::muted().render("No discussions"));
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
|
);
|
|
|
|
for discussion in user_discussions {
|
|
let user_notes: Vec<&NoteDetail> =
|
|
discussion.notes.iter().filter(|n| !n.is_system).collect();
|
|
|
|
if let Some(first_note) = user_notes.first() {
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", first_note.author_username)),
|
|
format_date(first_note.created_at),
|
|
);
|
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
|
|
for reply in user_notes.iter().skip(1) {
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
|
format_date(reply.created_at),
|
|
);
|
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn print_show_mr(mr: &MrDetail) {
|
|
// Title line
|
|
let draft_prefix = if mr.draft {
|
|
format!("{} ", Icons::mr_draft())
|
|
} else {
|
|
String::new()
|
|
};
|
|
println!(
|
|
" MR !{}: {}{}",
|
|
mr.iid,
|
|
draft_prefix,
|
|
Theme::bold().render(&mr.title),
|
|
);
|
|
|
|
// Details section
|
|
println!("{}", render::section_divider("Details"));
|
|
|
|
println!(" Project {}", Theme::info().render(&mr.project_path));
|
|
|
|
let (icon, state_style) = match mr.state.as_str() {
|
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
|
_ => (Icons::mr_opened(), Theme::dim()),
|
|
};
|
|
println!(
|
|
" State {}",
|
|
state_style.render(&format!("{icon} {}", mr.state))
|
|
);
|
|
|
|
println!(
|
|
" Branches {} -> {}",
|
|
Theme::info().render(&mr.source_branch),
|
|
Theme::warning().render(&mr.target_branch)
|
|
);
|
|
|
|
println!(" Author @{}", mr.author_username);
|
|
|
|
if !mr.assignees.is_empty() {
|
|
println!(
|
|
" Assignees {}",
|
|
mr.assignees
|
|
.iter()
|
|
.map(|a| format!("@{a}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
if !mr.reviewers.is_empty() {
|
|
println!(
|
|
" Reviewers {}",
|
|
mr.reviewers
|
|
.iter()
|
|
.map(|r| format!("@{r}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
println!(
|
|
" Created {} ({})",
|
|
format_date(mr.created_at),
|
|
render::format_relative_time_compact(mr.created_at),
|
|
);
|
|
println!(
|
|
" Updated {} ({})",
|
|
format_date(mr.updated_at),
|
|
render::format_relative_time_compact(mr.updated_at),
|
|
);
|
|
|
|
if let Some(merged_at) = mr.merged_at {
|
|
println!(
|
|
" Merged {} ({})",
|
|
format_date(merged_at),
|
|
render::format_relative_time_compact(merged_at),
|
|
);
|
|
}
|
|
|
|
if let Some(closed_at) = mr.closed_at {
|
|
println!(
|
|
" Closed {} ({})",
|
|
format_date(closed_at),
|
|
render::format_relative_time_compact(closed_at),
|
|
);
|
|
}
|
|
|
|
if !mr.labels.is_empty() {
|
|
println!(
|
|
" Labels {}",
|
|
render::format_labels_bare(&mr.labels, mr.labels.len())
|
|
);
|
|
}
|
|
|
|
if let Some(url) = &mr.web_url {
|
|
println!(" URL {}", Theme::muted().render(url));
|
|
}
|
|
|
|
// Description section
|
|
println!("{}", render::section_divider("Description"));
|
|
if let Some(desc) = &mr.description {
|
|
let wrapped = wrap_text(desc, 72, " ");
|
|
println!(" {wrapped}");
|
|
} else {
|
|
println!(" {}", Theme::muted().render("(no description)"));
|
|
}
|
|
|
|
// Discussions section
|
|
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
|
.discussions
|
|
.iter()
|
|
.filter(|d| d.notes.iter().any(|n| !n.is_system))
|
|
.collect();
|
|
|
|
if user_discussions.is_empty() {
|
|
println!("\n {}", Theme::muted().render("No discussions"));
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
|
);
|
|
|
|
for discussion in user_discussions {
|
|
let user_notes: Vec<&MrNoteDetail> =
|
|
discussion.notes.iter().filter(|n| !n.is_system).collect();
|
|
|
|
if let Some(first_note) = user_notes.first() {
|
|
if let Some(pos) = &first_note.position {
|
|
print_diff_position(pos);
|
|
}
|
|
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", first_note.author_username)),
|
|
format_date(first_note.created_at),
|
|
);
|
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
|
|
for reply in user_notes.iter().skip(1) {
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
|
format_date(reply.created_at),
|
|
);
|
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_diff_position(pos: &DiffNotePosition) {
|
|
let file = pos.new_path.as_ref().or(pos.old_path.as_ref());
|
|
|
|
if let Some(file_path) = file {
|
|
let line_str = match (pos.old_line, pos.new_line) {
|
|
(Some(old), Some(new)) if old == new => format!(":{}", new),
|
|
(Some(old), Some(new)) => format!(":{}→{}", old, new),
|
|
(None, Some(new)) => format!(":+{}", new),
|
|
(Some(old), None) => format!(":-{}", old),
|
|
(None, None) => String::new(),
|
|
};
|
|
|
|
println!(
|
|
" {} {}{}",
|
|
Theme::dim().render("\u{1f4cd}"),
|
|
Theme::warning().render(file_path),
|
|
Theme::dim().render(&line_str)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct IssueDetailJson {
|
|
pub id: i64,
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub state: String,
|
|
pub author_username: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub closed_at: Option<String>,
|
|
pub confidential: bool,
|
|
pub web_url: Option<String>,
|
|
pub project_path: String,
|
|
pub references_full: String,
|
|
pub labels: Vec<String>,
|
|
pub assignees: Vec<String>,
|
|
pub due_date: Option<String>,
|
|
pub milestone: Option<String>,
|
|
pub user_notes_count: i64,
|
|
pub merge_requests_count: usize,
|
|
pub closing_merge_requests: Vec<ClosingMrRefJson>,
|
|
pub discussions: Vec<DiscussionDetailJson>,
|
|
pub status_name: Option<String>,
|
|
#[serde(skip_serializing)]
|
|
pub status_category: Option<String>,
|
|
pub status_color: Option<String>,
|
|
pub status_icon_name: Option<String>,
|
|
pub status_synced_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ClosingMrRefJson {
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub state: String,
|
|
pub web_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct DiscussionDetailJson {
|
|
pub notes: Vec<NoteDetailJson>,
|
|
pub individual_note: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct NoteDetailJson {
|
|
pub author_username: String,
|
|
pub body: String,
|
|
pub created_at: String,
|
|
pub is_system: bool,
|
|
}
|
|
|
|
impl From<&IssueDetail> for IssueDetailJson {
|
|
fn from(issue: &IssueDetail) -> Self {
|
|
Self {
|
|
id: issue.id,
|
|
iid: issue.iid,
|
|
title: issue.title.clone(),
|
|
description: issue.description.clone(),
|
|
state: issue.state.clone(),
|
|
author_username: issue.author_username.clone(),
|
|
created_at: ms_to_iso(issue.created_at),
|
|
updated_at: ms_to_iso(issue.updated_at),
|
|
closed_at: issue.closed_at.clone(),
|
|
confidential: issue.confidential,
|
|
web_url: issue.web_url.clone(),
|
|
project_path: issue.project_path.clone(),
|
|
references_full: issue.references_full.clone(),
|
|
labels: issue.labels.clone(),
|
|
assignees: issue.assignees.clone(),
|
|
due_date: issue.due_date.clone(),
|
|
milestone: issue.milestone.clone(),
|
|
user_notes_count: issue.user_notes_count,
|
|
merge_requests_count: issue.merge_requests_count,
|
|
closing_merge_requests: issue
|
|
.closing_merge_requests
|
|
.iter()
|
|
.map(|mr| ClosingMrRefJson {
|
|
iid: mr.iid,
|
|
title: mr.title.clone(),
|
|
state: mr.state.clone(),
|
|
web_url: mr.web_url.clone(),
|
|
})
|
|
.collect(),
|
|
discussions: issue.discussions.iter().map(|d| d.into()).collect(),
|
|
status_name: issue.status_name.clone(),
|
|
status_category: issue.status_category.clone(),
|
|
status_color: issue.status_color.clone(),
|
|
status_icon_name: issue.status_icon_name.clone(),
|
|
status_synced_at: issue.status_synced_at.map(ms_to_iso),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&DiscussionDetail> for DiscussionDetailJson {
|
|
fn from(disc: &DiscussionDetail) -> Self {
|
|
Self {
|
|
notes: disc.notes.iter().map(|n| n.into()).collect(),
|
|
individual_note: disc.individual_note,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&NoteDetail> for NoteDetailJson {
|
|
fn from(note: &NoteDetail) -> Self {
|
|
Self {
|
|
author_username: note.author_username.clone(),
|
|
body: note.body.clone(),
|
|
created_at: ms_to_iso(note.created_at),
|
|
is_system: note.is_system,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrDetailJson {
|
|
pub id: i64,
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub state: String,
|
|
pub draft: bool,
|
|
pub author_username: String,
|
|
pub source_branch: String,
|
|
pub target_branch: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub merged_at: Option<String>,
|
|
pub closed_at: Option<String>,
|
|
pub web_url: Option<String>,
|
|
pub project_path: String,
|
|
pub labels: Vec<String>,
|
|
pub assignees: Vec<String>,
|
|
pub reviewers: Vec<String>,
|
|
pub discussions: Vec<MrDiscussionDetailJson>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrDiscussionDetailJson {
|
|
pub notes: Vec<MrNoteDetailJson>,
|
|
pub individual_note: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrNoteDetailJson {
|
|
pub author_username: String,
|
|
pub body: String,
|
|
pub created_at: String,
|
|
pub is_system: bool,
|
|
pub position: Option<DiffNotePosition>,
|
|
}
|
|
|
|
impl From<&MrDetail> for MrDetailJson {
|
|
fn from(mr: &MrDetail) -> Self {
|
|
Self {
|
|
id: mr.id,
|
|
iid: mr.iid,
|
|
title: mr.title.clone(),
|
|
description: mr.description.clone(),
|
|
state: mr.state.clone(),
|
|
draft: mr.draft,
|
|
author_username: mr.author_username.clone(),
|
|
source_branch: mr.source_branch.clone(),
|
|
target_branch: mr.target_branch.clone(),
|
|
created_at: ms_to_iso(mr.created_at),
|
|
updated_at: ms_to_iso(mr.updated_at),
|
|
merged_at: mr.merged_at.map(ms_to_iso),
|
|
closed_at: mr.closed_at.map(ms_to_iso),
|
|
web_url: mr.web_url.clone(),
|
|
project_path: mr.project_path.clone(),
|
|
labels: mr.labels.clone(),
|
|
assignees: mr.assignees.clone(),
|
|
reviewers: mr.reviewers.clone(),
|
|
discussions: mr.discussions.iter().map(|d| d.into()).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&MrDiscussionDetail> for MrDiscussionDetailJson {
|
|
fn from(disc: &MrDiscussionDetail) -> Self {
|
|
Self {
|
|
notes: disc.notes.iter().map(|n| n.into()).collect(),
|
|
individual_note: disc.individual_note,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&MrNoteDetail> for MrNoteDetailJson {
|
|
fn from(note: &MrNoteDetail) -> Self {
|
|
Self {
|
|
author_username: note.author_username.clone(),
|
|
body: note.body.clone(),
|
|
created_at: ms_to_iso(note.created_at),
|
|
is_system: note.is_system,
|
|
position: note.position.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
|
|
let json_result = IssueDetailJson::from(issue);
|
|
let meta = RobotMeta { elapsed_ms };
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": json_result,
|
|
"meta": meta,
|
|
});
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|
|
|
|
pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) {
|
|
let json_result = MrDetailJson::from(mr);
|
|
let meta = RobotMeta { elapsed_ms };
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": json_result,
|
|
"meta": meta,
|
|
});
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|