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:
Taylor Eernisse
2026-01-29 08:41:56 -05:00
parent d3a05cfb87
commit 753ff46bb4
2 changed files with 36 additions and 18 deletions

View File

@@ -232,8 +232,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project {
where_clauses.push("p.path_with_namespace LIKE ?");
params.push(Box::new(format!("%{project}%")));
// Exact match or suffix match after '/' to avoid partial matches
// 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
@@ -337,11 +340,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
i.updated_at,
i.web_url,
p.path_with_namespace,
(SELECT GROUP_CONCAT(l.name, ',')
(SELECT GROUP_CONCAT(l.name, X'1F')
FROM issue_labels il
JOIN labels l ON il.label_id = l.id
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
WHERE ia.issue_id = i.id) AS assignees_csv,
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| {
let labels_csv: Option<String> = row.get(8)?;
let labels = labels_csv
.map(|s| s.split(',').map(String::from).collect())
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
let assignees_csv: Option<String> = row.get(9)?;
let assignees = assignees_csv
.map(|s| s.split(',').map(String::from).collect())
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
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();
if let Some(project) = filters.project {
where_clauses.push("p.path_with_namespace LIKE ?");
params.push(Box::new(format!("%{project}%")));
// Exact match or suffix match after '/' to avoid partial matches
// 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
@@ -536,14 +542,14 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
m.updated_at,
m.web_url,
p.path_with_namespace,
(SELECT GROUP_CONCAT(l.name, ',')
(SELECT GROUP_CONCAT(l.name, X'1F')
FROM mr_labels ml
JOIN labels l ON ml.label_id = l.id
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
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
WHERE mr.merge_request_id = m.id) AS reviewers_csv,
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| {
let labels_csv: Option<String> = row.get(11)?;
let labels = labels_csv
.map(|s| s.split(',').map(String::from).collect())
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
let assignees_csv: Option<String> = row.get(12)?;
let assignees = assignees_csv
.map(|s| s.split(',').map(String::from).collect())
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
let reviewers_csv: Option<String> = row.get(13)?;
let reviewers = reviewers_csv
.map(|s| s.split(',').map(String::from).collect())
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
let draft_int: i64 = row.get(3)?;
@@ -615,6 +621,10 @@ fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
if diff < 0 {
return "in the future".to_string();
}
match diff {
d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000),

View File

@@ -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
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE i.iid = ? AND p.path_with_namespace LIKE ?",
vec![Box::new(iid), Box::new(format!("%{}%", project))],
WHERE i.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)",
vec![
Box::new(iid),
Box::new(project.to_string()),
Box::new(format!("%/{}", project)),
],
),
None => (
"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
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))],
WHERE m.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)",
vec![
Box::new(iid),
Box::new(project.to_string()),
Box::new(format!("%/{}", project)),
],
),
None => (
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,