feat(cli): Add MR support to list/show/count/ingest commands
Extends all data commands to support merge requests alongside issues, with consistent patterns and JSON output for robot mode. List command (gi list mrs): - MR-specific columns: branches, draft status, reviewers - Filters: --state (opened|merged|closed|locked|all), --draft, --no-draft, --reviewer, --target-branch, --source-branch - Discussion count with unresolved indicator (e.g., "5/2!") - JSON output includes full MR metadata Show command (gi show mr <iid>): - MR details with branches, assignees, reviewers, merge status - DiffNote positions showing file:line for code review comments - Full description and discussion bodies (no truncation in JSON) - --json flag for structured output with ISO timestamps Count command (gi count mrs): - MR counting with optional --type filter for discussions/notes - JSON output with breakdown by state Ingest command (gi ingest --type mrs): - Full MR sync with discussion prefetch - Progress output shows MR-specific metrics (diffnotes count) - JSON summary with comprehensive sync statistics All commands respect global --robot mode for auto-JSON output. The pattern "gi list mrs --json | jq '.mrs[] | .iid'" now works for scripted MR processing. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
use console::style;
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
use crate::core::db::create_connection;
|
||||
@@ -9,8 +10,59 @@ use crate::core::error::{GiError, Result};
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
/// Merge request metadata for display.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MrDetail {
|
||||
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: i64,
|
||||
pub updated_at: i64,
|
||||
pub merged_at: Option<i64>,
|
||||
pub closed_at: Option<i64>,
|
||||
pub web_url: Option<String>,
|
||||
pub project_path: String,
|
||||
pub labels: Vec<String>,
|
||||
pub assignees: Vec<String>,
|
||||
pub reviewers: Vec<String>,
|
||||
pub discussions: Vec<MrDiscussionDetail>,
|
||||
}
|
||||
|
||||
/// MR discussion detail for display.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MrDiscussionDetail {
|
||||
pub notes: Vec<MrNoteDetail>,
|
||||
pub individual_note: bool,
|
||||
}
|
||||
|
||||
/// MR note detail for display (includes DiffNote position).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MrNoteDetail {
|
||||
pub author_username: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
pub is_system: bool,
|
||||
pub position: Option<DiffNotePosition>,
|
||||
}
|
||||
|
||||
/// DiffNote position context for display.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiffNotePosition {
|
||||
pub old_path: Option<String>,
|
||||
pub new_path: Option<String>,
|
||||
pub old_line: Option<i64>,
|
||||
pub new_line: Option<i64>,
|
||||
pub position_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Issue metadata for display.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueDetail {
|
||||
pub id: i64,
|
||||
pub iid: i64,
|
||||
@@ -27,14 +79,14 @@ pub struct IssueDetail {
|
||||
}
|
||||
|
||||
/// Discussion detail for display.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiscussionDetail {
|
||||
pub notes: Vec<NoteDetail>,
|
||||
pub individual_note: bool,
|
||||
}
|
||||
|
||||
/// Note detail for display.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteDetail {
|
||||
pub author_username: String,
|
||||
pub body: String,
|
||||
@@ -129,8 +181,7 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
project_path: row.get(9)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
match issues.len() {
|
||||
0 => Err(GiError::NotFound(format!("Issue #{} not found", iid))),
|
||||
@@ -155,10 +206,9 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
|
||||
ORDER BY l.name",
|
||||
)?;
|
||||
|
||||
let labels = stmt
|
||||
let labels: Vec<String> = stmt
|
||||
.query_map([issue_id], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(labels)
|
||||
}
|
||||
@@ -177,8 +227,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
|
||||
let individual: i64 = row.get(1)?;
|
||||
Ok((row.get(0)?, individual == 1))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Then get notes for each discussion
|
||||
let mut note_stmt = conn.prepare(
|
||||
@@ -200,8 +249,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
|
||||
is_system: is_system == 1,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Filter out discussions with only system notes
|
||||
let has_user_notes = notes.iter().any(|n| !n.is_system);
|
||||
@@ -216,6 +264,255 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
|
||||
Ok(discussions)
|
||||
}
|
||||
|
||||
/// Run the show MR command.
|
||||
pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result<MrDetail> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
// Find the MR
|
||||
let mr = find_mr(&conn, iid, project_filter)?;
|
||||
|
||||
// Load labels
|
||||
let labels = get_mr_labels(&conn, mr.id)?;
|
||||
|
||||
// Load assignees
|
||||
let assignees = get_mr_assignees(&conn, mr.id)?;
|
||||
|
||||
// Load reviewers
|
||||
let reviewers = get_mr_reviewers(&conn, mr.id)?;
|
||||
|
||||
// Load discussions with notes
|
||||
let discussions = get_mr_discussions(&conn, mr.id)?;
|
||||
|
||||
Ok(MrDetail {
|
||||
id: mr.id,
|
||||
iid: mr.iid,
|
||||
title: mr.title,
|
||||
description: mr.description,
|
||||
state: mr.state,
|
||||
draft: mr.draft,
|
||||
author_username: mr.author_username,
|
||||
source_branch: mr.source_branch,
|
||||
target_branch: mr.target_branch,
|
||||
created_at: mr.created_at,
|
||||
updated_at: mr.updated_at,
|
||||
merged_at: mr.merged_at,
|
||||
closed_at: mr.closed_at,
|
||||
web_url: mr.web_url,
|
||||
project_path: mr.project_path,
|
||||
labels,
|
||||
assignees,
|
||||
reviewers,
|
||||
discussions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Internal MR row from query.
|
||||
struct MrRow {
|
||||
id: i64,
|
||||
iid: i64,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
state: String,
|
||||
draft: bool,
|
||||
author_username: String,
|
||||
source_branch: String,
|
||||
target_branch: String,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
merged_at: Option<i64>,
|
||||
closed_at: Option<i64>,
|
||||
web_url: Option<String>,
|
||||
project_path: String,
|
||||
}
|
||||
|
||||
/// Find MR by iid, optionally filtered by project.
|
||||
fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> {
|
||||
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
|
||||
Some(project) => (
|
||||
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
|
||||
m.author_username, m.source_branch, m.target_branch,
|
||||
m.created_at, m.updated_at, m.merged_at, m.closed_at,
|
||||
m.web_url, p.path_with_namespace
|
||||
FROM merge_requests m
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.iid = ? AND p.path_with_namespace LIKE ?",
|
||||
vec![Box::new(iid), Box::new(format!("%{}%", project))],
|
||||
),
|
||||
None => (
|
||||
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
|
||||
m.author_username, m.source_branch, m.target_branch,
|
||||
m.created_at, m.updated_at, m.merged_at, m.closed_at,
|
||||
m.web_url, p.path_with_namespace
|
||||
FROM merge_requests m
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.iid = ?",
|
||||
vec![Box::new(iid)],
|
||||
),
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let mrs: Vec<MrRow> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let draft_val: i64 = row.get(5)?;
|
||||
Ok(MrRow {
|
||||
id: row.get(0)?,
|
||||
iid: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
state: row.get(4)?,
|
||||
draft: draft_val == 1,
|
||||
author_username: row.get(6)?,
|
||||
source_branch: row.get(7)?,
|
||||
target_branch: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
merged_at: row.get(11)?,
|
||||
closed_at: row.get(12)?,
|
||||
web_url: row.get(13)?,
|
||||
project_path: row.get(14)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
match mrs.len() {
|
||||
0 => Err(GiError::NotFound(format!("MR !{} not found", iid))),
|
||||
1 => Ok(mrs.into_iter().next().unwrap()),
|
||||
_ => {
|
||||
let projects: Vec<String> = mrs.iter().map(|m| m.project_path.clone()).collect();
|
||||
Err(GiError::Ambiguous(format!(
|
||||
"MR !{} exists in multiple projects: {}. Use --project to specify.",
|
||||
iid,
|
||||
projects.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get labels for an MR.
|
||||
fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT l.name FROM labels l
|
||||
JOIN mr_labels ml ON l.id = ml.label_id
|
||||
WHERE ml.merge_request_id = ?
|
||||
ORDER BY l.name",
|
||||
)?;
|
||||
|
||||
let labels: Vec<String> = stmt
|
||||
.query_map([mr_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
/// Get assignees for an MR.
|
||||
fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username FROM mr_assignees
|
||||
WHERE merge_request_id = ?
|
||||
ORDER BY username",
|
||||
)?;
|
||||
|
||||
let assignees: Vec<String> = stmt
|
||||
.query_map([mr_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(assignees)
|
||||
}
|
||||
|
||||
/// Get reviewers for an MR.
|
||||
fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username FROM mr_reviewers
|
||||
WHERE merge_request_id = ?
|
||||
ORDER BY username",
|
||||
)?;
|
||||
|
||||
let reviewers: Vec<String> = stmt
|
||||
.query_map([mr_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(reviewers)
|
||||
}
|
||||
|
||||
/// Get discussions with notes for an MR.
|
||||
fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionDetail>> {
|
||||
// First get all discussions
|
||||
let mut disc_stmt = conn.prepare(
|
||||
"SELECT id, individual_note FROM discussions
|
||||
WHERE merge_request_id = ?
|
||||
ORDER BY first_note_at",
|
||||
)?;
|
||||
|
||||
let disc_rows: Vec<(i64, bool)> = disc_stmt
|
||||
.query_map([mr_id], |row| {
|
||||
let individual: i64 = row.get(1)?;
|
||||
Ok((row.get(0)?, individual == 1))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Then get notes for each discussion (with DiffNote position fields)
|
||||
let mut note_stmt = conn.prepare(
|
||||
"SELECT author_username, body, created_at, is_system,
|
||||
position_old_path, position_new_path, position_old_line,
|
||||
position_new_line, position_type
|
||||
FROM notes
|
||||
WHERE discussion_id = ?
|
||||
ORDER BY position",
|
||||
)?;
|
||||
|
||||
let mut discussions = Vec::new();
|
||||
for (disc_id, individual_note) in disc_rows {
|
||||
let notes: Vec<MrNoteDetail> = note_stmt
|
||||
.query_map([disc_id], |row| {
|
||||
let is_system: i64 = row.get(3)?;
|
||||
let old_path: Option<String> = row.get(4)?;
|
||||
let new_path: Option<String> = row.get(5)?;
|
||||
let old_line: Option<i64> = row.get(6)?;
|
||||
let new_line: Option<i64> = row.get(7)?;
|
||||
let position_type: Option<String> = row.get(8)?;
|
||||
|
||||
let position = if old_path.is_some()
|
||||
|| new_path.is_some()
|
||||
|| old_line.is_some()
|
||||
|| new_line.is_some()
|
||||
{
|
||||
Some(DiffNotePosition {
|
||||
old_path,
|
||||
new_path,
|
||||
old_line,
|
||||
new_line,
|
||||
position_type,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(MrNoteDetail {
|
||||
author_username: row.get(0)?,
|
||||
body: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
is_system: is_system == 1,
|
||||
position,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Filter out discussions with only system notes
|
||||
let has_user_notes = notes.iter().any(|n| !n.is_system);
|
||||
if has_user_notes || notes.is_empty() {
|
||||
discussions.push(MrDiscussionDetail {
|
||||
notes,
|
||||
individual_note,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(discussions)
|
||||
}
|
||||
|
||||
/// Format date from ms epoch.
|
||||
fn format_date(ms: i64) -> String {
|
||||
let iso = ms_to_iso(ms);
|
||||
@@ -223,12 +520,13 @@ fn format_date(ms: i64) -> String {
|
||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
||||
}
|
||||
|
||||
/// Truncate text with ellipsis.
|
||||
/// Truncate text with ellipsis (character-safe for UTF-8).
|
||||
fn truncate(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
if s.chars().count() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len.saturating_sub(3)])
|
||||
let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +655,347 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print MR detail.
|
||||
pub fn print_show_mr(mr: &MrDetail) {
|
||||
// Header with draft indicator
|
||||
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
|
||||
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", "━".repeat(header.len().min(80)));
|
||||
println!();
|
||||
|
||||
// Metadata
|
||||
println!("Project: {}", style(&mr.project_path).cyan());
|
||||
|
||||
let state_styled = match mr.state.as_str() {
|
||||
"opened" => style(&mr.state).green(),
|
||||
"merged" => style(&mr.state).magenta(),
|
||||
"closed" => style(&mr.state).red(),
|
||||
_ => style(&mr.state).dim(),
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
|
||||
println!(
|
||||
"Branches: {} -> {}",
|
||||
style(&mr.source_branch).cyan(),
|
||||
style(&mr.target_branch).yellow()
|
||||
);
|
||||
|
||||
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));
|
||||
println!("Updated: {}", format_date(mr.updated_at));
|
||||
|
||||
if let Some(merged_at) = mr.merged_at {
|
||||
println!("Merged: {}", format_date(merged_at));
|
||||
}
|
||||
|
||||
if let Some(closed_at) = mr.closed_at {
|
||||
println!("Closed: {}", format_date(closed_at));
|
||||
}
|
||||
|
||||
if mr.labels.is_empty() {
|
||||
println!("Labels: {}", style("(none)").dim());
|
||||
} else {
|
||||
println!("Labels: {}", mr.labels.join(", "));
|
||||
}
|
||||
|
||||
if let Some(url) = &mr.web_url {
|
||||
println!("URL: {}", style(url).dim());
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Description
|
||||
println!("{}", style("Description:").bold());
|
||||
if let Some(desc) = &mr.description {
|
||||
let truncated = truncate(desc, 500);
|
||||
let wrapped = wrap_text(&truncated, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
} else {
|
||||
println!(" {}", style("(no description)").dim());
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Discussions
|
||||
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!("{}", style("Discussions: (none)").dim());
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
||||
);
|
||||
println!();
|
||||
|
||||
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() {
|
||||
// Print DiffNote position context if present
|
||||
if let Some(pos) = &first_note.position {
|
||||
print_diff_position(pos);
|
||||
}
|
||||
|
||||
// First note of discussion (not indented)
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", first_note.author_username)).cyan(),
|
||||
format_date(first_note.created_at)
|
||||
);
|
||||
let wrapped = wrap_text(&truncate(&first_note.body, 300), 72, " ");
|
||||
println!(" {}", wrapped);
|
||||
println!();
|
||||
|
||||
// Replies (indented)
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", reply.author_username)).cyan(),
|
||||
format_date(reply.created_at)
|
||||
);
|
||||
let wrapped = wrap_text(&truncate(&reply.body, 300), 68, " ");
|
||||
println!(" {}", wrapped);
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print DiffNote position context.
|
||||
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!(
|
||||
" {} {}{}",
|
||||
style("📍").dim(),
|
||||
style(file_path).yellow(),
|
||||
style(line_str).dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON Output Structs (with ISO timestamps for machine consumption)
|
||||
// ============================================================================
|
||||
|
||||
/// JSON output for issue detail.
|
||||
#[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 web_url: Option<String>,
|
||||
pub project_path: String,
|
||||
pub labels: Vec<String>,
|
||||
pub discussions: Vec<DiscussionDetailJson>,
|
||||
}
|
||||
|
||||
/// JSON output for discussion detail.
|
||||
#[derive(Serialize)]
|
||||
pub struct DiscussionDetailJson {
|
||||
pub notes: Vec<NoteDetailJson>,
|
||||
pub individual_note: bool,
|
||||
}
|
||||
|
||||
/// JSON output for note detail.
|
||||
#[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),
|
||||
web_url: issue.web_url.clone(),
|
||||
project_path: issue.project_path.clone(),
|
||||
labels: issue.labels.clone(),
|
||||
discussions: issue.discussions.iter().map(|d| d.into()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON output for MR detail.
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// JSON output for MR discussion detail.
|
||||
#[derive(Serialize)]
|
||||
pub struct MrDiscussionDetailJson {
|
||||
pub notes: Vec<MrNoteDetailJson>,
|
||||
pub individual_note: bool,
|
||||
}
|
||||
|
||||
/// JSON output for MR note detail.
|
||||
#[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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print issue detail as JSON.
|
||||
pub fn print_show_issue_json(issue: &IssueDetail) {
|
||||
let json_result = IssueDetailJson::from(issue);
|
||||
match serde_json::to_string_pretty(&json_result) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print MR detail as JSON.
|
||||
pub fn print_show_mr_json(mr: &MrDetail) {
|
||||
let json_result = MrDetailJson::from(mr);
|
||||
match serde_json::to_string_pretty(&json_result) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user