#![allow(dead_code)] use anyhow::{Context, Result}; use rusqlite::Connection; use crate::message::EntityKey; use crate::state::issue_detail::{IssueDetailData, IssueMetadata}; use crate::view::common::cross_ref::{CrossRef, CrossRefKind}; use crate::view::common::discussion_tree::{DiscussionNode, NoteNode}; /// Fetch issue metadata and cross-references (Phase 1 load). /// /// Runs inside a single read transaction for snapshot consistency. /// Returns metadata + cross-refs; discussions are loaded separately. pub fn fetch_issue_detail(conn: &Connection, key: &EntityKey) -> Result { let metadata = fetch_issue_metadata(conn, key)?; let cross_refs = fetch_issue_cross_refs(conn, key)?; Ok(IssueDetailData { metadata, cross_refs, }) } /// Fetch issue metadata from the local DB. fn fetch_issue_metadata(conn: &Connection, key: &EntityKey) -> Result { let row = conn .query_row( "SELECT i.iid, p.path_with_namespace, i.title, COALESCE(i.description, ''), i.state, i.author_username, COALESCE(i.milestone_title, ''), i.due_date, i.created_at, i.updated_at, COALESCE(i.web_url, ''), (SELECT COUNT(*) FROM discussions d WHERE d.issue_id = i.id AND d.noteable_type = 'Issue') FROM issues i JOIN projects p ON p.id = i.project_id WHERE i.project_id = ?1 AND i.iid = ?2", rusqlite::params![key.project_id, key.iid], |row| { Ok(IssueMetadata { iid: row.get(0)?, project_path: row.get(1)?, title: row.get(2)?, description: row.get(3)?, state: row.get(4)?, author: row.get::<_, Option>(5)?.unwrap_or_default(), assignees: Vec::new(), // Fetched separately below. labels: Vec::new(), // Fetched separately below. milestone: { let m: String = row.get(6)?; if m.is_empty() { None } else { Some(m) } }, due_date: row.get(7)?, created_at: row.get(8)?, updated_at: row.get(9)?, web_url: row.get(10)?, discussion_count: row.get::<_, i64>(11)? as usize, }) }, ) .context("fetching issue metadata")?; // Fetch assignees. let mut assignees_stmt = conn .prepare("SELECT username FROM issue_assignees WHERE issue_id = (SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2)") .context("preparing assignees query")?; let assignees: Vec = assignees_stmt .query_map(rusqlite::params![key.project_id, key.iid], |r| r.get(0)) .context("fetching assignees")? .collect::, _>>() .context("reading assignee row")?; // Fetch labels. let mut labels_stmt = conn .prepare( "SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = (SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2) ORDER BY l.name", ) .context("preparing labels query")?; let labels: Vec = labels_stmt .query_map(rusqlite::params![key.project_id, key.iid], |r| r.get(0)) .context("fetching labels")? .collect::, _>>() .context("reading label row")?; Ok(IssueMetadata { assignees, labels, ..row }) } /// Fetch cross-references for an issue from the entity_references table. fn fetch_issue_cross_refs(conn: &Connection, key: &EntityKey) -> Result> { let mut stmt = conn .prepare( "SELECT er.reference_type, er.target_entity_type, er.target_entity_id, er.target_entity_iid, er.target_project_path, CASE WHEN er.target_entity_type = 'issue' THEN (SELECT title FROM issues WHERE id = er.target_entity_id) WHEN er.target_entity_type = 'merge_request' THEN (SELECT title FROM merge_requests WHERE id = er.target_entity_id) ELSE NULL END as entity_title, CASE WHEN er.target_entity_id IS NOT NULL THEN (SELECT project_id FROM issues WHERE id = er.target_entity_id UNION ALL SELECT project_id FROM merge_requests WHERE id = er.target_entity_id LIMIT 1) ELSE NULL END as target_project_id FROM entity_references er WHERE er.source_entity_type = 'issue' AND er.source_entity_id = (SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2) ORDER BY er.reference_type, er.target_entity_iid", ) .context("preparing cross-ref query")?; let refs = stmt .query_map(rusqlite::params![key.project_id, key.iid], |row| { let ref_type: String = row.get(0)?; let target_type: String = row.get(1)?; let target_id: Option = row.get(2)?; let target_iid: Option = row.get(3)?; let target_path: Option = row.get(4)?; let title: Option = row.get(5)?; let target_project_id: Option = row.get(6)?; let kind = match (ref_type.as_str(), target_type.as_str()) { ("closes", "merge_request") => CrossRefKind::ClosingMr, ("related", "issue") => CrossRefKind::RelatedIssue, _ => CrossRefKind::MentionedIn, }; let iid = target_iid.unwrap_or(0); let project_id = target_project_id.unwrap_or(key.project_id); let entity_key = match target_type.as_str() { "merge_request" => EntityKey::mr(project_id, iid), _ => EntityKey::issue(project_id, iid), }; let label = title.unwrap_or_else(|| { let prefix = if target_type == "merge_request" { "!" } else { "#" }; let path = target_path.unwrap_or_default(); if path.is_empty() { format!("{prefix}{iid}") } else { format!("{path}{prefix}{iid}") } }); let navigable = target_id.is_some(); Ok(CrossRef { kind, entity_key, label, navigable, }) }) .context("fetching cross-refs")? .collect::, _>>() .context("reading cross-ref row")?; Ok(refs) } /// Fetch discussions for an issue (Phase 2 async load). /// /// Returns `DiscussionNode` tree suitable for the discussion tree widget. pub fn fetch_issue_discussions(conn: &Connection, key: &EntityKey) -> Result> { let issue_id: i64 = conn .query_row( "SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2", rusqlite::params![key.project_id, key.iid], |r| r.get(0), ) .context("looking up issue id")?; let mut disc_stmt = conn .prepare( "SELECT d.id, d.gitlab_discussion_id, d.resolvable, d.resolved FROM discussions d WHERE d.issue_id = ?1 AND d.noteable_type = 'Issue' ORDER BY d.first_note_at ASC, d.id ASC", ) .context("preparing discussions query")?; let mut note_stmt = conn .prepare( "SELECT n.author_username, n.body, n.created_at, n.is_system, n.note_type, n.position_new_path, n.position_new_line FROM notes n WHERE n.discussion_id = ?1 ORDER BY n.position ASC, n.created_at ASC", ) .context("preparing notes query")?; let disc_rows: Vec<_> = disc_stmt .query_map(rusqlite::params![issue_id], |row| { Ok(( row.get::<_, i64>(0)?, // id row.get::<_, String>(1)?, // gitlab_discussion_id row.get::<_, bool>(2)?, // resolvable row.get::<_, bool>(3)?, // resolved )) }) .context("fetching discussions")? .collect::, _>>() .context("reading discussion row")?; let mut discussions = Vec::new(); for (disc_db_id, discussion_id, resolvable, resolved) in disc_rows { let notes: Vec = note_stmt .query_map(rusqlite::params![disc_db_id], |row| { Ok(NoteNode { author: row.get::<_, Option>(0)?.unwrap_or_default(), body: row.get::<_, Option>(1)?.unwrap_or_default(), created_at: row.get(2)?, is_system: row.get(3)?, is_diff_note: row.get::<_, Option>(4)?.as_deref() == Some("DiffNote"), diff_file_path: row.get(5)?, diff_new_line: row.get(6)?, }) }) .context("fetching notes")? .collect::, _>>() .context("reading note row")?; discussions.push(DiscussionNode { discussion_id, notes, resolvable, resolved, }); } Ok(discussions) } #[cfg(test)] mod tests { use super::*; fn create_issue_detail_schema(conn: &Connection) { conn.execute_batch( " CREATE TABLE projects ( id INTEGER PRIMARY KEY, gitlab_project_id INTEGER UNIQUE NOT NULL, path_with_namespace TEXT NOT NULL ); CREATE TABLE issues ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, project_id INTEGER NOT NULL, iid INTEGER NOT NULL, title TEXT NOT NULL, description TEXT, state TEXT NOT NULL DEFAULT 'opened', author_username TEXT, milestone_title TEXT, due_date TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, web_url TEXT, UNIQUE(project_id, iid) ); CREATE TABLE issue_assignees ( issue_id INTEGER NOT NULL, username TEXT NOT NULL, UNIQUE(issue_id, username) ); CREATE TABLE labels ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL, name TEXT NOT NULL ); CREATE TABLE issue_labels ( issue_id INTEGER NOT NULL, label_id INTEGER NOT NULL, UNIQUE(issue_id, label_id) ); CREATE TABLE discussions ( id INTEGER PRIMARY KEY, gitlab_discussion_id TEXT NOT NULL, project_id INTEGER NOT NULL, issue_id INTEGER, merge_request_id INTEGER, noteable_type TEXT NOT NULL, resolvable INTEGER NOT NULL DEFAULT 0, resolved INTEGER NOT NULL DEFAULT 0, first_note_at INTEGER ); CREATE TABLE notes ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, discussion_id INTEGER NOT NULL, project_id INTEGER NOT NULL, note_type TEXT, is_system INTEGER NOT NULL DEFAULT 0, author_username TEXT, body TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, position INTEGER, position_new_path TEXT, position_new_line INTEGER ); CREATE TABLE entity_references ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL, source_entity_type TEXT NOT NULL, source_entity_id INTEGER NOT NULL, target_entity_type TEXT NOT NULL, target_entity_id INTEGER, target_project_path TEXT, target_entity_iid INTEGER, reference_type TEXT NOT NULL, source_method TEXT NOT NULL DEFAULT 'api', created_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE merge_requests ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, project_id INTEGER NOT NULL, iid INTEGER NOT NULL, title TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'opened', UNIQUE(project_id, iid) ); ", ) .unwrap(); } fn setup_issue_detail_data(conn: &Connection) { // Project. conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')", [], ) .unwrap(); // Issue. conn.execute( "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, milestone_title, due_date, created_at, updated_at, web_url) VALUES (1, 1000, 1, 42, 'Fix authentication flow', 'Detailed description here', 'opened', 'alice', 'v1.0', '2026-03-01', 1700000000000, 1700000060000, 'https://gitlab.com/group/project/-/issues/42')", [], ) .unwrap(); // Assignees. conn.execute( "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'bob')", [], ) .unwrap(); conn.execute( "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'charlie')", [], ) .unwrap(); // Labels. conn.execute( "INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')", [], ) .unwrap(); conn.execute( "INSERT INTO labels (id, project_id, name) VALUES (2, 1, 'urgent')", [], ) .unwrap(); conn.execute( "INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)", [], ) .unwrap(); conn.execute( "INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 2)", [], ) .unwrap(); // Discussions + notes. conn.execute( "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, resolvable, resolved, first_note_at) VALUES (1, 'disc-aaa', 1, 1, 'Issue', 0, 0, 1700000010000)", [], ) .unwrap(); conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, position, is_system, note_type) VALUES (1, 10001, 1, 1, 'alice', 'This looks good overall', 1700000010000, 1700000010000, 0, 0, 'DiscussionNote')", [], ) .unwrap(); conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, position, is_system, note_type) VALUES (2, 10002, 1, 1, 'bob', 'Agreed, but see my comment below', 1700000020000, 1700000020000, 1, 0, 'DiscussionNote')", [], ) .unwrap(); // System note discussion. conn.execute( "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at) VALUES (2, 'disc-bbb', 1, 1, 'Issue', 1700000030000)", [], ) .unwrap(); conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, position, is_system, note_type) VALUES (3, 10003, 2, 1, 'system', 'changed the description', 1700000030000, 1700000030000, 0, 1, NULL)", [], ) .unwrap(); // Closing MR cross-ref. conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state) VALUES (1, 2000, 1, 10, 'Fix auth MR', 'opened')", [], ) .unwrap(); conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_entity_iid, reference_type) VALUES (1, 'issue', 1, 'merge_request', 1, 10, 'closes')", [], ) .unwrap(); } #[test] fn test_fetch_issue_detail_basic() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let data = fetch_issue_detail(&conn, &key).unwrap(); assert_eq!(data.metadata.iid, 42); assert_eq!(data.metadata.title, "Fix authentication flow"); assert_eq!(data.metadata.state, "opened"); assert_eq!(data.metadata.author, "alice"); assert_eq!(data.metadata.project_path, "group/project"); assert_eq!(data.metadata.milestone, Some("v1.0".to_string())); assert_eq!(data.metadata.due_date, Some("2026-03-01".to_string())); assert_eq!( data.metadata.web_url, "https://gitlab.com/group/project/-/issues/42" ); } #[test] fn test_fetch_issue_detail_assignees() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let data = fetch_issue_detail(&conn, &key).unwrap(); assert_eq!(data.metadata.assignees.len(), 2); assert!(data.metadata.assignees.contains(&"bob".to_string())); assert!(data.metadata.assignees.contains(&"charlie".to_string())); } #[test] fn test_fetch_issue_detail_labels() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let data = fetch_issue_detail(&conn, &key).unwrap(); assert_eq!(data.metadata.labels, vec!["backend", "urgent"]); } #[test] fn test_fetch_issue_detail_cross_refs() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let data = fetch_issue_detail(&conn, &key).unwrap(); assert_eq!(data.cross_refs.len(), 1); assert_eq!(data.cross_refs[0].kind, CrossRefKind::ClosingMr); assert_eq!(data.cross_refs[0].entity_key, EntityKey::mr(1, 10)); assert_eq!(data.cross_refs[0].label, "Fix auth MR"); assert!(data.cross_refs[0].navigable); } #[test] fn test_fetch_issue_detail_discussion_count() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let data = fetch_issue_detail(&conn, &key).unwrap(); assert_eq!(data.metadata.discussion_count, 2); } #[test] fn test_fetch_issue_discussions_basic() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let discussions = fetch_issue_discussions(&conn, &key).unwrap(); assert_eq!(discussions.len(), 2); } #[test] fn test_fetch_issue_discussions_notes() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let discussions = fetch_issue_discussions(&conn, &key).unwrap(); // First discussion has 2 notes. assert_eq!(discussions[0].notes.len(), 2); assert_eq!(discussions[0].notes[0].author, "alice"); assert_eq!(discussions[0].notes[0].body, "This looks good overall"); assert_eq!(discussions[0].notes[1].author, "bob"); assert!(!discussions[0].notes[0].is_system); } #[test] fn test_fetch_issue_discussions_system_note() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let discussions = fetch_issue_discussions(&conn, &key).unwrap(); // Second discussion is a system note. assert_eq!(discussions[1].notes.len(), 1); assert!(discussions[1].notes[0].is_system); assert_eq!(discussions[1].notes[0].body, "changed the description"); } #[test] fn test_fetch_issue_discussions_ordering() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 42); let discussions = fetch_issue_discussions(&conn, &key).unwrap(); // Ordered by first_note_at. assert_eq!(discussions[0].discussion_id, "disc-aaa"); assert_eq!(discussions[1].discussion_id, "disc-bbb"); } #[test] fn test_fetch_issue_detail_not_found() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); setup_issue_detail_data(&conn); let key = EntityKey::issue(1, 999); let result = fetch_issue_detail(&conn, &key); assert!(result.is_err()); } #[test] fn test_fetch_issue_detail_no_description() { let conn = Connection::open_in_memory().unwrap(); create_issue_detail_schema(&conn); conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'g/p')", [], ) .unwrap(); conn.execute( "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, created_at, updated_at) VALUES (1, 1000, 1, 1, 'No desc', NULL, 'opened', 0, 0)", [], ) .unwrap(); let key = EntityKey::issue(1, 1); let data = fetch_issue_detail(&conn, &key).unwrap(); assert_eq!(data.metadata.description, ""); } }