fix(cli): Correct project filtering and GROUP_CONCAT delimiter
Two SQL correctness issues fixed: 1. Project filter used LIKE '%term%' which caused partial matches (e.g. filtering for "foo" matched "group/foobar"). Now uses exact match OR suffix match after '/' so "foo" matches "group/foo" but not "group/foobar". 2. GROUP_CONCAT used comma as delimiter for labels and assignees, which broke parsing when label names themselves contained commas. Switched to ASCII unit separator (0x1F) which cannot appear in GitLab entity names. Also adds a guard for negative time deltas in format_relative_time to handle clock skew gracefully instead of panicking. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -232,8 +232,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
|||||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
if let Some(project) = filters.project {
|
if let Some(project) = filters.project {
|
||||||
where_clauses.push("p.path_with_namespace LIKE ?");
|
// Exact match or suffix match after '/' to avoid partial matches
|
||||||
params.push(Box::new(format!("%{project}%")));
|
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
|
||||||
|
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
|
||||||
|
params.push(Box::new(project.to_string()));
|
||||||
|
params.push(Box::new(format!("%/{project}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(state) = filters.state
|
if let Some(state) = filters.state
|
||||||
@@ -337,11 +340,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
|||||||
i.updated_at,
|
i.updated_at,
|
||||||
i.web_url,
|
i.web_url,
|
||||||
p.path_with_namespace,
|
p.path_with_namespace,
|
||||||
(SELECT GROUP_CONCAT(l.name, ',')
|
(SELECT GROUP_CONCAT(l.name, X'1F')
|
||||||
FROM issue_labels il
|
FROM issue_labels il
|
||||||
JOIN labels l ON il.label_id = l.id
|
JOIN labels l ON il.label_id = l.id
|
||||||
WHERE il.issue_id = i.id) AS labels_csv,
|
WHERE il.issue_id = i.id) AS labels_csv,
|
||||||
(SELECT GROUP_CONCAT(ia.username, ',')
|
(SELECT GROUP_CONCAT(ia.username, X'1F')
|
||||||
FROM issue_assignees ia
|
FROM issue_assignees ia
|
||||||
WHERE ia.issue_id = i.id) AS assignees_csv,
|
WHERE ia.issue_id = i.id) AS assignees_csv,
|
||||||
COALESCE(d.total, 0) AS discussion_count,
|
COALESCE(d.total, 0) AS discussion_count,
|
||||||
@@ -369,12 +372,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
|||||||
.query_map(param_refs.as_slice(), |row| {
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
let labels_csv: Option<String> = row.get(8)?;
|
let labels_csv: Option<String> = row.get(8)?;
|
||||||
let labels = labels_csv
|
let labels = labels_csv
|
||||||
.map(|s| s.split(',').map(String::from).collect())
|
.map(|s| s.split('\x1F').map(String::from).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let assignees_csv: Option<String> = row.get(9)?;
|
let assignees_csv: Option<String> = row.get(9)?;
|
||||||
let assignees = assignees_csv
|
let assignees = assignees_csv
|
||||||
.map(|s| s.split(',').map(String::from).collect())
|
.map(|s| s.split('\x1F').map(String::from).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(IssueListRow {
|
Ok(IssueListRow {
|
||||||
@@ -416,8 +419,11 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
|||||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
if let Some(project) = filters.project {
|
if let Some(project) = filters.project {
|
||||||
where_clauses.push("p.path_with_namespace LIKE ?");
|
// Exact match or suffix match after '/' to avoid partial matches
|
||||||
params.push(Box::new(format!("%{project}%")));
|
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
|
||||||
|
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
|
||||||
|
params.push(Box::new(project.to_string()));
|
||||||
|
params.push(Box::new(format!("%/{project}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(state) = filters.state
|
if let Some(state) = filters.state
|
||||||
@@ -536,14 +542,14 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
|||||||
m.updated_at,
|
m.updated_at,
|
||||||
m.web_url,
|
m.web_url,
|
||||||
p.path_with_namespace,
|
p.path_with_namespace,
|
||||||
(SELECT GROUP_CONCAT(l.name, ',')
|
(SELECT GROUP_CONCAT(l.name, X'1F')
|
||||||
FROM mr_labels ml
|
FROM mr_labels ml
|
||||||
JOIN labels l ON ml.label_id = l.id
|
JOIN labels l ON ml.label_id = l.id
|
||||||
WHERE ml.merge_request_id = m.id) AS labels_csv,
|
WHERE ml.merge_request_id = m.id) AS labels_csv,
|
||||||
(SELECT GROUP_CONCAT(ma.username, ',')
|
(SELECT GROUP_CONCAT(ma.username, X'1F')
|
||||||
FROM mr_assignees ma
|
FROM mr_assignees ma
|
||||||
WHERE ma.merge_request_id = m.id) AS assignees_csv,
|
WHERE ma.merge_request_id = m.id) AS assignees_csv,
|
||||||
(SELECT GROUP_CONCAT(mr.username, ',')
|
(SELECT GROUP_CONCAT(mr.username, X'1F')
|
||||||
FROM mr_reviewers mr
|
FROM mr_reviewers mr
|
||||||
WHERE mr.merge_request_id = m.id) AS reviewers_csv,
|
WHERE mr.merge_request_id = m.id) AS reviewers_csv,
|
||||||
COALESCE(d.total, 0) AS discussion_count,
|
COALESCE(d.total, 0) AS discussion_count,
|
||||||
@@ -571,17 +577,17 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
|||||||
.query_map(param_refs.as_slice(), |row| {
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
let labels_csv: Option<String> = row.get(11)?;
|
let labels_csv: Option<String> = row.get(11)?;
|
||||||
let labels = labels_csv
|
let labels = labels_csv
|
||||||
.map(|s| s.split(',').map(String::from).collect())
|
.map(|s| s.split('\x1F').map(String::from).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let assignees_csv: Option<String> = row.get(12)?;
|
let assignees_csv: Option<String> = row.get(12)?;
|
||||||
let assignees = assignees_csv
|
let assignees = assignees_csv
|
||||||
.map(|s| s.split(',').map(String::from).collect())
|
.map(|s| s.split('\x1F').map(String::from).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let reviewers_csv: Option<String> = row.get(13)?;
|
let reviewers_csv: Option<String> = row.get(13)?;
|
||||||
let reviewers = reviewers_csv
|
let reviewers = reviewers_csv
|
||||||
.map(|s| s.split(',').map(String::from).collect())
|
.map(|s| s.split('\x1F').map(String::from).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let draft_int: i64 = row.get(3)?;
|
let draft_int: i64 = row.get(3)?;
|
||||||
@@ -615,6 +621,10 @@ fn format_relative_time(ms_epoch: i64) -> String {
|
|||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let diff = now - ms_epoch;
|
let diff = now - ms_epoch;
|
||||||
|
|
||||||
|
if diff < 0 {
|
||||||
|
return "in the future".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
match diff {
|
match diff {
|
||||||
d if d < 60_000 => "just now".to_string(),
|
d if d < 60_000 => "just now".to_string(),
|
||||||
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
||||||
|
|||||||
@@ -150,8 +150,12 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
|||||||
i.created_at, i.updated_at, i.web_url, p.path_with_namespace
|
i.created_at, i.updated_at, i.web_url, p.path_with_namespace
|
||||||
FROM issues i
|
FROM issues i
|
||||||
JOIN projects p ON i.project_id = p.id
|
JOIN projects p ON i.project_id = p.id
|
||||||
WHERE i.iid = ? AND p.path_with_namespace LIKE ?",
|
WHERE i.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)",
|
||||||
vec![Box::new(iid), Box::new(format!("%{}%", project))],
|
vec![
|
||||||
|
Box::new(iid),
|
||||||
|
Box::new(project.to_string()),
|
||||||
|
Box::new(format!("%/{}", project)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
None => (
|
None => (
|
||||||
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
||||||
@@ -336,8 +340,12 @@ fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<
|
|||||||
m.web_url, p.path_with_namespace
|
m.web_url, p.path_with_namespace
|
||||||
FROM merge_requests m
|
FROM merge_requests m
|
||||||
JOIN projects p ON m.project_id = p.id
|
JOIN projects p ON m.project_id = p.id
|
||||||
WHERE m.iid = ? AND p.path_with_namespace LIKE ?",
|
WHERE m.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)",
|
||||||
vec![Box::new(iid), Box::new(format!("%{}%", project))],
|
vec![
|
||||||
|
Box::new(iid),
|
||||||
|
Box::new(project.to_string()),
|
||||||
|
Box::new(format!("%/{}", project)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
None => (
|
None => (
|
||||||
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
|
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
|
||||||
|
|||||||
Reference in New Issue
Block a user