Files
gitlore/src/cli/commands/show/mr.rs
teernisse cab8c540da fix(show): include gitlab_id on notes in issue/MR detail views
The show command's NoteDetail and MrNoteDetail structs were missing
gitlab_id, making individual notes unaddressable in robot mode output.
This was inconsistent with the notes list command which already exposed
gitlab_id. Without an identifier, agents consuming show output could
not construct GitLab web URLs or reference specific notes for follow-up
operations via glab.

Added gitlab_id to:
- NoteDetail / NoteDetailJson (issue discussions)
- MrNoteDetail / MrNoteDetailJson (MR discussions)
- Both SQL queries (shifted column indices accordingly)
- Both From<&T> conversion impls

Deliberately scoped to show command only — me/timeline/trace structs
were evaluated and intentionally left unchanged because they serve
different consumption patterns where note-level identity is not needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:27:33 -04:00

286 lines
8.9 KiB
Rust

#[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>,
}
#[derive(Debug, Serialize)]
pub struct MrDiscussionDetail {
pub notes: Vec<MrNoteDetail>,
pub individual_note: bool,
}
#[derive(Debug, Serialize)]
pub struct MrNoteDetail {
pub gitlab_id: i64,
pub author_username: String,
pub body: String,
pub created_at: i64,
pub is_system: bool,
pub position: Option<DiffNotePosition>,
}
#[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>,
}
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)?;
let mr = find_mr(&conn, iid, project_filter)?;
let labels = get_mr_labels(&conn, mr.id)?;
let assignees = get_mr_assignees(&conn, mr.id)?;
let reviewers = get_mr_reviewers(&conn, mr.id)?;
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,
})
}
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,
}
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) => {
let project_id = resolve_project(conn, 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 m.project_id = ?",
vec![Box::new(iid), Box::new(project_id)],
)
}
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(LoreError::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(LoreError::Ambiguous(format!(
"MR !{} exists in multiple projects: {}. Use --project to specify.",
iid,
projects.join(", ")
)))
}
}
}
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)
}
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)
}
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)
}
fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionDetail>> {
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<_>, _>>()?;
let mut note_stmt = conn.prepare(
"SELECT gitlab_id, 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(4)?;
let old_path: Option<String> = row.get(5)?;
let new_path: Option<String> = row.get(6)?;
let old_line: Option<i64> = row.get(7)?;
let new_line: Option<i64> = row.get(8)?;
let position_type: Option<String> = row.get(9)?;
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 {
gitlab_id: row.get(0)?,
author_username: row.get(1)?,
body: row.get(2)?,
created_at: row.get(3)?,
is_system: is_system == 1,
position,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
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)
}