Replace hardcoded truncation widths across CLI commands with render::flex_width() calls that adapt to terminal size. Remove server-side truncate_to_chars() in timeline collect/seed stages so full text is preserved through the pipeline — truncation now happens only at the presentation layer where terminal width is known. Affected commands: explain, file-history, list (issues/mrs/notes), me, timeline, who (active/expert/workload).
373 lines
14 KiB
Rust
373 lines
14 KiB
Rust
use rusqlite::Connection;
|
|
|
|
use crate::cli::render::{self, Icons, Theme};
|
|
use crate::core::error::Result;
|
|
use crate::core::time::ms_to_iso;
|
|
|
|
use super::types::*;
|
|
|
|
// ─── Query: Workload Mode ───────────────────────────────────────────────────
|
|
|
|
pub(super) fn query_workload(
|
|
conn: &Connection,
|
|
username: &str,
|
|
project_id: Option<i64>,
|
|
since_ms: Option<i64>,
|
|
limit: usize,
|
|
include_closed: bool,
|
|
) -> Result<WorkloadResult> {
|
|
// Prevent overflow: saturating_add caps at usize::MAX instead of wrapping to 0.
|
|
// The .min() ensures the value fits in i64 for SQLite's LIMIT clause.
|
|
let limit_plus_one = limit.saturating_add(1).min(i64::MAX as usize) as i64;
|
|
|
|
// Query 1: Open issues assigned to user
|
|
let issues_sql = "SELECT i.iid,
|
|
(p.path_with_namespace || '#' || i.iid) AS ref,
|
|
i.title, p.path_with_namespace, i.updated_at
|
|
FROM issues i
|
|
JOIN issue_assignees ia ON ia.issue_id = i.id
|
|
JOIN projects p ON i.project_id = p.id
|
|
WHERE ia.username = ?1
|
|
AND i.state = 'opened'
|
|
AND (?2 IS NULL OR i.project_id = ?2)
|
|
AND (?3 IS NULL OR i.updated_at >= ?3)
|
|
ORDER BY i.updated_at DESC
|
|
LIMIT ?4";
|
|
|
|
let mut stmt = conn.prepare_cached(issues_sql)?;
|
|
let assigned_issues: Vec<WorkloadIssue> = stmt
|
|
.query_map(
|
|
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
|
|row| {
|
|
Ok(WorkloadIssue {
|
|
iid: row.get(0)?,
|
|
ref_: row.get(1)?,
|
|
title: row.get(2)?,
|
|
project_path: row.get(3)?,
|
|
updated_at: row.get(4)?,
|
|
})
|
|
},
|
|
)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// Query 2: Open MRs authored
|
|
let authored_sql = "SELECT m.iid,
|
|
(p.path_with_namespace || '!' || m.iid) AS ref,
|
|
m.title, m.draft, p.path_with_namespace, m.updated_at
|
|
FROM merge_requests m
|
|
JOIN projects p ON m.project_id = p.id
|
|
WHERE m.author_username = ?1
|
|
AND m.state = 'opened'
|
|
AND (?2 IS NULL OR m.project_id = ?2)
|
|
AND (?3 IS NULL OR m.updated_at >= ?3)
|
|
ORDER BY m.updated_at DESC
|
|
LIMIT ?4";
|
|
let mut stmt = conn.prepare_cached(authored_sql)?;
|
|
let authored_mrs: Vec<WorkloadMr> = stmt
|
|
.query_map(
|
|
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
|
|row| {
|
|
Ok(WorkloadMr {
|
|
iid: row.get(0)?,
|
|
ref_: row.get(1)?,
|
|
title: row.get(2)?,
|
|
draft: row.get::<_, i32>(3)? != 0,
|
|
project_path: row.get(4)?,
|
|
author_username: None,
|
|
updated_at: row.get(5)?,
|
|
})
|
|
},
|
|
)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// Query 3: Open MRs where user is reviewer
|
|
let reviewing_sql = "SELECT m.iid,
|
|
(p.path_with_namespace || '!' || m.iid) AS ref,
|
|
m.title, m.draft, p.path_with_namespace,
|
|
m.author_username, m.updated_at
|
|
FROM merge_requests m
|
|
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
|
JOIN projects p ON m.project_id = p.id
|
|
WHERE r.username = ?1
|
|
AND m.state = 'opened'
|
|
AND (?2 IS NULL OR m.project_id = ?2)
|
|
AND (?3 IS NULL OR m.updated_at >= ?3)
|
|
ORDER BY m.updated_at DESC
|
|
LIMIT ?4";
|
|
let mut stmt = conn.prepare_cached(reviewing_sql)?;
|
|
let reviewing_mrs: Vec<WorkloadMr> = stmt
|
|
.query_map(
|
|
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
|
|row| {
|
|
Ok(WorkloadMr {
|
|
iid: row.get(0)?,
|
|
ref_: row.get(1)?,
|
|
title: row.get(2)?,
|
|
draft: row.get::<_, i32>(3)? != 0,
|
|
project_path: row.get(4)?,
|
|
author_username: row.get(5)?,
|
|
updated_at: row.get(6)?,
|
|
})
|
|
},
|
|
)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// Query 4: Unresolved discussions where user participated
|
|
let state_filter = if include_closed {
|
|
""
|
|
} else {
|
|
" AND (i.id IS NULL OR i.state = 'opened')
|
|
AND (m.id IS NULL OR m.state = 'opened')"
|
|
};
|
|
let disc_sql = format!(
|
|
"SELECT d.noteable_type,
|
|
COALESCE(i.iid, m.iid) AS entity_iid,
|
|
(p.path_with_namespace ||
|
|
CASE WHEN d.noteable_type = 'MergeRequest' THEN '!' ELSE '#' END ||
|
|
COALESCE(i.iid, m.iid)) AS ref,
|
|
COALESCE(i.title, m.title) AS entity_title,
|
|
p.path_with_namespace,
|
|
d.last_note_at
|
|
FROM discussions d
|
|
JOIN projects p ON d.project_id = p.id
|
|
LEFT JOIN issues i ON d.issue_id = i.id
|
|
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
|
WHERE d.resolvable = 1 AND d.resolved = 0
|
|
AND EXISTS (
|
|
SELECT 1 FROM notes n
|
|
WHERE n.discussion_id = d.id
|
|
AND n.author_username = ?1
|
|
AND n.is_system = 0
|
|
)
|
|
AND (?2 IS NULL OR d.project_id = ?2)
|
|
AND (?3 IS NULL OR d.last_note_at >= ?3)
|
|
{state_filter}
|
|
ORDER BY d.last_note_at DESC
|
|
LIMIT ?4"
|
|
);
|
|
|
|
let mut stmt = conn.prepare_cached(&disc_sql)?;
|
|
let unresolved_discussions: Vec<WorkloadDiscussion> = stmt
|
|
.query_map(
|
|
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
|
|row| {
|
|
let noteable_type: String = row.get(0)?;
|
|
let entity_type = if noteable_type == "MergeRequest" {
|
|
"MR"
|
|
} else {
|
|
"Issue"
|
|
};
|
|
Ok(WorkloadDiscussion {
|
|
entity_type: entity_type.to_string(),
|
|
entity_iid: row.get(1)?,
|
|
ref_: row.get(2)?,
|
|
entity_title: row.get(3)?,
|
|
project_path: row.get(4)?,
|
|
last_note_at: row.get(5)?,
|
|
})
|
|
},
|
|
)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// Truncation detection
|
|
let assigned_issues_truncated = assigned_issues.len() > limit;
|
|
let authored_mrs_truncated = authored_mrs.len() > limit;
|
|
let reviewing_mrs_truncated = reviewing_mrs.len() > limit;
|
|
let unresolved_discussions_truncated = unresolved_discussions.len() > limit;
|
|
|
|
let assigned_issues: Vec<WorkloadIssue> = assigned_issues.into_iter().take(limit).collect();
|
|
let authored_mrs: Vec<WorkloadMr> = authored_mrs.into_iter().take(limit).collect();
|
|
let reviewing_mrs: Vec<WorkloadMr> = reviewing_mrs.into_iter().take(limit).collect();
|
|
let unresolved_discussions: Vec<WorkloadDiscussion> =
|
|
unresolved_discussions.into_iter().take(limit).collect();
|
|
|
|
Ok(WorkloadResult {
|
|
username: username.to_string(),
|
|
assigned_issues,
|
|
authored_mrs,
|
|
reviewing_mrs,
|
|
unresolved_discussions,
|
|
assigned_issues_truncated,
|
|
authored_mrs_truncated,
|
|
reviewing_mrs_truncated,
|
|
unresolved_discussions_truncated,
|
|
})
|
|
}
|
|
|
|
// ─── Human Renderer: Workload ───────────────────────────────────────────────
|
|
|
|
pub(super) fn print_workload_human(r: &WorkloadResult) {
|
|
println!();
|
|
println!(
|
|
"{}",
|
|
Theme::bold().render(&format!(
|
|
"{} {} -- Workload Summary",
|
|
Icons::user(),
|
|
r.username
|
|
))
|
|
);
|
|
println!("{}", "\u{2500}".repeat(60));
|
|
|
|
if !r.assigned_issues.is_empty() {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
|
|
);
|
|
for item in &r.assigned_issues {
|
|
println!(
|
|
" {} {} {}",
|
|
Theme::info().render(&item.ref_),
|
|
render::truncate(&item.title, render::flex_width(25, 20)),
|
|
Theme::dim().render(&render::format_relative_time(item.updated_at)),
|
|
);
|
|
}
|
|
if r.assigned_issues_truncated {
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
|
);
|
|
}
|
|
}
|
|
|
|
if !r.authored_mrs.is_empty() {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
|
|
);
|
|
for mr in &r.authored_mrs {
|
|
let draft = if mr.draft { " [draft]" } else { "" };
|
|
println!(
|
|
" {} {}{} {}",
|
|
Theme::info().render(&mr.ref_),
|
|
render::truncate(&mr.title, render::flex_width(33, 20)),
|
|
Theme::dim().render(draft),
|
|
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
|
|
);
|
|
}
|
|
if r.authored_mrs_truncated {
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
|
);
|
|
}
|
|
}
|
|
|
|
if !r.reviewing_mrs.is_empty() {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
|
|
);
|
|
for mr in &r.reviewing_mrs {
|
|
let author = mr
|
|
.author_username
|
|
.as_deref()
|
|
.map(|a| format!(" by @{a}"))
|
|
.unwrap_or_default();
|
|
println!(
|
|
" {} {}{} {}",
|
|
Theme::info().render(&mr.ref_),
|
|
render::truncate(&mr.title, render::flex_width(40, 20)),
|
|
Theme::dim().render(&author),
|
|
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
|
|
);
|
|
}
|
|
if r.reviewing_mrs_truncated {
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
|
);
|
|
}
|
|
}
|
|
|
|
if !r.unresolved_discussions.is_empty() {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!(
|
|
"Unresolved Discussions ({})",
|
|
r.unresolved_discussions.len()
|
|
))
|
|
);
|
|
for disc in &r.unresolved_discussions {
|
|
println!(
|
|
" {} {} {} {}",
|
|
Theme::dim().render(&disc.entity_type),
|
|
Theme::info().render(&disc.ref_),
|
|
render::truncate(&disc.entity_title, render::flex_width(30, 20)),
|
|
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
|
|
);
|
|
}
|
|
if r.unresolved_discussions_truncated {
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
|
);
|
|
}
|
|
}
|
|
|
|
if r.assigned_issues.is_empty()
|
|
&& r.authored_mrs.is_empty()
|
|
&& r.reviewing_mrs.is_empty()
|
|
&& r.unresolved_discussions.is_empty()
|
|
{
|
|
println!();
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render("No open work items found for this user.")
|
|
);
|
|
}
|
|
|
|
println!();
|
|
}
|
|
|
|
// ─── JSON Renderer: Workload ────────────────────────────────────────────────
|
|
|
|
pub(super) fn workload_to_json(r: &WorkloadResult) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"username": r.username,
|
|
"assigned_issues": r.assigned_issues.iter().map(|i| serde_json::json!({
|
|
"iid": i.iid,
|
|
"ref": i.ref_,
|
|
"title": i.title,
|
|
"project_path": i.project_path,
|
|
"updated_at": ms_to_iso(i.updated_at),
|
|
})).collect::<Vec<_>>(),
|
|
"authored_mrs": r.authored_mrs.iter().map(|m| serde_json::json!({
|
|
"iid": m.iid,
|
|
"ref": m.ref_,
|
|
"title": m.title,
|
|
"draft": m.draft,
|
|
"project_path": m.project_path,
|
|
"updated_at": ms_to_iso(m.updated_at),
|
|
})).collect::<Vec<_>>(),
|
|
"reviewing_mrs": r.reviewing_mrs.iter().map(|m| serde_json::json!({
|
|
"iid": m.iid,
|
|
"ref": m.ref_,
|
|
"title": m.title,
|
|
"draft": m.draft,
|
|
"project_path": m.project_path,
|
|
"author_username": m.author_username,
|
|
"updated_at": ms_to_iso(m.updated_at),
|
|
})).collect::<Vec<_>>(),
|
|
"unresolved_discussions": r.unresolved_discussions.iter().map(|d| serde_json::json!({
|
|
"entity_type": d.entity_type,
|
|
"entity_iid": d.entity_iid,
|
|
"ref": d.ref_,
|
|
"entity_title": d.entity_title,
|
|
"project_path": d.project_path,
|
|
"last_note_at": ms_to_iso(d.last_note_at),
|
|
})).collect::<Vec<_>>(),
|
|
"summary": {
|
|
"assigned_issue_count": r.assigned_issues.len(),
|
|
"authored_mr_count": r.authored_mrs.len(),
|
|
"reviewing_mr_count": r.reviewing_mrs.len(),
|
|
"unresolved_discussion_count": r.unresolved_discussions.len(),
|
|
},
|
|
"truncation": {
|
|
"assigned_issues_truncated": r.assigned_issues_truncated,
|
|
"authored_mrs_truncated": r.authored_mrs_truncated,
|
|
"reviewing_mrs_truncated": r.reviewing_mrs_truncated,
|
|
"unresolved_discussions_truncated": r.unresolved_discussions_truncated,
|
|
}
|
|
})
|
|
}
|